diff --git a/.editorconfig b/.editorconfig index 0979bd12c..bd7557884 100644 --- a/.editorconfig +++ b/.editorconfig @@ -272,9 +272,9 @@ csharp_new_line_between_query_expression_clauses = true csharp_indent_block_contents = true csharp_indent_braces = false csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = true +csharp_indent_case_contents_when_block = false csharp_indent_switch_labels = true -csharp_indent_labels = flush_left +csharp_indent_labels = no_change # Whitespace options csharp_style_allow_embedded_statements_on_same_line_experimental = false diff --git a/.github/workflows/ReplaceTmdbApiKey.ps1 b/.github/workflows/ReplaceTmdbApiKey.ps1 new file mode 100644 index 000000000..fda2d85bc --- /dev/null +++ b/.github/workflows/ReplaceTmdbApiKey.ps1 @@ -0,0 +1,10 @@ +Param( + [string] $apiKey = "TMDB_API_KEY_GOES_HERE" +) + +$filename = "./Shoko.Server/Server/Constants.cs" +$searchString = "TMDB_API_KEY_GOES_HERE" + +(Get-Content $filename) | ForEach-Object { + $_ -replace $searchString, $apiKey +} | Set-Content $filename diff --git a/.github/workflows/build-daily.yml b/.github/workflows/build-daily.yml index 2352c4149..6a11843bb 100644 --- a/.github/workflows/build-daily.yml +++ b/.github/workflows/build-daily.yml @@ -5,6 +5,9 @@ on: branches: - master +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + jobs: current_info: runs-on: ubuntu-latest @@ -12,32 +15,49 @@ jobs: name: Current Information outputs: + tag: ${{ steps.release_info.outputs.tag }} version: ${{ steps.release_info.outputs.version }} date: ${{ steps.commit_date_iso8601.outputs.date }} sha: ${{ github.sha }} sha_short: ${{ steps.commit_info.outputs.sha }} steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.sha }}" submodules: recursive fetch-depth: 0 # This is set to download the full git history for the repo + - name: Get Commit Date (as ISO8601) + id: commit_date_iso8601 + shell: bash + env: + TZ: UTC0 + run: | + echo "date=$(git --no-pager show -s --date='format-local:%Y-%m-%dT%H:%M:%SZ' --format=%cd ${{ github.sha }})" >> "$GITHUB_OUTPUT" + + - name: Get Previous Version + id: previous_release_info + uses: revam/gh-action-get-tag-and-version@v1 + with: + branch: false + prefix: "v" + prefixRegex: "[vV]?" + suffixRegex: "dev" + suffix: "dev" + - name: Get Current Version id: release_info uses: revam/gh-action-get-tag-and-version@v1 with: - branch: true - prefix: v + branch: false + increment: "suffix" + prefix: "v" prefixRegex: "[vV]?" + suffixRegex: "dev" + suffix: "dev" - - name: Get Commit Date (as ISO8601) - id: commit_date_iso8601 - shell: bash - run: | - echo "date=$(git --no-pager show -s --format=%aI ${{ github.sha }})" >> "$GITHUB_OUTPUT" - id: commit_info name: Shorten Commit Hash uses: actions/github-script@v6 @@ -67,7 +87,7 @@ jobs: name: Build CLI — ${{ matrix.build_type }} ${{ matrix.rid }} (Daily) steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.sha }}" @@ -77,6 +97,7 @@ jobs: shell: pwsh run: | ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + ./.github/workflows/ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} ./.github/workflows/ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - name: Set up QEMU @@ -90,12 +111,12 @@ jobs: with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet publish -c Release -r ${{ matrix.rid }} -f net8.0 ${{ matrix.build_props }} Shoko.CLI /p:Version="${{ needs.current_info.outputs.version }}" /p:InformationalVersion="\"channel=dev,commit=${{ needs.current_info.outputs.sha }},date=${{ needs.current_info.outputs.date }},\"" + - run: dotnet publish -c Release -r ${{ matrix.rid }} -f net8.0 ${{ matrix.build_props }} Shoko.CLI /p:Version="${{ needs.current_info.outputs.version }}" /p:InformationalVersion="\"channel=dev,commit=${{ needs.current_info.outputs.sha }},tag=${{ needs.current_info.outputs.tag }},date=${{ needs.current_info.outputs.date }},\"" - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: Shoko.CLI_${{ matrix.build_type }}_${{ matrix.rid }}.zip + name: Shoko.CLI_${{ matrix.build_type }}_${{ matrix.rid }} path: Shoko.Server/bin/Release/net8.0/${{matrix.rid}}/publish/ tray-service-daily: @@ -109,7 +130,7 @@ jobs: dotnet: [ '8.x' ] build_type: ['Standalone', 'Framework'] include: - - build_props: '-r win-x64 --self-contained true -f net8.0-windows' + - build_props: '-r win-x64 --self-contained true' build_type: 'Standalone' - build_dir: '/net8.0-windows/win-x64' build_type: 'Standalone' @@ -121,7 +142,7 @@ jobs: name: Build Tray Service ${{ matrix.build_type }} (Daily) steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.sha }}" @@ -131,6 +152,7 @@ jobs: shell: pwsh run: | .\\.github\\workflows\\ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + .\\.github\\workflows\\ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} .\\.github\\workflows\\ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - name: Setup dotnet @@ -138,26 +160,20 @@ jobs: with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet publish -c Release ${{ matrix.build_props }} Shoko.TrayService /p:Version="${{ needs.current_info.outputs.version }}" /p:InformationalVersion="channel=dev%2ccommit=${{ needs.current_info.outputs.sha }}%2cdate=${{ needs.current_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh + - run: dotnet publish -c Release ${{ matrix.build_props }} Shoko.TrayService /p:Version="${{ needs.current_info.outputs.version }}" /p:InformationalVersion="channel=dev%2ccommit=${{ needs.current_info.outputs.sha }}%2ctag=${{ needs.current_info.outputs.tag }}%2cdate=${{ needs.current_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: Shoko.TrayService_${{ matrix.build_type }}_win-x64.zip + name: Shoko.TrayService_${{ matrix.build_type }}_win-x64 path: Shoko.Server/bin/Release${{ matrix.build_dir }}/publish/ - - name: Upload to shokoanime.com - if: ${{ matrix.build_type == 'Standalone' }} - shell: pwsh - env: - FTP_USERNAME: ${{ secrets.FTP_USERNAME }} - FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} - FTP_SERVER: ${{ secrets.FTP_SERVER }} - run : Compress-Archive .\\Shoko.Server\\bin\\Release\\net8.0-windows\\win-x64\\publish .\\ShokoServer.zip && .\\.github\\workflows\\UploadArchive.ps1 - docker-daily-build: runs-on: ubuntu-latest + needs: + - current_info + strategy: fail-fast: false matrix: @@ -170,11 +186,8 @@ jobs: name: Build Docker Image - ${{ matrix.arch }} (Daily) - needs: - - current_info - steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.sha }}" @@ -184,6 +197,7 @@ jobs: shell: pwsh run: | ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + ./.github/workflows/ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} ./.github/workflows/ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - uses: docker/setup-qemu-action@v2 @@ -226,6 +240,7 @@ jobs: channel=dev commit=${{ needs.current_info.outputs.sha }} date=${{ needs.current_info.outputs.date }} + tag=${{ needs.current_info.outputs.tag }} provenance: false docker-daily-push_manifest: @@ -260,7 +275,7 @@ jobs: docker manifest push ghcr.io/${{ secrets.DOCKER_REPO }}:daily docker manifest push ${{ secrets.DOCKER_REPO }}:daily - sentry-upload: + add-tag: runs-on: ubuntu-latest needs: @@ -269,17 +284,39 @@ jobs: - tray-service-daily - docker-daily-push_manifest + name: Add tag for pre-release + + steps: + - name: Checkout "${{ github.ref }}" + uses: actions/checkout@master + with: + ref: "${{ github.sha }}" + submodules: recursive + + - name: Push pre-release tag + uses: rickstaa/action-create-tag@v1 + with: + tag: ${{ needs.current_info.outputs.tag }} + message: Shoko Server v${{ needs.current_info.outputs.version }} Pre-Release + + sentry-upload: + runs-on: ubuntu-latest + + needs: + - current_info + - add-tag + name: Upload version info to Sentry.io steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.sha }}" submodules: recursive # Only add the release to sentry if the build is successful. - - name: Push Sentry Release "${{ needs.current_info.outputs.version }}-dev-${{ needs.current_info.outputs.sha_short }}" + - name: Push Sentry Release "${{ needs.current_info.outputs.version }}" uses: getsentry/action-release@v1.2.1 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -288,7 +325,36 @@ jobs: # SENTRY_URL: https://sentry.io/ with: environment: 'dev' - version: ${{ needs.current_info.outputs.version }}-dev-${{ needs.current_info.outputs.sha_short }} + version: ${{ needs.current_info.outputs.version }} + + upload-site: + runs-on: windows-latest + continue-on-error: true + + needs: + - sentry-upload + + name: Upload archive to site + + steps: + - name: Checkout "${{ github.ref }}" + uses: actions/checkout@master + with: + ref: "${{ github.sha }}" + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: Shoko.TrayService_Standalone_win-x64 + path: ShokoServer + + - name: Upload daily archive to site + shell: pwsh + env: + FTP_USERNAME: ${{ secrets.FTP_USERNAME }} + FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} + FTP_SERVER: ${{ secrets.FTP_SERVER }} + run : Compress-Archive .\\ShokoServer .\\ShokoServer.zip && .\\.github\\workflows\\UploadArchive.ps1 discord-notify: runs-on: ubuntu-latest @@ -306,7 +372,7 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - RELEASE_VERSION: ${{ needs.current_info.outputs.version }}-dev-${{ needs.current_info.outputs.sha_short }} + RELEASE_VERSION: ${{ needs.current_info.outputs.version }} run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" @@ -326,7 +392,7 @@ jobs: embed-author-icon-url: https://raw.githubusercontent.com/${{ github.repository }}/master/.github/images/Shoko.png embed-author-url: https://github.com/${{ github.repository }} embed-description: | - **Version**: `${{ needs.current_info.outputs.version }}-dev-${{ needs.current_info.outputs.sha_short }}` + **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) Update by grabbing the latest daily from [our site](https://shokoanime.com/downloads/shoko-server) or through Docker using the `shokoanime/server:daily` tag! diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 2d7e8821a..b10fcde1f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -5,32 +5,39 @@ on: types: - released - jobs: - cli-framework-stable: + current_info: runs-on: ubuntu-latest - strategy: - matrix: - dotnet: [ '8.x' ] + name: Current Information - name: Build CLI — Framework dependent (Stable) + outputs: + tag: ${{ steps.release_info.outputs.tag }} + tag_major: v${{ steps.release_info.outputs.version_major }} + tag_minor: v${{ steps.release_info.outputs.version_major }}.${{ steps.release_info.outputs.version_minor }} + version: ${{ steps.release_info.outputs.version }} + version_short: ${{ steps.release_info.outputs.version_short }} + date: ${{ steps.commit_date_iso8601.outputs.date }} + sha: ${{ github.sha }} + sha_short: ${{ steps.commit_info.outputs.sha }} steps: - - name: Checkout master + - name: Checkout "${{ github.sha }}" uses: actions/checkout@master with: - ref: "${{ github.ref }}" + ref: "${{ github.sha }}" submodules: recursive fetch-depth: 0 # This is set to download the full git history for the repo - - name: Replace Sentry DSN and other keys - shell: pwsh + - name: Get Commit Date (as ISO8601) + id: commit_date_iso8601 + shell: bash + env: + TZ: UTC0 run: | - ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} - ./.github/workflows/ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} + echo "date=$(git --no-pager show -s --date='format-local:%Y-%m-%dT%H:%M:%SZ' --format=%cd ${{ github.sha }})" >> "$GITHUB_OUTPUT" - - name: Get release version + - name: Get Current Version id: release_info uses: revam/gh-action-get-tag-and-version@v1 with: @@ -38,57 +45,54 @@ jobs: prefix: v prefixRegex: "[vV]?" - - name: Setup dotnet - uses: actions/setup-dotnet@v3 + - id: commit_info + name: Shorten Commit Hash + uses: actions/github-script@v6 with: - dotnet-version: ${{ matrix.dotnet }} + script: | + const sha = context.sha.substring(0, 7); + core.setOutput("sha", sha); - - run: dotnet publish -c Release --no-self-contained Shoko.CLI /p:Version="${{ steps.release_info.outputs.version }}" /p:InformationalVersion="\"channel=stable,commit=${{ github.sha }},tag=${{ steps.release_info.outputs.tag }},date=${{ steps.release_info.outputs.date }},\"" - - - name: Archive Release - shell: pwsh - run: Compress-Archive .\\Shoko.Server\\bin\\Release\\net8.0\\publish .\\Shoko.CLI_Framework_any-x64.zip - - - name: Upload Release - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ./Shoko.CLI*.zip - tag: ${{ steps.release_info.outputs.tag }} - file_glob: true - - cli-standalone-stable: + cli-release: runs-on: ubuntu-latest + needs: + - current_info + strategy: matrix: - rid: ['win-x64', 'linux-x64', 'linux-arm64'] - dotnet: [ '8.x' ] - - name: Build CLI — Standalone ${{ matrix.rid }} (Stable) + dotnet: + - '8.x' + include: + - build_type: 'Standalone' + build_props: '' + display_id: 'linux-x64' + rid: 'linux-x64' + - build_type: 'Standalone' + build_props: '' + display_id: 'linux-arm64' + rid: 'linux-arm64' + - build_type: 'Framework' + build_props: '--no-self-contained' + display_id: 'any-x64' + rid: 'linux-x64' + + name: Build CLI — ${{ matrix.build_type }} ${{ matrix.display_id }} (Release) steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: - ref: "${{ github.ref }}" + ref: "${{ github.sha }}" submodules: recursive - fetch-depth: 0 # This is set to download the full git history for the repo - name: Replace Sentry DSN and other keys shell: pwsh run: | ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + ./.github/workflows/ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} ./.github/workflows/ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - - name: Get release version - id: release_info - uses: revam/gh-action-get-tag-and-version@v1 - with: - tag: "${{ github.ref }}" - prefix: v - prefixRegex: "[vV]?" - - name: Set up QEMU uses: docker/setup-qemu-action@v2 with: @@ -100,67 +104,53 @@ jobs: with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet publish -c Release -r ${{ matrix.rid }} Shoko.CLI /p:Version="${{ steps.release_info.outputs.version }}" /p:InformationalVersion="\"channel=stable,commit=${{ github.sha }},tag=${{ steps.release_info.outputs.tag }},date=${{ steps.release_info.outputs.date }},\"" + - run: dotnet publish -c Release -r ${{ matrix.rid }} -f net8.0 ${{ matrix.build_props }} Shoko.CLI /p:Version="${{ needs.current_info.outputs.version }}" /p:InformationalVersion="\"channel=stable,commit=${{ needs.current_info.outputs.sha }},tag=${{ needs.current_info.outputs.tag }},date=${{ needs.current_info.outputs.date }},\"" - - name: Archive Release (${{ matrix.rid }}) + - name: Archive Release shell: pwsh - run: Compress-Archive .\\Shoko.Server\\bin\\Release\\net8.0\\${{ matrix.rid }}\\publish .\\Shoko.CLI_Standalone_${{ matrix.rid }}.zip + run: Compress-Archive .\\Shoko.Server\\bin\\Release\\net8.0\\publish .\\Shoko.CLI_${{ matrix.build_type }}_${{ matrix.display_id }}.zip - - name: Upload Release (${{ matrix.rid }}) + - name: Upload Release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ./Shoko.CLI*.zip - tag: ${{ steps.release_info.outputs.tag }} + tag: ${{ needs.current_info.outputs.tag }} file_glob: true - - name: Upload Artifact to shokoanime.com - if: ${{ matrix.rid != 'win-x64' }} - shell: pwsh - env: - FTP_USERNAME: ${{ secrets.FTP_USERNAME }} - FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} - FTP_SERVER: ${{ secrets.FTP_SERVER }} - run: - .\\.github\\workflows\\UploadRelease.ps1 -remote "ShokoServer-${{ steps.release_info.outputs.version_short }}-${{ matrix.rid }}.zip" -local "Shoko.CLI_Standalone_${{ matrix.rid }}.zip"; + tray-service-framework: + runs-on: windows-latest - tray-service-framework-stable: - runs-on: windows-2022 + needs: + - current_info strategy: matrix: - dotnet: [ '8.x' ] + dotnet: + - '8.x' name: Build Tray Service — Framework dependent (Stable) steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.ref }}" submodules: recursive - fetch-depth: 0 # This is set to download the full git history for the repo - name: Replace Sentry DSN and other keys shell: pwsh run: | .\\.github\\workflows\\ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + .\\.github\\workflows\\ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} .\\.github\\workflows\\ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - - name: Get release version - id: release_info - uses: revam/gh-action-get-tag-and-version@v1 - with: - tag: "${{ github.ref }}" - prefix: v - prefixRegex: "[vV]?" - - name: Setup dotnet uses: actions/setup-dotnet@v3 with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet publish -c Release -r win-x64 --no-self-contained Shoko.TrayService /p:Version="${{ steps.release_info.outputs.version }}" /p:InformationalVersion="channel=stable%2ccommit=${{ github.sha }}%2ctag=${{ steps.release_info.outputs.tag }}%2cdate=${{ steps.release_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh + - run: dotnet publish -c Release -r win-x64 --no-self-contained Shoko.TrayService /p:Version="${{ needs.current_info.outputs.version }}" /p:InformationalVersion="channel=stable%2ccommit=${{ github.sha }}%2ctag=${{ needs.current_info.outputs.tag }}%2cdate=${{ needs.current_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh - name: Archive Release shell: pwsh @@ -171,46 +161,42 @@ jobs: with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ./Shoko.TrayService*.zip - tag: ${{ steps.release_info.outputs.tag }} + tag: ${{ needs.current_info.outputs.tag }} file_glob: true - tray-service-standalone-stable: - runs-on: windows-2022 + tray-service-installer: + runs-on: windows-latest + + needs: + - current_info strategy: matrix: - dotnet: [ '8.x' ] + dotnet: + - '8.x' - name: Build Tray Service — Standalone (Stable) + name: Build Tray Service — Installer (Stable) steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.ref }}" submodules: recursive - fetch-depth: 0 # This is set to download the full git history for the repo - name: Replace Sentry DSN and other keys shell: pwsh run: | .\\.github\\workflows\\ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + .\\.github\\workflows\\ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} .\\.github\\workflows\\ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - - name: Get release version - id: release_info - uses: revam/gh-action-get-tag-and-version@v1 - with: - tag: "${{ github.ref }}" - prefix: v - prefixRegex: "[vV]?" - - name: Setup dotnet uses: actions/setup-dotnet@v3 with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet publish -c Release -r win-x64 --self-contained true -f net8.0-windows Shoko.TrayService /p:Version="${{ steps.release_info.outputs.version }}" /p:InformationalVersion="channel=stable%2ccommit=${{ github.sha }}%2ctag=${{ steps.release_info.outputs.tag }}%2cdate=${{ steps.release_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh + - run: dotnet publish -c Release -r win-x64 --self-contained true Shoko.TrayService /p:Version="${{ needs.current_info.outputs.version }}" /p:InformationalVersion="channel=stable%2ccommit=${{ github.sha }}%2ctag=${{ needs.current_info.outputs.tag }}%2cdate=${{ needs.current_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh - name: Archive Release shell: pwsh @@ -221,7 +207,7 @@ jobs: with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ./Shoko.TrayService*.zip - tag: ${{ steps.release_info.outputs.tag }} + tag: ${{ needs.current_info.outputs.tag }} file_glob: true - name: Build Installer @@ -232,46 +218,162 @@ jobs: with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ./Shoko.Setup.exe - tag: ${{ steps.release_info.outputs.tag }} + tag: ${{ needs.current_info.outputs.tag }} file_glob: true - - name: Upload Installer to shokoanime.com + - name: Upload Installer to site shell: pwsh env: FTP_USERNAME: ${{ secrets.FTP_USERNAME }} FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} FTP_SERVER: ${{ secrets.FTP_SERVER }} run: - .\\.github\\workflows\\UploadRelease.ps1 -remote "ShokoServer-${{ steps.release_info.outputs.version_short }}-Win.exe" -local "Shoko.Setup.exe"; + .\\.github\\workflows\\UploadRelease.ps1 -remote "ShokoServer-${{ needs.current_info.outputs.version_short }}-Win.exe" -local "Shoko.Setup.exe"; - sentry-upload: + docker-release-build: runs-on: ubuntu-latest needs: - - cli-framework-stable - - cli-standalone-stable - - tray-service-framework-stable - - tray-service-standalone-stable + - current_info - name: Upload version info to Sentry.io + strategy: + fail-fast: false + matrix: + include: + - arch: 'amd64' + dockerfile: 'Dockerfile' + + - arch: 'arm64' + dockerfile: 'Dockerfile.aarch64' + + name: Build Docker Image - ${{ matrix.arch }} (Release) steps: - - name: Checkout master + - name: Checkout "${{ github.ref }}" uses: actions/checkout@master with: ref: "${{ github.ref }}" submodules: recursive - fetch-depth: 0 # This is set to download the full git history for the repo - - name: Get release version - id: release_info - uses: revam/gh-action-get-tag-and-version@v1 + - name: Replace Sentry DSN and other keys + shell: pwsh + run: | + ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + ./.github/workflows/ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} + ./.github/workflows/ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} + + - uses: docker/setup-qemu-action@v2 + name: Set up QEMU with: - tag: "${{ github.ref }}" - prefix: v - prefixRegex: "[vV]?" + platforms: arm64 + if: ${{ matrix.arch == 'arm64' }} + + - uses: docker/setup-buildx-action@v2 + name: Set up Docker Buildx + + - uses: docker/login-action@v2 + name: Log into GitHub Container Registry + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/login-action@v2 + name: Log into Docker Hub + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Disabled provenance for now, until it works with docker manifest create. + # The manifest list produced by the new feature is incompatible with the + # expected format used in the docker manifest create command. + - uses: docker/build-push-action@v4 + name: Build and Push the Docker image + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: | + ghcr.io/${{ secrets.DOCKER_REPO }}:latest-${{ matrix.arch }} + ghcr.io/${{ secrets.DOCKER_REPO }}:${{ needs.current_info.outputs.tag }}-${{ matrix.arch }} + ghcr.io/${{ secrets.DOCKER_REPO }}:${{ needs.current_info.outputs.tag_major }}-${{ matrix.arch }} + ghcr.io/${{ secrets.DOCKER_REPO }}:${{ needs.current_info.outputs.tag_minor }}-${{ matrix.arch }} + ${{ secrets.DOCKER_REPO }}:latest-${{ matrix.arch }} + ${{ secrets.DOCKER_REPO }}:${{ needs.current_info.outputs.tag }}-${{ matrix.arch }} + ${{ secrets.DOCKER_REPO }}:${{ needs.current_info.outputs.tag_major }}-${{ matrix.arch }} + ${{ secrets.DOCKER_REPO }}:${{ needs.current_info.outputs.tag_minor }}-${{ matrix.arch }} + platforms: linux/${{ matrix.arch }} + build-args: | + version=${{ needs.current_info.outputs.version }} + channel=stable + commit=${{ github.sha }} + date=${{ needs.current_info.outputs.date }} + tag=${{ needs.current_info.outputs.tag }} + provenance: false + + docker-release-push_manifest: + runs-on: ubuntu-latest + + needs: + - current_info + - docker-release-build + + name: Push combined tag "${{ matrix.tag }}" for both images + + strategy: + fail-fast: false + matrix: + tag: + - latest + - ${{ needs.current_info.outputs.tag }} + - ${{ needs.current_info.outputs.tag_major }} + - ${{ needs.current_info.outputs.tag_minor }} + + steps: + - uses: docker/login-action@v2 + name: Log into GitHub Container Registry + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/login-action@v2 + name: Log into Docker Hub + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Create manifest + run: | + docker manifest create ghcr.io/${{ secrets.DOCKER_REPO }}:${{ matrix.tag }} --amend ghcr.io/${{ secrets.DOCKER_REPO }}:${{ matrix.tag }}-amd64 --amend ghcr.io/${{ secrets.DOCKER_REPO }}:${{ matrix.tag }}-arm64 + docker manifest create ${{ secrets.DOCKER_REPO }}:${{ matrix.tag }} --amend ${{ secrets.DOCKER_REPO }}:${{ matrix.tag }}-amd64 --amend ${{ secrets.DOCKER_REPO }}:${{ matrix.tag }}-arm64 + + - name: Push manifest + run: | + docker manifest push ghcr.io/${{ secrets.DOCKER_REPO }}:${{ matrix.tag }} + docker manifest push ${{ secrets.DOCKER_REPO }}:${{ matrix.tag }} + + sentry-upload: + runs-on: ubuntu-latest + + needs: + - current_info + - cli-release + - tray-service-framework + - tray-service-installer + - docker-release-build + - docker-release-push_manifest + + name: Upload version info to Sentry.io + + steps: + - name: Checkout "${{ github.ref }}" + uses: actions/checkout@master + with: + ref: "${{ github.ref }}" + submodules: recursive - - name: Push Sentry release "${{ steps.release_info.outputs.version }}" + - name: Push Sentry release "${{ needs.current_info.outputs.version }}" uses: getsentry/action-release@v1.2.1 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -280,4 +382,4 @@ jobs: # SENTRY_URL: https://sentry.io/ with: environment: 'stable' - version: ${{ steps.release_info.outputs.version }} + version: ${{ needs.current_info.outputs.version }} diff --git a/.github/workflows/cleanup-docker-images.yml b/.github/workflows/cleanup-docker-images.yml new file mode 100644 index 000000000..15819e683 --- /dev/null +++ b/.github/workflows/cleanup-docker-images.yml @@ -0,0 +1,18 @@ +name: Cleanup untagged docker images + +on: + workflow_dispatch: + schedule: + # Schedule to run at 00:50 every Sunday + - cron: '50 0 * * 0' + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/delete-package-versions@v5 + with: + package-name: 'server' + package-type: 'container' + min-versions-to-keep: 50 + delete-only-untagged-versions: 'true' diff --git a/.github/workflows/docker-manual.yml b/.github/workflows/docker-manual.yml index 8f16fe703..3799d70fd 100644 --- a/.github/workflows/docker-manual.yml +++ b/.github/workflows/docker-manual.yml @@ -44,6 +44,7 @@ jobs: shell: pwsh run: | ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + ./.github/workflows/ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} ./.github/workflows/ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - uses: docker/setup-qemu-action@v2 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml deleted file mode 100644 index 27969be2f..000000000 --- a/.github/workflows/docker-release.yml +++ /dev/null @@ -1,160 +0,0 @@ -name: Publish to Docker Hub (Release) - -on: - release: - types: - - published - branches: master - -jobs: - docker-release-build: - name: Build docker image - strategy: - matrix: - include: - - arch: 'amd64' - dockerfile: 'Dockerfile' - - - arch: 'arm64' - dockerfile: 'Dockerfile.aarch64' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@master - with: - ref: "${{ github.ref }}" - submodules: recursive - fetch-depth: 0 # This is set to download the full git history for the repo - - - name: Replace Sentry DSN and other keys - shell: pwsh - run: | - ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} - ./.github/workflows/ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - - - name: Get release info - id: release_info - uses: revam/gh-action-get-tag-and-version@v1 - with: - tag: "${{ github.ref }}" - prefix: v - prefixRegex: "[vV]?" - - - uses: docker/setup-qemu-action@v2 - name: Set up QEMU - with: - platforms: arm64 - if: ${{ matrix.arch == 'arm64' }} - - - uses: docker/setup-buildx-action@v2 - name: Set up Docker Buildx - - - uses: docker/login-action@v2 - name: Log into GitHub Container Registry - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/login-action@v2 - name: Log into Docker Hub - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - # Disabled provenance for now, until it works with docker manifest create. - # The manifest list produced by the new feature is incompatible with the - # expected format used in the docker manifest create command. - - uses: docker/build-push-action@v4 - name: Build and Push the Docker image - with: - context: . - file: ${{ matrix.dockerfile }} - push: true - tags: | - ghcr.io/${{ secrets.DOCKER_REPO }}:latest-${{ matrix.arch }} - ghcr.io/${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }}-${{ matrix.arch }} - ${{ secrets.DOCKER_REPO }}:latest-${{ matrix.arch }} - ${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }}-${{ matrix.arch }} - platforms: linux/${{ matrix.arch }} - build-args: | - version=${{ steps.release_info.outputs.version }} - channel=stable - commit=${{ github.sha }} - date=${{ steps.release_info.outputs.date }} - tag=${{ steps.release_info.outputs.tag }} - provenance: false - - docker-release-push_manifest_latest: - runs-on: ubuntu-latest - name: Push combined latest tag for both images - needs: - - docker-release-build - - steps: - - uses: docker/login-action@v2 - name: Log into GitHub Container Registry - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/login-action@v2 - name: Log into Docker Hub - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Create manifest - run: | - docker manifest create ghcr.io/${{ secrets.DOCKER_REPO }}:latest --amend ghcr.io/${{ secrets.DOCKER_REPO }}:latest-amd64 --amend ghcr.io/${{ secrets.DOCKER_REPO }}:latest-arm64 - docker manifest create ${{ secrets.DOCKER_REPO }}:latest --amend ${{ secrets.DOCKER_REPO }}:latest-amd64 --amend ${{ secrets.DOCKER_REPO }}:latest-arm64 - - - name: Push manifest - run: | - docker manifest push ghcr.io/${{ secrets.DOCKER_REPO }}:latest - docker manifest push ${{ secrets.DOCKER_REPO }}:latest - - docker-release-push_manifest_version: - runs-on: ubuntu-latest - name: Push combined versioned tag for both images - needs: - - docker-release-build - - steps: - - uses: actions/checkout@master - with: - ref: "${{ github.ref }}" - submodules: recursive - fetch-depth: 0 # This is set to download the full git history for the repo - - - name: Get release info - id: release_info - uses: revam/gh-action-get-tag-and-version@v1 - with: - tag: "${{ github.ref }}" - prefix: v - prefixRegex: "[vV]?" - - - uses: docker/login-action@v2 - name: Log into GitHub Container Registry - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/login-action@v2 - name: Log into Docker Hub - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Create manifest - run: | - docker manifest create ghcr.io/${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }} --amend ghcr.io/${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }}-amd64 --amend ghcr.io/${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }}-arm64 - docker manifest create ${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }} --amend ${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }}-amd64 --amend ${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }}-arm64 - - - name: Push manifest - run: | - docker manifest push ghcr.io/${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }} - docker manifest push ${{ secrets.DOCKER_REPO }}:${{ steps.release_info.outputs.tag }} diff --git a/.github/workflows/issue-no-response.yml b/.github/workflows/issue-no-response.yml index eafdc86fd..d3cc853bd 100644 --- a/.github/workflows/issue-no-response.yml +++ b/.github/workflows/issue-no-response.yml @@ -6,8 +6,8 @@ on: issue_comment: types: [created] schedule: - # Schedule for five minutes after the hour, every 6th hour - - cron: '5 */6 * * *' + # Schedule to run at 00:50 every Monday + - cron: '50 0 * * 1' jobs: noResponse: diff --git a/.github/workflows/tray-standalone-manual.yml b/.github/workflows/tray-standalone-manual.yml index 69c8fefe6..4ada685df 100644 --- a/.github/workflows/tray-standalone-manual.yml +++ b/.github/workflows/tray-standalone-manual.yml @@ -38,6 +38,7 @@ jobs: shell: pwsh run: | .\\.github\\workflows\\ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + .\\.github\\workflows\\ReplaceTmdbApiKey.ps1 -apiKey ${{ secrets.TMDB_API }} .\\.github\\workflows\\ReplaceAVD3URL.ps1 -url ${{ secrets.AVD3_URL }} - name: Get release info @@ -53,7 +54,7 @@ jobs: with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet publish -c Release -r win-x64 --self-contained true -f net8.0-windows Shoko.TrayService /p:Version="${{ steps.release_info.outputs.version }}" /p:InformationalVersion="channel=${{ github.event.inputs.release }}%2ccommit=${{ github.sha }}%2cdate=${{ steps.release_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh + - run: dotnet publish -c Release -r win-x64 --self-contained true Shoko.TrayService /p:Version="${{ steps.release_info.outputs.version }}" /p:InformationalVersion="channel=${{ github.event.inputs.release }}%2ccommit=${{ github.sha }}%2cdate=${{ steps.release_info.outputs.date }}%2c" # %2c is comma, blame windows/pwsh - uses: actions/upload-artifact@v3 with: diff --git a/.vscode/launch.json b/.vscode/launch.json index 1275ae579..341883b4b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,11 +14,12 @@ "args": [], "cwd": "${workspaceFolder}/Shoko.Server/bin/Debug/net8.0", "env": { - "SHOKO_HOME": "${workspaceFolder}/Shoko.Server/bin/Debug/net8.0/data" + "SHOKO_HOME": "${workspaceFolder}/Shoko.Server/bin/Data" }, // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console "console": "integratedTerminal", - "stopAtEntry": false + "stopAtEntry": false, + "requireExactSource": false }, { "name": ".NET Core Attach", diff --git a/.vscode/settings.json b/.vscode/settings.json index 70ee8ef47..7f9abd40a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,49 @@ { + "cSpell.words": [ + "allcinema", + "animeshon", + "anison", + "APIV2Helper", + "automagically", + "avdump", + "avdumping", + "bangumi", + "cabac", + "clob", + "Cloneable", + "crunchyroll", + "fanart", + "fanarts", + "favourite", + "flac", + "funimation", + "imdb", + "IUDPConnectionHandler", + "k1itsu", + "kanareading", + "magick", + "malid", + "muxed", + "muxing", + "mylist", + "outro", + "ova", + "ovas", + "rescan", + "rewatching", + "seasonwide", + "seiyuu", + "shoko", + "shokofin", + "signalr", + "subsampling", + "syoboi", + "theora", + "tvdb", + "vndb", + "vndbid", + "vorbis", + "webui" + ], "dotnet.defaultSolution": "Shoko.Server.sln" } diff --git a/Dependencies/MediaInfo/History.txt b/Dependencies/MediaInfo/History.txt index 19a650b43..a747cbfb4 100644 --- a/Dependencies/MediaInfo/History.txt +++ b/Dependencies/MediaInfo/History.txt @@ -12,6 +12,402 @@ Known bugs - Languages (other than english and French) : not all words are translated, I need translators! - Others? https://sourceforge.net/p/mediainfo/_list/tickets +Version 24.06, 2024-06-27 +------------- ++ I1881, MXF & MOV: customizable seek pos and duration of caption probe ++ I1882, CEA-608/708: option for forcing all CC1-CC4/T1 if stream is detected ++ JPEG 2000: support of HTJ2K profile ++ JPEG 2000: readout of jp2h colr atom, more file extensions, better support of broken files ++ DAT: Support of raw Digital Audio Tape ++ Enable Control Flow Guard (CFG) and Control-flow Enforcement Technology (CET) ++ Conformance checker: an element is indicated bigger than its upper element ++ Conformance checker: option for max count of items per check +x Windows GUI: Fix unwanted deactivation of the ffmpeg plugin +x I2086, MXF: StreamOrder for tracks in ANC +x I2076, Dolby E: StreamOrder includes all underlying streams +x I2087, MPEG-TS: general duration includes before and after PCR offsets +x WavPack: various fixes for multichannel & DSD files +x Supported platforms: this is the last version compatible with RHEL/CentOS 7, SLE 12, Debian 10, Mageia 8 + +Version 24.05, 2024-05-30 +------------- ++ I2029, MXF: decode of VBI (Line 21 & VITC) ++ I2058, VorbisCom: show MusicBrainz IDs in XML or full text output ++ I1881, MXF & MOV: customizable seek pos and duration of caption probe ++ I2005, WavPack: support of non-standard sampling rate ++ I2021, MP4: support of Qt style AudioSampleEntry in ISO MP4 ++ Conformance checker: report of malformed frames for AVC & HEVC & AAC ++ Conformance checker: an element is indicated bigger than its upper element ++ Conformance checker: Add more stream synchronization related checks ++ Conformance checker: Check coherency of MXF elements having vectors ++ Conformance checker: check of MPEG Audio sync loss in raw MP3 & truncated file ++ Conformance checker: FFV1 checks also when in AVI and MOV/MP4 ++ Conformance checker: check if a TIFF file is complete ++ Conformance checker: span of frames & frame/timestamp/byte offset +x Avoid infinite loop with distant files +x MXF: Support of SMPTE ST 422-2019 I2 +x I2055, Dolby Vision: fix crash with some files +x I2054, ID3v2: fix crash with some malformed files +x FFV1: fix conformance checker crash with Golomb Rice parsing +x AC-3: fix crash with some TrueHD files +x I2005, WavPack: handle of small files +x BMP: fix bitdepth info + +Version 24.04, 2024-04-18 +------------- ++ ADM: more AdvSS Emission profile checks ++ AC-3 & Dolby E: more AC-3 metadata readouts ++ AV1: support of chroma_sample_position ++ I1999, WAV: support of BS.2088 BW64 chunkId ++ I2008, Wavpack: support of DSD ++ I1882, CEA-608/708: options for ignoring command only streams ++ I1990, FLV: support of enhanced RTMP +x WAV: fix support of 4+ GB ADM +x I2005, WavPack: fix duration with small files +x I2009, IVF: fix division by zero with buggy files + +Version 24.03, 2024-03-28 +------------- ++ ADM: ADM v3, including profile element, support ++ ADM: conformance checks on AdvSS Emission profile ++ Dolby E: display more AC-3 metadata items ++ MOV/MP4: parsing of rtmd (real time metadata) tracks ++ PNG: packing kind (linear or indexed) +x WAV: support of 4+ GiB axml (useful for huge ADM content) +x MPEG-H: fix uninitialized values leading to random behavior +x PDF: fix crash with corrupted files +x MOV/MP4: fix bit depth info for some PCM tracks with pcmC box + +Version 24.01, 2024-01-31 +------------- ++ ADM: Dolby Atmos Master ADM Profile conformance checker (technology preview) ++ Dolby Vision: support of version 3, with compression info, and profile 20 ++ Dolby Vision: explicit display of profile ++ HEVC: support of multiview profile signaled in VPS extension ++ MP4: parsing of vexu (Video Extended Usage) box ++ ICC: support of CCIP in ICC in JPEG, PNG, TIFF, MP4, raw files ++ MPEG-TS: detection of VVC and EVC ++ AVC: count of slices ++ PNG: support of color description chunks (CCIP CLLI MDCV) ++ GXF: support of AVC and VC-3 ++ TrueHD: display of Dolby Surround EX & Dolby Pro Logic IIz +x Matroska: better fallback in case of buggy timecode +x I1940, MOV/MP4: fix slowness with some unrecognized metadata atoms +x HDR10/HDR10+: fix HDR10 info even if some characteristics are not met + +Version 23.11, 2023-11-30 +------------- ++ XMP: support of a couple of additional metadata ++ PNG: pixel aspect ratio, gamma, active bit depth ++ PNG: support of textual metadata ++ Detection of active width/height/DAR (based on FFmpeg), Windows only ++ Matroska: show ST-12 timecode of first frame ++ ADM: rounding of FFoA to 0 decimal and Start/End time codes to 2 decimals ++ WAV: support of big (1+ GB) axml chunks ++ ADM: support of big (1+ GB) files on 32-bit systems +x I1876, BWF: fix missing precision in TimeReference export +x I1607, MPEG-TS/PS: Less Inform() with Open(memory) than Open(file) +x MP4/MOV: show right time code of last frame with complex time code tracks +x Duration: timecode output should not use drop frame for 23.976fps +x AVC+HEVC: fix handling of DF timestamps +x SF1188, ID3v2: fix wrong handling of chunks having padding +x I1887, TS DVB: fix wrong handling of UTF-8 strings in service name +x I1892, Matroska: fix date readout if before the millennium + +Version 23.10, 2023-10-04 +------------- ++ Italian language update ++ Languages: add 'fil' (Filipino) ++ Support of MPEG-H in MPEG-TS ++ MOV/MP4: caption probing time expanded from ~15s to ~30s ++ MPEG-7 and DVD-Video: provide title duration based on frame rate ++ WAV: better display of buggy WAV files have 2 fmt/data chunks +x MOV/MP4: fix lack of detection of CEA-608/708 if junk at end of stream +x DVD-Video: fix duration if more than 1 menu + +Version 23.09, 2023-09-14 +------------- ++ DTS-UHD support (contribution from Xperi) ++ MPEG-7 output update, supporting collections for DVD Video ++ ISO 9660: more metadata ++ AVC: read out of time code +x DVD Video: better support of ISO having several episodes +x MPEG Video: fix duration not including last field duration (interlaced content only) +x I754, AVC&HEVC: fix risk of crash with some streams + +Version 23.07, 2023-07-12 +------------- ++ USAC conformance checker: update DRC presence check ++ USAC conformance checker: sbgp presence check ++ USAC conformance checker: difference between extra zero bytes and other extra bytes ++ ISO 9660: support of DVD video, with option for listing all contents ++ MPEG-7: support of collections (beta) ++ More Blackmagic RAW meta kinds ++ DTS-HD: DTSHDHDR header support (used for raw DTS-HD files) +x ADIF: fix wrong detection of lot of files as ADIF (Android and MediaInfoOnline) +x USAC conformance checker: fix arith context handling in some corner cases +x ADM: some tweaks about FFoA/Start/End time codes +x Remove curl default ca info message in stdout + +Version 23.06, 2023-06-28 +------------- ++ USAC/xHE-AAC conformance checker ++ S-ADM: support of SMPTE ST 2127-1 / SMPTE ST 2109 / SMPTE ST 2127-10 (S-ADM in MGA in MXF) ++ S-ADM: add S-ADM version and support of 1/1.001 frame rates ++ ADM: show FFoA/Start/End as timestamp and timecode ++ MPEG-7 output update with more extensions ++ MPEG-TS: support of JPEG XS ++ DTS-UHD: support of DTS-UHD (a.k.a. DTS-X P2) in MP4 ++ MP4: detection of VVC ++ MP4: support of media characteristicd (spoken dialog, translation, easy to read...) ++ MP4: support of more Blackmagic RAW Codec IDs ++ MP4: support of ipcm CodecID ++ MP4: support of service kind ++ HEVC: support of SMPTE ST 2094-10 ++ HDR: display of all formats if more than 1 format is detected ++ Matroska: support of SMPTE ST 12 in block additions ++ HEVC: time code SEI readout ++ AVC & HEVC: active format description readout ++ MPEG-TS: support of SMPTE ST 2038 (ancillary data) +x ADM/Dolby: fix wrong FFoA with 1.001 frame rates ++ MOV/MP4: more info with tracks having unknown type +x MOV/MP4: avoid to parse too much content with non stripped timecodes +x MOV/MP4: avoid incoherent behavior if 2 tracks have the same ID +x TTML: fix default frame rate +x TimeCode: 1/1.001 frame rate was not always detected +x MediaTrace: fix some random blank outputs +x URL: remove query part of the URL in the FileExtension field +x Referenced files: fix handling of URL encoded with UTF-8 content +x Matroska: fix crash in support of HDR10+ + +Version 23.04, 2023-04-26 +------------- ++ MXF: support of SMPTE ST 381-4 (AAC in MXF) ++ DTS: show MA or HRA tip in commercial name for DTS:X ++ DTS: detection of DTS:X not lossless ++ APT-X100 a.k.a. Cinema DTS: initial support ++ Matroska: support of HDR10+ ++ MP4: more information about thumbnails ++ ID3v2: more information about thumbnails ++ VP9: initial support, for more information about chroma subsampling ++ AWS S3: support for reference files with AccessID:SecretKey@URL ++ Windows: fix some download errors with AWS S3 objects (libcurl update) +x AWS S3: fix errors with some special chars in SecretKey +x AWS S3: fix random credential issues with non geolocated URLs +x DTS: fix freeze with some DTS-HD not DTS:X files +x MPEG-TS: fix crash in HEVC_timing_and_HRD +x AAC: fix samples per frame with SBR streams +x FLAC: fix missing Tbc Tbr in ChannelLayout + +Version 23.03, 2023-03-29 +------------- ++ DTS: Detection of IMAX Enhanced ++ MOV/MP4: Add HDR Vivid format support ++ HEVC: Add HDR Vivid format support ++ MXF/PCM: detect silent tracks (full parsing only) ++ Monkey's Audio: support of 32-bit files, show version ++ MP4 audioProfileLevelIndication: add Low Delay AAC v2 Profile ++ MP4/MOV: support of FLAC ++ MOV/MP4: support of TTML with images ++ MPEG-7: 3 modes (strict, relaxed, extended) ++ MPEG-7: more sub-termIDs (AudioPresentationCS) ++ MPEG-7: Add more PublicIdentifiers ++ MPEG-7: more sub-termIDs (MP4, WAV, AVC, ProRes) ++ AVI/WAV: display of the kind of fmt chunk ++ AVC: detection of more profiles ++ ChannelLayout: difference between M (Mono) and C (Center, part of multichannel content) ++ AC-3: detection of channel layout also for encrypted content ++ AC-4 and MPEG-H 3D Audio: Merged channel layout (all sub-streams together) ++ DTS: Detection of real bit depth e.g. 20 instead of only byte aligned bit depth (16 or 24) ++ FLAC: support of BWF in Vorbis comments ++ N19/STL: codepage, subtitle count, max line per subtitle, more metadata ++ ISAN: detection of descriptions referencing an ISAN ++ AAC: detection of eSBR (and fix of random wrong PS detection) ++ Extract of time codes, XML format, currently only for for MXF +x MP4/MOV: fix freezes with some unknown udta atoms +x FLV: fix duration of 0 with some buggy files +x AVC: fix PTS of last frame +x FFV1: fix potential crash with malformed files +x AV1: add HDR format line and fix HDR values +x AAC and WAV: fix of channel layout display for 5 front channels +x AC-4: Tl/Tr mapped to to Tsl/Tsr +x FLAC: fix sampling count +x ID3v2: fix Genre not showing ID 0 (Blues) +x MPEG-7: VBR fix +x JSON/XML: Remove minus sign from element names +x Normalization of date/time in report + +Version 22.12, 2022-12-22 +------------- ++ WebVTT: more information (duration, start/end timestamp, count of lines...) ++ MP4/MOV: support of FLAC ++ MP4/MOV: support of LanguageIETF ++ ProRes: parse FFmpeg glbl atom for getting color range ++ AVI/WAV: detection of character set ++ WAV: display MD5 of raw content ++ FLAC: display MD5 of unencoded content ++ USAC: trace of UsacFrame() up to after preroll ++ MOV/MP4: option for parsing only the header, no parsing of any frame ++ MXF: option for parsing only the header, no parsing of any frame +x MXF: quicker parsing when fast parsing is requested +x I662, WAV: fix false-positive detection of DTS in PCM +x I1637, MPEG-Audio: proper support of Helix MP3 encoder detection and encoder settings +x I661, MXF: fix UKDPP FpaPass value sometimes not outputted +x S1182, Teletext subtitle: prioritize subtitle metadata other overs +x Matroska: Better handling in case of buggy AVC stream +x 22.2 audio: Fix name of 1 channel (Tll --> Tsl) +x AAC: fix wrong parsing of some bitstreams +x Fix crash with stdin input and ctrl-c +x Fix memory leak in JSON output + +Version 22.09, 2022-10-04 +------------- ++ Italian language update ++ USAC: IOD and sampling rate coherency checking ++ ADM: support of nested objects and complementary objects ++ AC-4: Display of Custom downmix targets ++ IAB: Parsing of IAB bitstream and ADM-like output ++ Frame rate: store FrameRate_Num/Den also for integer values ++ MPEG-4/MOV: support of time codes >30 fps ++ MOV/MPEG-4: List of QuickTime time code discontinuities ++ Dolby Vision: add info about more profiles +x Text streams: show stream frame rate if not same as container frame rate +x CDP: fix rounding of frame rate +x SCC: fix of CEA-608 FirstDisplay_Delay_Frames +x SCC: fix TimeCode_Last +x MPEG-4/MOV: last time code value for all kind of QuickTime time codes +x MOV/MPEG-4: Fix frame count for NDF non-integer frame rates +x JSON: fix invalid output in some corner cases +x Several other parsing bug/crash fixes (thanks to fuzzing by users) + +Version 22.06, 2022-06-23 +------------- ++ MXF: FFV1 support ++ Dolby Vision: add info about more profiles ++ AAC: check of missing ID_END and incoherent count of channels ++ NSV: better handling of buggy StarDiva agenda negative timestamps ++ Text: Show text frame rate ++ Text: frame rate precise numerator/denominator also for text streams ++ CDP: readout of display aspect ratio ++ MPEG-4/MOV: support of time codes >30 fps ++ TTML: Support of more timeExpression flavors +x ADM: correctly map Dolby binaural render mode to track UID +x Dolby Audio Metadata: first frame of action in HH:MM:SS:FF format +x Dolby Vision: profiles and levels in decimal rather than in hexadecimal +x MXF: fix of Dolby Vision Metadata not displayed if HDR10 metadata is present +x MPEG-4/MOV: avoid buggy frame rates by taking frame rate from stts atom +x CDP: better catching of wrong line21_field value +x NSV: better handling of invalid frames +x MXF: Include frame count in SDTI and SystemScheme1 time codes to time stamp conversion +x TTML: do not show frame rate if it is from MediaInfo options +x DV: timecode trace in HH:MM:SS:FF format + +Version 22.03, 2022-03-31 +------------- ++ NSV (Nullsoft Video): full featured support ++ NSV: support of proprietary StarDiva metadata (by reverse engineering) ++ HEVC: CEA-608/708 support ++ Dolby Audio Metadata: First frame of action, binaural render modes ++ Dolby Audio Metadata: 5.1 and 5.1.x downmix, 5.1 to 2.0 downmix, associated video frame rate, trim modes ++ MOV/MP4, TTML, SCC, MXF TC: time code of last frame ++ EIA-608: first displayed caption type ++ EIA-608: Maximum count of lines per event and total count of lines ++ EIA-608: duration of the visible content ++ TTML: Total count of lines ++ TTML: Maximum count of lines per event (including overlapping times) ++ TTML: Frame count, display aspect ratio ++ TTML: Support of timestamps in frames ++ SCC: Delay ++ Matroska: Encoding settings metadata support ++ MOV/MP4: Gamma metadata output ++ MPEG-4/MOV: difference between audio Center and Mono when possible ++ MP4/MOV: Support of dec3 atom in wave atom ++ MPEG-4/MOV: show both values in case of chan atom ChannelLayoutTag / ChannelDescriptions mismatch ++ MP4/MOV: Support of dec3 atom in wave atom ++ MXF: better support of AVC streams without SPS/PPS ++ ADM: display channel index of trackUIDs +x WAV: fix freeze with 32-bit PCM +x DPX: fix regression with DPX files more than 64 MB +x Dolby E: fix crash with some invalid streams +x E-AC-3: service kind was not correctly handled +x EXR: fix of bad handling of files with long names in attributes +x TTML: correct handling of 29.97 DF time codes +x AV1: fix of the parsing of some streams, especially the ones with HDR metadata +x WebVTT: was not correctly handling WebVTT header with comment +x Matroska: fix false positive detection of bad CRC32 +x Several other parsing bug/crash fixes + +Version 21.09, 2021-09-17 +------------- ++ Graph view for 3D audio streams (thanks to graphviz) ++ ADM: full featured support (programmes, content, objects, pack formats...) ++ ADM: in WAV (axml, bxml), MXF ++ S-ADM in AES3: support of Levels A1 and AX1 ++ MOV/MP4: support of Dolby Vision Metadata XML ++ MXF: detection of IAB ++ SMPTE ST 337 (AES3): support of subframe mode ++ HEVC: CEA-608/708 caption support ++ MP4/QuickTime: Android slow motion real frame rate ++ JSON output: add creatingLibrary field +x MPEG-4: read too much data with some predecessor definitions +x EBUCore: fix of fields order and types + +Version 21.03, 2021-03-26 +------------- ++ WAV: ADM profile detection of Dolby Atmos Master or MPEG-H ++ SMPTE ST 337: support of AC-4 ++ AC-3/AC-4: show top layer channels after Lw/Rw, as it becomes the defacto standard layout ++ Dolby Surround EX and Pro Logic IIz detection ++ Matroska: add DV support ++ CLI: read from stdin ++ DV: remove check of zeroed bytes in timecode, considered again as valid timecode ++ TIFF; add support of compression codes 7 and 8 ++ WAV: show bext (BWF) version in verbose mode / XML / JSON ++ MXF: detection fo DCI P3 mastering display color primaries ++ Options: add software version to text output ++ Options: add report creation timestamp to text output ++ macOS: native build for Apple Silicon (arm64) +x HDR: mastering max. luminance precision was wrong +x WM: fix EncodingTime parsing +x MOV/MP4: skip XMP huge atoms, fix +x MPEG-TS: fix inverted supplementary_audio_descriptor mix_type values +x AAC: fix File_Aac::is_intensity according to ISO/IEC 14496-3:2009 +x I1353, MP4: Skip user data Xtra and free atoms +x FFV1: fix crash with some bitstreams parsing +x TIFF: fix division by 0 +x RF64: fix the WAV malformed chunk size test +x Supported platforms: this is the last version compatible with Windows XP, macOS 10.5-10.9, RHEL/CentOS 6 + +Version 20.09, 2020-10-09 +------------- ++ Dolby ED2: full featured support (presentations, presentation targets, beds, objects) ++ MKV: support of Dolby Vision metadata ++ MXF: detection of Dolby E hidden in PCM tracks having more than 2 channels ++ WAV: detection of Dolby E hidden in PCM tracks having more than 2 channels ++ CineForm: display of color space (including Bayer), bit depth +x WAV: more precise sample count +x SMPTE ST 337: catch of streams starting later than usual (probing increased from 4 to 16 PCM "frames") +x PNG: detection of additional alpha plane in color space +x MXF: detection of additional alpha plane in color space +x AVI: detection of additional alpha plane in color space +x MPEG Audio: was wrongly flagging Xing info tag as CBR +x VorbisTag: does not skip DISCID +x Miscellaneous bug/crash fixes + +Version 20.08, 2020-08-11 +------------- ++ MPEG-H 3D Audio full featured support (group presets, switch groups, groups, signal groups) ++ MP4/MOV: support of more metadata locations ++ JSON and XML outputs: authorize "complete" output ++ MPEG-4: support of TrueHD ++ WM: show legacy value of performer if not same as modern one ++ WAV: trace of adtl (Associated Data List) chunk +x URL encoding detection fix for URL having a query part (issue with e.g. pre-signed AWS S3 URLs) +x Don't try to seek to the end (false positive range related error with HTTP) +x DPX: don't load the whole file in RAM +x Opus: fix wrong channel mapping +x Miscellaneous other bug fixes + Version 20.03, 2020-04-03 ------------- + AC-4 full featured support (presentations, groups, substreams) diff --git a/Dependencies/MediaInfo/LIBCURL.DLL b/Dependencies/MediaInfo/LIBCURL.DLL index 09233612c..9a94a3e09 100644 Binary files a/Dependencies/MediaInfo/LIBCURL.DLL and b/Dependencies/MediaInfo/LIBCURL.DLL differ diff --git a/Dependencies/MediaInfo/LICENSE b/Dependencies/MediaInfo/LICENSE index e078aa27d..4169054b5 100644 --- a/Dependencies/MediaInfo/LICENSE +++ b/Dependencies/MediaInfo/LICENSE @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2002-2020, MediaArea.net SARL +Copyright (c) 2002-2024, MediaArea.net SARL All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/Dependencies/MediaInfo/License.html b/Dependencies/MediaInfo/License.html index 53a6afeed..4255d64a9 100644 --- a/Dependencies/MediaInfo/License.html +++ b/Dependencies/MediaInfo/License.html @@ -9,7 +9,7 @@

MediaInfo(Lib) License

- Copyright (c) 2002-2020 MediaArea.net SARL. All rights reserved. + Copyright (c) 2002-2024 MediaArea.net SARL. All rights reserved.

Redistribution and use in source and binary forms, with or without modification, @@ -17,12 +17,10 @@

MediaInfo(Lib) License

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” @@ -39,21 +37,19 @@

MediaInfo(Lib) License


-

Alternate open source licenses:
- You can relicense (including source headers change) MediaInfo - under Apache License 2.0 or later, - and/or GNU Lesser General Public License 2.1 or later, - and/or GNU General Public License 2.0 or later, - and/or Mozilla Public License 2.0 or later.

+

Alternate open source licenses:
+You can relicense (including source headers change) MediaInfo +under Apache License 2.0 or later, +and/or GNU Lesser General Public License 2.1 or later, +and/or GNU General Public License 2.0 or later, +and/or Mozilla Public License 2.0 or later.


-

Alternate license for redistributions of the library in binary form:
- Redistributions in binary form must reproduce the following sentence (including the link to the website) in the - documentation and/or other materials provided with the distribution.
- This product uses MediaInfo library, Copyright (c) 2002-2020 MediaArea.net SARL.

+

Alternate license for redistributions of the library in binary form:
+Redistributions in binary form must reproduce the following sentence (including the link to the website) in the documentation and/or other materials provided with the distribution.
+This product uses MediaInfo library, Copyright (c) 2002-2024 MediaArea.net SARL.


@@ -62,32 +58,31 @@

Third party libraries

The software relies on third party libraries. Such libraries have their own license:

- +

Contributors

diff --git a/Dependencies/MediaInfo/MediaInfo.exe b/Dependencies/MediaInfo/MediaInfo.exe index caf861033..7be44d498 100644 Binary files a/Dependencies/MediaInfo/MediaInfo.exe and b/Dependencies/MediaInfo/MediaInfo.exe differ diff --git a/Dockerfile b/Dockerfile index 8d939ba6c..daaabe40b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,10 @@ ARG channel RUN apt-get update && apt-get install -y gnupg curl -RUN curl https://mediaarea.net/repo/deb/debian/pubkey.gpg | apt-key add - -RUN echo "deb https://mediaarea.net/repo/deb/debian/ bookworm main" | tee -a /etc/apt/sources.list +RUN curl --retry 3 -O https://mediaarea.net/repo/deb/debian/pubkey.gpg +# This converts the old format gpg key to the new format. The old format cannot be used with [signed-by] in the apt sources.list file. +RUN gpg --no-default-keyring --keyring ./temp-keyring.gpg --import pubkey.gpg && gpg --no-default-keyring --keyring ./temp-keyring.gpg --export --output /usr/share/keyrings/mediainfo.gpg && rm pubkey.gpg +RUN echo "deb [signed-by=/usr/share/keyrings/mediainfo.gpg] https://mediaarea.net/repo/deb/debian/ bookworm main" | tee -a /etc/apt/sources.list RUN apt-get update && apt-get install -y apt-utils gosu jq unzip mediainfo librhash-dev @@ -52,4 +54,4 @@ HEALTHCHECK --start-period=5m CMD curl -s -H "Content-Type: application/json" -H EXPOSE 8111 -ENTRYPOINT /bin/bash /dockerentry.sh +ENTRYPOINT ["/bin/bash", "/dockerentry.sh"] diff --git a/Dockerfile.aarch64 b/Dockerfile.aarch64 index a41581c79..d17dfec9b 100644 --- a/Dockerfile.aarch64 +++ b/Dockerfile.aarch64 @@ -26,8 +26,10 @@ ARG channel RUN apt-get update && apt-get install -y gnupg curl -RUN curl https://mediaarea.net/repo/deb/debian/pubkey.gpg | apt-key add - -RUN echo "deb https://mediaarea.net/repo/deb/debian/ bookworm main" | tee -a /etc/apt/sources.list +RUN curl --retry 3 -O https://mediaarea.net/repo/deb/debian/pubkey.gpg +# This converts the old format gpg key to the new format. The old format cannot be used with [signed-by] in the apt sources.list file. +RUN gpg --no-default-keyring --keyring ./temp-keyring.gpg --import pubkey.gpg && gpg --no-default-keyring --keyring ./temp-keyring.gpg --export --output /usr/share/keyrings/mediainfo.gpg && rm pubkey.gpg +RUN echo "deb [signed-by=/usr/share/keyrings/mediainfo.gpg] https://mediaarea.net/repo/deb/debian/ bookworm main" | tee -a /etc/apt/sources.list RUN apt-get update && apt-get install -y apt-utils gosu jq unzip mediainfo librhash-dev @@ -53,4 +55,4 @@ HEALTHCHECK --start-period=5m CMD curl -s -H "Content-Type: application/json" -H EXPOSE 8111 -ENTRYPOINT /bin/bash /dockerentry.sh +ENTRYPOINT ["/bin/bash", "/dockerentry.sh"] diff --git a/Installer/ShokoServer.iss b/Installer/ShokoServer.iss index 1c1ea54dc..9ffa51a18 100644 --- a/Installer/ShokoServer.iss +++ b/Installer/ShokoServer.iss @@ -1,8 +1,8 @@ ; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! -#define AppVer GetVersionNumbersString('..\Shoko.Server\bin\Release\net8.0-windows\win10-x64\ShokoServer.exe') -#define AppSlug Copy(StringChange(AppVer, ".", ""), 1, Len(AppVer) - 1) +#define AppVer GetVersionNumbersString('..\Shoko.Server\bin\Release\net8.0-windows\win-x64\ShokoServer.exe') +#define AppSlug Copy(StringChange(AppVer, ".", "-"), 1, Len(AppVer) - 2) #define MyAppExeName "ShokoServer.exe" [Setup] @@ -45,7 +45,7 @@ Name: "{commonstartup}\Shoko Server"; Filename: "{app}\ShokoServer.exe"; Tasks: [Run] Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""Shoko Server - Client Port"" dir=in action=allow protocol=TCP localport=8111"; Flags: runhidden; StatusMsg: "Open exception on firewall..."; Tasks: Firewall Filename: "{app}\ShokoServer.exe"; Flags: nowait postinstall skipifsilent shellexec; Description: "{cm:LaunchProgram,Shoko Server}" -Filename: "https://docs.shokoanime.com/server/install/"; Flags: shellexec runasoriginaluser postinstall; Description: "Shoko Server Install Guide" +Filename: "https://docs.shokoanime.com/getting-started/running-shoko-server/"; Flags: shellexec runasoriginaluser postinstall; Description: "Shoko Server Install Guide" Filename: "https://shokoanime.com/blog/shoko-version-{#AppSlug}-released/"; Flags: shellexec runasoriginaluser postinstall; Check: BlogPostCheck; Description: "View {#AppVer} Release Notes" [UninstallRun] @@ -60,7 +60,7 @@ Name: "{commonappdata}\ShokoServer"; Permissions: users-full [Files] Source: ".\FixPermissions.bat"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\Shoko.Server\bin\Release\net8.0-windows\win10-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\Shoko.Server\bin\Release\net8.0-windows\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs [Code] diff --git a/README.md b/README.md index 9053f539d..5d4766058 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Shoko takes the hassle out of managing your anime collection. With its user-frie back and let it do the work for you. No more manual inputting or renaming - just effortless organization and access to your favorite anime. -[Learn More About Shoko](https://shokoanime.com) +[Learn More About Shoko](https://shokoanime.com) +[User Docs](https://docs.shokoanime.com/getting-started/installing-shoko-server) # Supported Media Players Shoko currently supports the following media players. @@ -29,4 +30,26 @@ Discord, and we'll be more than happy to provide guidance and assistance. # Building Shoko +Install the latest .net sdk +## Windows: +Build TrayService or CLI from VS Code or command line via: + +`dotnet build Shoko.TrayService/Shoko.TrayService.csproj` + +## Linux: +Install mediainfo and rhash. For apt, that would be: + +`sudo apt install mediainfo librhash-dev` + + +Build from CLI: + +`dotnet build -c=Release -r linux-x64 -f net8.0 Shoko.CLI/Shoko.CLI.csproj` + +If that doesn't work, this document may be out of date. Check the dockerfile for guaranteedly updated build steps. + +# Contributing +We are always accepting help, and there are a million little things that always need done. Hop on our [discord](https://discord.gg/vpeHDsg) and talk to us. Communication is important in any team. No offesnse, but it's difficult to help anyone that shows up out of nowhere, opens 3 issues, then creates a PR without even talking to us. We have a wealth of experience. Let us help you...preferably before the ADHD takes over, you hyperfixate, and you come up with a fantastic solution to problem that isn't at all what you expected. Support is also best found in the discord, in case you read this far. + +![Alt](https://repobeats.axiom.co/api/embed/c233a2de69d1f2f56e4cbe96b4b4cd33dc223d19.svg "Repobeats analytics image") diff --git a/Shoko.CLI/Shoko.CLI.csproj b/Shoko.CLI/Shoko.CLI.csproj index 1f8cfc819..a4c34118b 100644 --- a/Shoko.CLI/Shoko.CLI.csproj +++ b/Shoko.CLI/Shoko.CLI.csproj @@ -1,6 +1,7 @@  - net8.0;net8.0-windows + net8.0 + win-x64;linux-x64 exe x64;AnyCPU false @@ -13,12 +14,6 @@ false enable - - win-x64 - - - win-x64;linux-x64 - ..\Shoko.Server\bin\Debug\ @@ -46,4 +41,4 @@ db.ico - \ No newline at end of file + diff --git a/Shoko.Commons b/Shoko.Commons index 259c93adc..e257946e1 160000 --- a/Shoko.Commons +++ b/Shoko.Commons @@ -1 +1 @@ -Subproject commit 259c93adc13e82c5bfc760aac8eb354c5ce0b7fd +Subproject commit e257946e182c409130102d34374c41a6f8d77ddb diff --git a/Shoko.Plugin.Abstractions/Attributes/PluginSettings.cs b/Shoko.Plugin.Abstractions/Attributes/PluginSettings.cs index 3d3e8ca10..f41c62b29 100644 --- a/Shoko.Plugin.Abstractions/Attributes/PluginSettings.cs +++ b/Shoko.Plugin.Abstractions/Attributes/PluginSettings.cs @@ -1,10 +1,8 @@ using System; -namespace Shoko.Plugin.Abstractions.Attributes -{ +namespace Shoko.Plugin.Abstractions.Attributes; - public class PluginSettingsAttribute : Attribute - { - - } -} \ No newline at end of file +/// +/// An attribute for defining a plugin settings object. +/// +public class PluginSettingsAttribute : Attribute { } diff --git a/Shoko.Plugin.Abstractions/Attributes/RenamerAttribute.cs b/Shoko.Plugin.Abstractions/Attributes/RenamerAttribute.cs deleted file mode 100644 index cac071e45..000000000 --- a/Shoko.Plugin.Abstractions/Attributes/RenamerAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; - -namespace Shoko.Plugin.Abstractions.Attributes -{ - [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] - public class RenamerAttribute : Attribute - { - public RenamerAttribute(string renamerId) - { - RenamerId = renamerId; - } - - public string RenamerId { get; } - private string _desc; - - public string Description - { - get => _desc ?? RenamerId; - set => _desc = value; - } - } -} \ No newline at end of file diff --git a/Shoko.Plugin.Abstractions/Attributes/RenamerIDAttribute.cs b/Shoko.Plugin.Abstractions/Attributes/RenamerIDAttribute.cs new file mode 100644 index 000000000..954a58fc2 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Attributes/RenamerIDAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Shoko.Plugin.Abstractions.Attributes; + +/// +/// This attribute is used to identify a renamer. +/// It is an attribute to allow getting the ID of the renamer without instantiating it. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class RenamerIDAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the renamer. + public RenamerIDAttribute(string renamerId) + { + RenamerId = renamerId; + } + + /// + /// The ID of the renamer. + /// + public string RenamerId { get; } +} diff --git a/Shoko.Plugin.Abstractions/Attributes/RenamerSettingAttribute.cs b/Shoko.Plugin.Abstractions/Attributes/RenamerSettingAttribute.cs new file mode 100644 index 000000000..301797592 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Attributes/RenamerSettingAttribute.cs @@ -0,0 +1,46 @@ +using System; +using System.Runtime.CompilerServices; +using Shoko.Plugin.Abstractions.Enums; + +namespace Shoko.Plugin.Abstractions.Attributes; + +/// +/// An attribute for defining a renamer setting on a renamer settings object. +/// +[AttributeUsage(AttributeTargets.Property)] +public class RenamerSettingAttribute : Attribute +{ + /// + /// The name of the setting to be displayed + /// + public string Name { get; set; } + + /// + /// The type of the setting, to be used in the UI + /// + public RenamerSettingType Type { get; set; } + + /// + /// The language to use for text highlighting in the editor + /// + public CodeLanguage Language { get; set; } + + /// + /// The description of the setting and what it controls + /// + public string? Description { get; set; } + + /// + /// Create a new setting definition for a property. + /// + /// The name of the setting to be displayed. Will be inferred if not specified. + /// The type of the setting, to be used in the UI. Will be inferred if not specified. + /// The description of the setting and what it controls. Can be omitted. + public RenamerSettingAttribute([CallerMemberName] string? name = null, RenamerSettingType type = RenamerSettingType.Auto, string? description = null) + { + // the nullability is suppressed because [CallerMemberName] is used + Name = name!; + Type = type; + Description = description; + } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/AniDBMediaData.cs b/Shoko.Plugin.Abstractions/DataModels/AniDBMediaData.cs index 11fbf6c95..a55f9f341 100644 --- a/Shoko.Plugin.Abstractions/DataModels/AniDBMediaData.cs +++ b/Shoko.Plugin.Abstractions/DataModels/AniDBMediaData.cs @@ -1,10 +1,19 @@ using System.Collections.Generic; -namespace Shoko.Plugin.Abstractions.DataModels +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Represents the audio and subtitle languages associated with an AniDB file. +/// +public class AniDBMediaData { - public class AniDBMediaData - { - public IReadOnlyList AudioLanguages { get; set; } - public IReadOnlyList SubLanguages { get; set; } - } -} \ No newline at end of file + /// + /// Gets the audio languages associated with the media file. + /// + public IReadOnlyList AudioLanguages { get; set; } = new List(); + + /// + /// Gets the subtitle languages associated with the media file. + /// + public IReadOnlyList SubLanguages { get; set; } = new List(); +} diff --git a/Shoko.Plugin.Abstractions/DataModels/AnimeTitle.cs b/Shoko.Plugin.Abstractions/DataModels/AnimeTitle.cs index e770ed32d..1be4add30 100644 --- a/Shoko.Plugin.Abstractions/DataModels/AnimeTitle.cs +++ b/Shoko.Plugin.Abstractions/DataModels/AnimeTitle.cs @@ -1,150 +1,41 @@ -using System.Xml.Serialization; + using Shoko.Plugin.Abstractions.Enums; -namespace Shoko.Plugin.Abstractions.DataModels -{ - public class AnimeTitle - { - public DataSourceEnum Source { get; set; } +namespace Shoko.Plugin.Abstractions.DataModels; - public TitleLanguage Language { get; set; } +/// +/// Represents a title from a data source. +/// +public class AnimeTitle +{ + /// + /// The source. + /// + public DataSourceEnum Source { get; set; } - public string LanguageCode { get; set; } + /// + /// The language. + /// + public TitleLanguage Language { get; set; } - public string Title { get; set; } + /// + /// The language code. + /// + public string LanguageCode { get; set; } = string.Empty; - public TitleType Type { get; set; } - } + /// + /// The country code, if available and applicable. + /// + public string? CountryCode { get; set; } - public enum TitleLanguage - { - Unknown = 0, - English = 1, - Romaji, - Japanese, - Afrikaans, - Arabic, - Bangladeshi, - Bulgarian, - FrenchCanadian, - Czech, - Danish, - German, - Greek, - Spanish, - Estonian, - Finnish, - French, - Galician, - Hebrew, - Hungarian, - Italian, - Korean, - Lithuanian, - Mongolian, - Malaysian, - Dutch, - Norwegian, - Polish, - Portuguese, - BrazilianPortuguese, - Romanian, - Russian, - Slovak, - Slovenian, - Serbian, - Swedish, - Thai, - Turkish, - Ukrainian, - Vietnamese, - Chinese, - ChineseSimplified, - ChineseTraditional, - Pinyin, - Latin, - Albanian, - Basque, - Bengali, - Bosnian, - Amharic, - Armenian, - Azerbaijani, - Belarusian, - Catalan, - Chichewa, - Corsican, - Croatian, - Divehi, - Esperanto, - Fijian, - Georgian, - Gujarati, - HaitianCreole, - Hausa, - Icelandic, - Igbo, - Indonesian, - Irish, - Javanese, - Kannada, - Kazakh, - Khmer, - Kurdish, - Kyrgyz, - Lao, - Latvian, - Luxembourgish, - Macedonian, - Malagasy, - Malayalam, - Maltese, - Maori, - Marathi, - MyanmarBurmese, - Nepali, - Oriya, - Pashto, - Persian, - Punjabi, - Quechua, - Samoan, - ScotsGaelic, - Sesotho, - Shona, - Sindhi, - Sinhala, - Somali, - Swahili, - Tajik, - Tamil, - Tatar, - Telugu, - Turkmen, - Uighur, - Uzbek, - Welsh, - Xhosa, - Yiddish, - Yoruba, - Zulu, - } + /// + /// The title value. + /// + public string Title { get; set; } = string.Empty; - public enum TitleType - { - [XmlEnum("none")] - None = 0, - [XmlEnum("main")] - Main = 1, - [XmlEnum("official")] - Official = 2, - [XmlEnum("short")] - Short = 3, - [XmlEnum("syn")] - Synonym = 4, - [XmlEnum("card")] - TitleCard = 5, - [XmlEnum("kana")] - KanjiReading = 6, - } + /// + /// The type. + /// + public TitleType Type { get; set; } } + diff --git a/Shoko.Plugin.Abstractions/DataModels/AnimeType.cs b/Shoko.Plugin.Abstractions/DataModels/AnimeType.cs index c7f554370..4a2b0f26a 100644 --- a/Shoko.Plugin.Abstractions/DataModels/AnimeType.cs +++ b/Shoko.Plugin.Abstractions/DataModels/AnimeType.cs @@ -1,13 +1,38 @@ -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Type of series. +/// public enum AnimeType { + /// + /// A movie. A self-contained story. + /// Movie = 0, + + /// + /// An original Video Animation (OVA). A short series of episodes, not broadcast on TV. + /// OVA = 1, + + /// + /// A TV series. A series of episodes that are broadcast on TV. + /// TVSeries = 2, + + /// + /// A TV special. A special episode of a TV series. + /// TVSpecial = 3, + + /// + /// A web series. A series of episodes that are released on the web. + /// Web = 4, + + /// + /// Other misc. types of series not listed in this enum. + /// Other = 5, } diff --git a/Shoko.Plugin.Abstractions/DataModels/DropFolderType.cs b/Shoko.Plugin.Abstractions/DataModels/DropFolderType.cs index 0f1da5746..ef4213b02 100644 --- a/Shoko.Plugin.Abstractions/DataModels/DropFolderType.cs +++ b/Shoko.Plugin.Abstractions/DataModels/DropFolderType.cs @@ -1,16 +1,30 @@ using System; -namespace Shoko.Plugin.Abstractions.DataModels +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// The rules that this Import Folder should adhere to. A folder that is both a Source and Destination cares not how files are moved in or out of it. +/// +[Flags] +public enum DropFolderType { /// - /// The rules that this Import Folder should adhere to. A folder that is both a Source and Destination cares not how files are moved in or out of it. + /// None. + /// + Excluded = 0, + + /// + /// Source. + /// + Source = 1, + + /// + /// Destination. + /// + Destination = 2, + + /// + /// Both Source and Destination. /// - [Flags] - public enum DropFolderType - { - Excluded = 0, - Source = 1, - Destination = 2, - Both = Source | Destination - } -} \ No newline at end of file + Both = Source | Destination, +} diff --git a/Shoko.Plugin.Abstractions/DataModels/EpisodeCounts.cs b/Shoko.Plugin.Abstractions/DataModels/EpisodeCounts.cs index cd2e7ebe6..b6fe3dc0a 100644 --- a/Shoko.Plugin.Abstractions/DataModels/EpisodeCounts.cs +++ b/Shoko.Plugin.Abstractions/DataModels/EpisodeCounts.cs @@ -1,13 +1,51 @@ -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Represents the count of different types of episodes. +/// public class EpisodeCounts { + /// + /// The number of normal episodes. + /// public int Episodes { get; set; } + + /// + /// The number of special episodes. + /// public int Specials { get; set; } + + /// + /// The number of credits episodes. + /// public int Credits { get; set; } + + /// + /// The number of trailer episodes. + /// public int Trailers { get; set; } - public int Others { get; set; } + + /// + /// The number of parody episodes. + /// public int Parodies { get; set; } + + /// + /// The number of other episodes. + /// + public int Others { get; set; } + + /// + /// Returns the number of episodes for the given + /// + public int this[EpisodeType type] => type switch + { + EpisodeType.Episode => Episodes, + EpisodeType.Special => Specials, + EpisodeType.Credits => Credits, + EpisodeType.Trailer => Trailers, + EpisodeType.Parody => Parodies, + _ => Others + }; } diff --git a/Shoko.Plugin.Abstractions/DataModels/EpisodeType.cs b/Shoko.Plugin.Abstractions/DataModels/EpisodeType.cs index 653d634da..ec4e8c20d 100644 --- a/Shoko.Plugin.Abstractions/DataModels/EpisodeType.cs +++ b/Shoko.Plugin.Abstractions/DataModels/EpisodeType.cs @@ -1,14 +1,37 @@ -using System; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Episode type. +/// public enum EpisodeType { + /// + /// Normal episode. + /// Episode = 1, + + /// + /// Credits. Be it opening credits or ending credits. + /// Credits = 2, + + /// + /// Special episode. + /// Special = 3, + + /// + /// Trailer. + /// Trailer = 4, + + /// + /// Parody. + /// Parody = 5, + /// + /// Other. + /// Other = 6 } diff --git a/Shoko.Plugin.Abstractions/DataModels/IAniDBFile.cs b/Shoko.Plugin.Abstractions/DataModels/IAniDBFile.cs index 9122c6fa9..ef599a733 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IAniDBFile.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IAniDBFile.cs @@ -1,44 +1,54 @@ using System; -namespace Shoko.Plugin.Abstractions.DataModels +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// AniDB file information. +/// +public interface IAniDBFile { - public interface IAniDBFile - { - /// - /// The ID of the file on AniDB - /// - int AniDBFileID { get; } - /// - /// Info about the release group of the file - /// - IReleaseGroup ReleaseGroup { get; } - /// - /// Where the file was ripped from, bluray, dvd, etc - /// - string Source { get; } - /// - /// The Filename as released, according to AniDB. It's usually correct. - /// - string OriginalFilename { get; } - /// - /// Description of the file on AniDB. This will often be blank, and it's generally not useful - /// - string Description { get; } - /// - /// When the file was released, according to AniDB. This will be wrong for a lot of older or less popular anime - /// - DateTime? ReleaseDate { get; } - /// - /// Usually 1. Sometimes 2. 3 happens. It's incremented when a release is updated due to errors - /// - int Version { get; } - /// - /// This is mostly for hentai, and it's often wrong. - /// - bool Censored { get; } - /// - /// AniDB's user input data for streams - /// - AniDBMediaData MediaInfo { get; } - } -} \ No newline at end of file + /// + /// The ID of the file on AniDB + /// + int AniDBFileID { get; } + + /// + /// Info about the release group of the file + /// + IReleaseGroup ReleaseGroup { get; } + + /// + /// Where the file was ripped from, blu-ray, dvd, etc + /// + string Source { get; } + + /// + /// The Filename as released, according to AniDB. It's usually correct. + /// + string OriginalFilename { get; } + + /// + /// Description of the file on AniDB. This will often be blank, and it's generally not useful + /// + string Description { get; } + + /// + /// When the file was released, according to AniDB. This will be wrong for a lot of older or less popular anime + /// + DateTime? ReleaseDate { get; } + + /// + /// Usually 1. Sometimes 2. 3 happens. It's incremented when a release is updated due to errors + /// + int Version { get; } + + /// + /// This is mostly for restricted entries, and it's often wrong. + /// + bool Censored { get; } + + /// + /// AniDB's user input data for streams + /// + AniDBMediaData MediaInfo { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/IAnime.cs b/Shoko.Plugin.Abstractions/DataModels/IAnime.cs deleted file mode 100644 index 82e49f8fe..000000000 --- a/Shoko.Plugin.Abstractions/DataModels/IAnime.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; - -#nullable enable -namespace Shoko.Plugin.Abstractions.DataModels; - -public interface IAnime : ISeries -{ - #region To-be-removed - - /// - /// Relations for the anime. - /// - [Obsolete("Use ISeries.RelatedSeries instead.")] - IReadOnlyList Relations { get; } - - /// - /// The number of total episodes in the series. - /// - [Obsolete("Use ISeries.EpisodeCounts instead.")] - EpisodeCounts EpisodeCounts { get; } - - /// - /// The AniDB Anime ID. - /// - [Obsolete("Use ID instead.")] - int AnimeID { get; } - - #endregion -} diff --git a/Shoko.Plugin.Abstractions/DataModels/IEpisode.cs b/Shoko.Plugin.Abstractions/DataModels/IEpisode.cs index 2a7330406..bc3813766 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IEpisode.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IEpisode.cs @@ -1,16 +1,24 @@ using System; using System.Collections.Generic; +using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; -public interface IEpisode : IWithTitles, IWithDescriptions, IMetadata +/// +/// Episode metadata. +/// +public interface IEpisode : IWithTitles, IWithDescriptions, IWithImages, IMetadata { /// - /// The AniDB Anime ID. + /// The series id. /// int SeriesID { get; } + /// + /// The shoko episode ID, if we have any. + /// + IReadOnlyList ShokoEpisodeIDs { get; } + /// /// The episode type. /// @@ -39,12 +47,12 @@ public interface IEpisode : IWithTitles, IWithDescriptions, IMetadata /// /// Get the series info for the episode, if available. /// - ISeries? SeriesInfo { get; } + ISeries? Series { get; } /// - /// All episodes linked to this entity. + /// All shoko episodes linked to this episode. /// - IReadOnlyList LinkedEpisodes { get; } + IReadOnlyList ShokoEpisodes { get; } /// /// All cross-references linked to the episode. @@ -55,32 +63,4 @@ public interface IEpisode : IWithTitles, IWithDescriptions, IMetadata /// Get all videos linked to the episode, if any. /// IReadOnlyList VideoList { get; } - - #region To-be-removed - - /// - /// The AniDB Episode ID. - /// - [Obsolete("Use ID instead.")] - int EpisodeID { get; } - - /// - /// The AniDB Anime ID. - /// - [Obsolete("Use ShowID instead.")] - int AnimeID { get; } - - /// - /// The runtime of the episode, in seconds. - /// - [Obsolete("Use Runtime instead.")] - int Duration { get; } - - /// - /// - /// - [Obsolete("Use EpisodeNumber instead.")] - int Number { get; } - - #endregion } diff --git a/Shoko.Plugin.Abstractions/DataModels/IGroup.cs b/Shoko.Plugin.Abstractions/DataModels/IGroup.cs deleted file mode 100644 index ca78c6e23..000000000 --- a/Shoko.Plugin.Abstractions/DataModels/IGroup.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; - -#nullable enable -namespace Shoko.Plugin.Abstractions.DataModels; - -public interface IGroup -{ - /// - /// Group Name - /// - string Name { get; } - - /// - /// The series that is used for the name. May be null. Just use Series.FirstOrDefault() at that point. - /// - IAnime MainSeries { get; } - - /// - /// The series in a group, ordered by AirDate - /// - IReadOnlyList Series { get; } -} diff --git a/Shoko.Plugin.Abstractions/DataModels/IHashes.cs b/Shoko.Plugin.Abstractions/DataModels/IHashes.cs index 1b4612363..369105e23 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IHashes.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IHashes.cs @@ -1,11 +1,28 @@ -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Hash container object. +/// public interface IHashes { + /// + /// Gets the CRC32 hash if it's available. + /// string? CRC { get; } + + /// + /// Gets the MD5 hash if it's available. + /// string? MD5 { get; } + + /// + /// Gets the ED2K hash. + /// string ED2K { get; } + + /// + /// Gets the SHA1 hash if it's available. + /// string? SHA1 { get; } } diff --git a/Shoko.Plugin.Abstractions/DataModels/IImageMetadata.cs b/Shoko.Plugin.Abstractions/DataModels/IImageMetadata.cs new file mode 100644 index 000000000..020d7a8e2 --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/IImageMetadata.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Shoko.Plugin.Abstractions.Enums; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Image metadata. +/// +public interface IImageMetadata : IMetadata, IEquatable +{ + /// + /// Image type. + /// + ImageEntityType ImageType { get; } + + /// + /// MIME type for image. + /// + public string ContentType { get; } + + /// + /// Indicates the image is enabled for use. Disabled images should not be + /// used except for administrative purposes. + /// + public bool IsEnabled { get; } + + /// + /// Indicates that the image is the preferred image for the linked entity. + /// + /// + public bool IsPreferred { get; } + + /// + /// Indicates the image is locked and cannot be removed by the user. It can + /// still be disabled though. + /// + public bool IsLocked { get; } + + /// + /// Indicates the image is readily available. + /// + public bool IsAvailable { get; } + + /// + /// Indicates that the image is readily available from the local file system. + /// + public bool IsLocalAvailable { get; } + + /// + /// Indicates that the image is readily available from the remote location. + /// + public bool IsRemoteAvailable { get; } + + /// + /// Image aspect ratio. + /// + /// + double AspectRatio { get; } + + /// + /// Width of the image, in pixels. + /// + int Width { get; } + + /// + /// Height of the image, in pixels. + /// + int Height { get; } + + /// + /// Language code for the language used for the text in the image, if any. + /// Or null if the image doesn't contain any language specifics. + /// + string? LanguageCode { get; } + + /// + /// The language used for any text in the image, if any. + /// Or if the image doesn't contain any + /// language specifics. + /// + TitleLanguage Language { get; } + + /// + /// A full remote URL to fetch the image, if the provider uses remote + /// images. + /// + string? RemoteURL { get; } + + /// + /// Local absolute path to where the image is stored. Will be null if the + /// image is currently not locally available. + /// + string? LocalPath { get; } + + /// + /// Get a stream that reads the image contents from the local copy or remote + /// copy of the image. Returns null if the image is currently unavailable. + /// + /// + /// Allow retrieving the stream from the local cache. + /// + /// + /// Allow retrieving the stream from the remote cache. + /// + /// + /// A stream of the image content, or null. The stream will never be + /// interrupted partway through. + /// + Stream? GetStream(bool allowLocal = true, bool allowRemote = true); + + /// + /// Will attempt to download the remote copy of the image available at + /// to the . + /// + /// Indicates that the image is available locally. + Task DownloadImage(bool force = false); +} diff --git a/Shoko.Plugin.Abstractions/DataModels/IImportFolder.cs b/Shoko.Plugin.Abstractions/DataModels/IImportFolder.cs index faabf59af..5cb610a2d 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IImportFolder.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IImportFolder.cs @@ -1,8 +1,8 @@ -using System; - -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Represents an import folder. +/// public interface IImportFolder { /// @@ -20,18 +20,18 @@ public interface IImportFolder /// string Path { get; } + /// + /// The available free space in the import folder. + /// + /// + /// A value of -1 indicates that the import folder does not + /// exist, while a value of -2 indicates that free space could + /// not be determined. + /// + long AvailableFreeSpace { get; } + /// /// The rules that this Import Folder should adhere to. E.g. a folder that is both a and cares not how files are moved in or out of it. /// DropFolderType DropFolderType { get; } - - #region To-be-removed - - [Obsolete("Use ID instead.")] - int ImportFolderID { get; } - - [Obsolete("Use Path instead.")] - string Location { get; } - - #endregion } diff --git a/Shoko.Plugin.Abstractions/DataModels/IMediaContainer.cs b/Shoko.Plugin.Abstractions/DataModels/IMediaContainer.cs deleted file mode 100644 index 3edd3fa6f..000000000 --- a/Shoko.Plugin.Abstractions/DataModels/IMediaContainer.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Collections.Generic; - -namespace Shoko.Plugin.Abstractions.DataModels -{ - public interface IMediaContainer - { - IGeneralStream General { get; } - IVideoStream Video { get; } - IReadOnlyList Audio { get; } - IReadOnlyList Subs { get; } - bool Chaptered { get; } - } - - public interface IStream - { - string Title { get; set; } - - int StreamOrder { get; set; } - - string Codec { get; set; } - - string CodecID { get; set; } - - /// - /// The Language code (ISO 639-1 in everything I've seen) from MediaInfo - /// - string Language { get; set; } - - /// - /// This is the 3 character language code - /// This is mapped from the Language, it is not MediaInfo data - /// - string LanguageCode { get; set; } - - /// - /// This is the Language Name, "English" - /// This is mapped from the Language, it is not MediaInfo data - /// - string LanguageName { get; set; } - - bool Default { get; set; } - - bool Forced { get; set; } - } - - public interface IGeneralStream : IStream - { - double Duration { get; set; } - - int OverallBitRate { get; set; } - - decimal FrameRate { get; set; } - } - - public interface IVideoStream : IStream - { - int BitRate { get; set; } - - int Width { get; set; } - - int Height { get; set; } - - decimal FrameRate { get; set; } - - string FrameRate_Mode { get; set; } - - int BitDepth { get; set; } - - string StandardizedResolution { get; } - string SimplifiedCodec { get; } - } - - public interface IAudioStream : IStream - { - int Channels { get; set; } - - int SamplingRate { get; set; } - - string Compression_Mode { get; set; } - - int BitRate { get; set; } - - string BitRate_Mode { get; set; } - - int BitDepth { get; set; } - - string SimplifiedCodec { get; } - } - - public interface ITextStream : IStream - { - /// - /// Not From MediaInfo. Is this an external sub file - /// - bool External { get; set; } - - /// - /// Not from MediaInfo, this is the name of the external sub file - /// - string Filename { get; set; } - - string SimplifiedCodec { get; } - } -} \ No newline at end of file diff --git a/Shoko.Plugin.Abstractions/DataModels/IMediaInfo.cs b/Shoko.Plugin.Abstractions/DataModels/IMediaInfo.cs new file mode 100644 index 000000000..7d9373e9b --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/IMediaInfo.cs @@ -0,0 +1,453 @@ +using System; +using System.Collections.Generic; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Media container metadata. +/// +public interface IMediaInfo +{ + /// + /// General title for the media. + /// + string? Title { get; } + + /// + /// Overall duration of the media. + /// + TimeSpan Duration { get; } + + /// + /// Overall bit-rate across all streams in the media container. + /// + int BitRate { get; } + + /// + /// Average frame-rate across all the streams in the media container. + /// + decimal FrameRate { get; } + + /// + /// Date when encoding took place, if known. + /// + DateTime? Encoded { get; } + + /// + /// Indicates the media is streaming-friendly. + /// + bool IsStreamable { get; } + + /// + /// Common file extension for the media container format. + /// + string FileExtension { get; } + + /// + /// The media container format name. + /// + string ContainerName { get; } + + /// + /// The media container format version. + /// + int ContainerVersion { get; } + + /// + /// First available video stream in the media container. + /// + IVideoStream? VideoStream { get; } + + /// + /// Video streams in the media container. + /// + IReadOnlyList VideoStreams { get; } + + /// + /// Audio streams in the media container. + /// + IReadOnlyList AudioStreams { get; } + + /// + /// Sub-title (text) streams in the media container. + /// + IReadOnlyList TextStreams { get; } + + /// + /// Chapter information present in the media container. + /// + IReadOnlyList Chapters { get; } + + /// + /// Container file attachments. + /// + IReadOnlyList Attachments { get; } +} + +/// +/// Stream metadata. +/// +public interface IStream +{ + /// + /// Local id for the stream. + /// + int ID { get; } + + /// + /// Unique id for the stream. + /// + string UID { get; } + + /// + /// Stream title, if available. + /// + string? Title { get; } + + /// + /// Stream order. + /// + int Order { get; } + + /// + /// Indicates this is the default stream of the given type. + /// + bool IsDefault { get; } + + /// + /// Indicates the stream is forced to be used. + /// + bool IsForced { get; } + + /// + /// name of the language of the stream. + /// + TitleLanguage Language { get; } + + /// + /// 3 character language code of the language of the stream. + /// + string? LanguageCode { get; } + + /// + /// Stream codec information. + /// + IStreamCodecInfo Codec { get; } + + /// + /// Stream format information. + /// + IStreamFormatInfo Format { get; } +} + +/// +/// Stream codec information. +/// +public interface IStreamCodecInfo +{ + /// + /// Codec name, if available. + /// + string? Name { get; } + + /// + /// Simplified codec id. + /// + string Simplified { get; } + + /// + /// Raw codec id. + /// + string? Raw { get; } +} + +/// +/// Stream format information. +/// +public interface IStreamFormatInfo +{ + /// + /// Name of the format used. + /// + string Name { get; } + + /// + /// Profile name of the format used, if available. + /// + string? Profile { get; } + + /// + /// Compression level of the format used, if available. + /// + string? Level { get; } + + /// + /// Format settings, if available. + /// + string? Settings { get; } + + /// + /// Known additional features enabled for the format, if available. + /// + string? AdditionalFeatures { get; } + + /// + /// Format endianness, if available. + /// + string? Endianness { get; } + + /// + /// Format tier, if available. + /// + string? Tier { get; } + + /// + /// Format commercial information, if available. + /// + string? Commercial { get; } + + /// + /// HDR format information, if available. + /// + /// + /// Only available for . + /// + string? HDR { get; } + + /// + /// HDR format compatibility information, if available. + /// + /// + /// Only available for . + /// + string? HDRCompatibility { get; } + + /// + /// Context-adaptive binary arithmetic coding (CABAC). + /// + /// + /// Only available for . + /// + bool CABAC { get; } + + /// + /// Bi-directional video object planes (BVOP). + /// + /// + /// Only available for . + /// + bool BVOP { get; } + + /// + /// Quarter-pixel motion (Qpel). + /// + /// + /// Only available for . + /// + bool QPel { get; } + + /// + /// Global Motion Compensation (GMC) mode, if available. + /// + /// + /// Only available for . + /// + string? GMC { get; } + + /// + /// Reference frames count, if known. + /// + /// + /// Only available for . + /// + int? ReferenceFrames { get; } +} + +/// +/// Stream muxing information. +/// +public interface IStreamMuxingInfo +{ + /// + /// Raw muxing mode value. + /// + public string? Raw { get; } +} + +/// +/// Video stream information. +/// +public interface IVideoStream : IStream +{ + /// + /// Width of the video stream. + /// + int Width { get; } + + /// + /// Height of the video stream. + /// + int Height { get; } + + /// + /// Standardized resolution. + /// + string Resolution { get; } + + /// + /// Pixel aspect-ratio. + /// + decimal PixelAspectRatio { get; } + + /// + /// Frame-rate. + /// + decimal FrameRate { get; } + + /// + /// Frame-rate mode. + /// + string FrameRateMode { get; } + + /// + /// Total number of frames in the video stream. + /// + int FrameCount { get; } + + /// + /// Scan-type. Interlaced or progressive. + /// + string ScanType { get; } + + /// + /// Color-space. + /// + string ColorSpace { get; } + + /// + /// Chroma sub-sampling. + /// + string ChromaSubsampling { get; } + + /// + /// Matrix co-efficiency. + /// + string? MatrixCoefficients { get; } + + /// + /// Bit-rate of the video stream. + /// + int BitRate { get; } + + /// + /// Bit-depth of the video stream. + /// + int BitDepth { get; } + + /// + /// How the stream is muxed in the media container. + /// + IStreamMuxingInfo Muxing { get; } +} + +/// +/// Audio stream metadata. +/// +public interface IAudioStream : IStream +{ + /// + /// Number of total channels in the audio stream. + /// + int Channels { get; } + + /// + /// A text representation of the layout of the channels available in the + /// audio stream. + /// + string ChannelLayout { get; } + + /// + /// Samples per frame. + /// + int SamplesPerFrame { get; } + + /// + /// Sampling rate of the audio. + /// + int SamplingRate { get; } + + /// + /// Compression mode used. + /// + string CompressionMode { get; } + + /// + /// Dialog norm of the audio stream, if available. + /// + double? DialogNorm { get; } + + /// + /// Bit-rate of the audio-stream. + /// + int BitRate { get; } + + /// + /// Bit-rate mode of the audio stream. + /// + string BitRateMode { get; } + + /// + /// Bit-depth of the audio stream. + /// + int BitDepth { get; } +} + +/// +/// Text stream information. +/// +public interface ITextStream : IStream +{ + /// + /// Sub-title of the text stream. + /// + /// + string? SubTitle { get; } + + /// + /// Not From MediaInfo. Is this an external sub file + /// + bool IsExternal { get; } + + /// + /// The name of the external subtitle file if this is stream is from an + /// external source. This field is only sent if + /// is set to true. + /// + string? ExternalFilename { get; } +} + +/// +/// Chapter information. +/// +public interface IChapterInfo +{ + /// + /// Chapter title. + /// + string Title { get; } + + /// + /// name of the language the chapter information. + /// + TitleLanguage Language { get; } + + /// + /// 3 character language code of the language the chapter information. + /// + string? LanguageCode { get; } + + /// + /// Chapter timestamp. + /// + TimeSpan Timestamp { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/IMetadata.cs b/Shoko.Plugin.Abstractions/DataModels/IMetadata.cs index 0c873aa33..5348033c2 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IMetadata.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IMetadata.cs @@ -1,14 +1,25 @@ using Shoko.Plugin.Abstractions.Enums; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Base metadata interface. +/// public interface IMetadata { + /// + /// The source of the metadata. + /// DataSourceEnum Source { get; } } +/// +/// Base metadata interface with an ID. +/// public interface IMetadata : IMetadata where TId : struct { + /// + /// The ID of the metadata. + /// TId ID { get; } } diff --git a/Shoko.Plugin.Abstractions/DataModels/IMovie.cs b/Shoko.Plugin.Abstractions/DataModels/IMovie.cs new file mode 100644 index 000000000..754d58387 --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/IMovie.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using Shoko.Plugin.Abstractions.DataModels.Shoko; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Movie metadata. +/// +public interface IMovie : IWithTitles, IWithDescriptions, IWithImages, IMetadata +{ + /// + /// The shoko series ID, if we have any. + /// /// + IReadOnlyList ShokoSeriesIDs { get; } + + /// + /// The shoko episode ID, if we have any. + /// /// + IReadOnlyList ShokoEpisodeIDs { get; } + + /// + /// The first release date of the movie in the country of origin, if it's known. + /// + DateTime? ReleaseDate { get; } + + /// + /// Overall user rating for the show, normalized on a scale of 1-10. + /// + double Rating { get; } + + /// + /// Default poster for the movie. + /// + IImageMetadata? DefaultPoster { get; } + + /// + /// All shoko episodes linked to the movie. + /// + IReadOnlyList ShokoEpisodes { get; } + + /// + /// All shoko series linked to the movie. + /// + IReadOnlyList ShokoSeries { get; } + + /// + /// Related series. + /// + IReadOnlyList> RelatedSeries { get; } + + /// + /// Related movies. + /// + IReadOnlyList> RelatedMovies { get; } + + /// + /// All cross-references linked to the episode. + /// + IReadOnlyList CrossReferences { get; } + + /// + /// Get all videos linked to the series, if any. + /// + IReadOnlyList VideoList { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/IRelatedAnime.cs b/Shoko.Plugin.Abstractions/DataModels/IRelatedAnime.cs deleted file mode 100644 index 01308dc78..000000000 --- a/Shoko.Plugin.Abstractions/DataModels/IRelatedAnime.cs +++ /dev/null @@ -1,10 +0,0 @@ - -#nullable enable -namespace Shoko.Plugin.Abstractions.DataModels; - -public interface IRelatedAnime -{ - int RelatedAnimeID { get; } - IAnime? RelatedAnime { get; } - RelationType RelationType { get; } -} diff --git a/Shoko.Plugin.Abstractions/DataModels/IRelatedMetadata.cs b/Shoko.Plugin.Abstractions/DataModels/IRelatedMetadata.cs index a13f824e0..b960b5552 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IRelatedMetadata.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IRelatedMetadata.cs @@ -1,15 +1,30 @@ -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Related metadata. +/// public interface IRelatedMetadata { + /// + /// Related entity id. + /// int RelatedID { get; } + /// + /// Relation type. + /// RelationType RelationType { get; } } +/// +/// Related metadata with entity. +/// +/// Related entity type. public interface IRelatedMetadata : IMetadata, IRelatedMetadata where TMetadata : IMetadata { + /// + /// Related entity, if available. + /// TMetadata? Related { get; } } diff --git a/Shoko.Plugin.Abstractions/DataModels/IReleaseGroup.cs b/Shoko.Plugin.Abstractions/DataModels/IReleaseGroup.cs index 4cf2c5fab..61c712dbf 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IReleaseGroup.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IReleaseGroup.cs @@ -1,8 +1,18 @@ -namespace Shoko.Plugin.Abstractions.DataModels + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Release group. +/// +public interface IReleaseGroup : IMetadata { - public interface IReleaseGroup - { - string Name { get; } - string ShortName { get; } - } -} \ No newline at end of file + /// + /// The name of the release group. + /// + string? Name { get; } + + /// + /// The short name of the release group. + /// + string? ShortName { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/ISeries.cs b/Shoko.Plugin.Abstractions/DataModels/ISeries.cs index 74b9c5ebd..e175e83e1 100644 --- a/Shoko.Plugin.Abstractions/DataModels/ISeries.cs +++ b/Shoko.Plugin.Abstractions/DataModels/ISeries.cs @@ -2,21 +2,18 @@ using System.Collections.Generic; using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; -public interface ISeries : IWithTitles, IWithDescriptions, IMetadata +/// +/// Series metadata. +/// +public interface ISeries : IWithTitles, IWithDescriptions, IWithImages, IMetadata { /// /// The shoko series ID, if we have any. /// /// IReadOnlyList ShokoSeriesIDs { get; } - /// - /// The shoko group IDs, if we have any. - /// - IReadOnlyList ShokoGroupIDs { get; } - /// /// The Anime Type. /// @@ -44,24 +41,24 @@ public interface ISeries : IWithTitles, IWithDescriptions, IMetadata bool Restricted { get; } /// - /// All shoko series linked to this entity. + /// Default poster for the series. /// - IReadOnlyList ShokoSeries { get; } + IImageMetadata? DefaultPoster { get; } /// /// All shoko series linked to this entity. /// - IReadOnlyList ShokoGroups { get; } + IReadOnlyList ShokoSeries { get; } /// - /// All series linked to this entity. + /// Related series. /// - IReadOnlyList LinkedSeries { get; } + IReadOnlyList> RelatedSeries { get; } /// - /// Related series. + /// Related movies. /// - IReadOnlyList> RelatedSeries { get; } + IReadOnlyList> RelatedMovies { get; } /// /// All cross-references linked to the series. @@ -71,15 +68,15 @@ public interface ISeries : IWithTitles, IWithDescriptions, IMetadata /// /// All known episodes for the show. /// - IReadOnlyList EpisodeList { get; } + IReadOnlyList Episodes { get; } /// - /// Episode counts for every episode type. + /// The number of total episodes in the series. /// - IReadOnlyDictionary EpisodeCountDict { get; } + EpisodeCounts EpisodeCounts { get; } /// /// Get all videos linked to the series, if any. /// - IReadOnlyList VideoList { get; } + IReadOnlyList Videos { get; } } diff --git a/Shoko.Plugin.Abstractions/DataModels/IVideo.cs b/Shoko.Plugin.Abstractions/DataModels/IVideo.cs index 7ef22edda..ac5434a51 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IVideo.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IVideo.cs @@ -1,9 +1,13 @@ using System.Collections.Generic; +using System.IO; +using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Video. +/// public interface IVideo : IMetadata { /// @@ -34,7 +38,7 @@ public interface IVideo : IMetadata /// /// The MediaInfo data for the file. This can be null, but it shouldn't be. /// - IMediaContainer? MediaInfo { get; } + IMediaInfo? MediaInfo { get; } /// /// All cross-references linked to the video. @@ -44,15 +48,20 @@ public interface IVideo : IMetadata /// /// All episodes linked to the video. /// - IReadOnlyList EpisodeInfo { get; } + IReadOnlyList Episodes { get; } /// /// All shows linked to the show. /// - IReadOnlyList SeriesInfo { get; } + IReadOnlyList Series { get; } /// /// Information about the group /// - IReadOnlyList GroupInfo { get; } + IReadOnlyList Groups { get; } + + /// + /// Get the stream for the video, if any files are still available. + /// + Stream? GetStream(); } diff --git a/Shoko.Plugin.Abstractions/DataModels/IVideoCrossReference.cs b/Shoko.Plugin.Abstractions/DataModels/IVideoCrossReference.cs index cb0f7726f..1c9261c6d 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IVideoCrossReference.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IVideoCrossReference.cs @@ -1,7 +1,10 @@ +using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Video cross-reference. +/// public interface IVideoCrossReference : IMetadata { /// @@ -21,7 +24,7 @@ public interface IVideoCrossReference : IMetadata int AnidbEpisodeID { get; } /// - /// AniDB anime ID. Will be available even if is + /// AniDB anime ID. Will be available even if is /// not available yet. /// int AnidbAnimeID { get; } @@ -51,4 +54,14 @@ public interface IVideoCrossReference : IMetadata /// The AniDB anime series, if available. /// ISeries? AnidbAnime { get; } + + /// + /// The Shoko episode, if available. + /// + IShokoEpisode? ShokoEpisode { get; } + + /// + /// The Shoko series, if available. + /// + IShokoSeries? ShokoSeries { get; } } diff --git a/Shoko.Plugin.Abstractions/DataModels/IVideoFile.cs b/Shoko.Plugin.Abstractions/DataModels/IVideoFile.cs index ea7a39e3f..2d032d820 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IVideoFile.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IVideoFile.cs @@ -1,17 +1,19 @@ -using System; +using System.IO; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Video file location. +/// public interface IVideoFile { /// - /// The video file location id. + /// The video file location (VideoLocal_Place) id. /// int ID { get; } /// - /// The video id. + /// The video (VideoLocal) id. /// int VideoID { get; } @@ -26,12 +28,18 @@ public interface IVideoFile string FileName { get; } /// - /// The absolute path leading to the location of the file. Uses an OS dependent directory seperator. + /// The absolute path leading to the location of the file. Uses an OS dependent directory separator. /// string Path { get; } /// - /// The relative path from the to the location of the file. Will always use forward slash as a directory seperator. + /// The relative path from the to the location of the file. Will always use forward slash as a directory + /// separator, and will always start with a leading slash. + ///
+ /// E.g. + /// "C:\absolute\relative\path.ext" becomes "/relative/path.ext" if "C:\absolute" is the import folder. + /// or + /// "/absolute/relative/path.ext" becomes "/relative/path.ext" if "/absolute" is the import folder. ///
string RelativePath { get; } @@ -44,35 +52,15 @@ public interface IVideoFile /// Get the video tied to the video file location. ///
/// - IVideo? VideoInfo { get; } + IVideo Video { get; } /// /// The import folder tied to the video file location. /// - IImportFolder? ImportFolder { get; } + IImportFolder ImportFolder { get; } - #region To-be-removed - - [Obsolete("Use VideoID instead.")] - int VideoFileID { get; } - - [Obsolete("Use FileName instead. Change the 'n' to a 'N' and you're good mate.")] - string Filename { get; } - - [Obsolete("Use Path instead.")] - string FilePath { get; } - - [Obsolete("Use Size instead.")] - long FileSize { get; } - - [Obsolete("Use VideoInfo?.Hashes instead.")] - IHashes? Hashes { get; } - - [Obsolete("Use VideoInfo?.MediaInfo instead")] - IMediaContainer? MediaInfo { get; } - - [Obsolete("Use VideoInfo?.AniDB instead.")] - IAniDBFile? AniDBFileInfo { get; } - - #endregion + /// + /// Get the stream for the video file, if the file is still available. + /// + Stream? GetStream(); } diff --git a/Shoko.Plugin.Abstractions/DataModels/IWithDescriptions.cs b/Shoko.Plugin.Abstractions/DataModels/IWithDescriptions.cs index 18407355b..7527fd237 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IWithDescriptions.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IWithDescriptions.cs @@ -2,6 +2,9 @@ namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Container object with descriptions. +/// public interface IWithDescriptions { /// diff --git a/Shoko.Plugin.Abstractions/DataModels/IWithImages.cs b/Shoko.Plugin.Abstractions/DataModels/IWithImages.cs new file mode 100644 index 000000000..31b8b3fb7 --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/IWithImages.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Shoko.Plugin.Abstractions.Enums; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Container object with images. +/// +public interface IWithImages +{ + /// + /// Get the preferred image for the given for + /// the entity, or null if no preferred image is found. + /// + /// The entity type to search for. + /// The preferred image metadata for the given entity, or null if + /// not found. + IImageMetadata? GetPreferredImageForType(ImageEntityType entityType); + + /// + /// Get all images for the entity, or all images for the given + /// provided for the entity. + /// + /// If set, will restrict the returned list to only + /// containing the images of the given entity type. + /// A read-only list of images that are linked to the entity. + /// + IReadOnlyList GetImages(ImageEntityType? entityType = null); +} diff --git a/Shoko.Plugin.Abstractions/DataModels/IWithTitles.cs b/Shoko.Plugin.Abstractions/DataModels/IWithTitles.cs index 76bef1667..986e4cd86 100644 --- a/Shoko.Plugin.Abstractions/DataModels/IWithTitles.cs +++ b/Shoko.Plugin.Abstractions/DataModels/IWithTitles.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Container object with titles. +/// public interface IWithTitles { /// diff --git a/Shoko.Plugin.Abstractions/DataModels/RelationType.cs b/Shoko.Plugin.Abstractions/DataModels/RelationType.cs index f2d8cccc0..0437e4808 100644 --- a/Shoko.Plugin.Abstractions/DataModels/RelationType.cs +++ b/Shoko.Plugin.Abstractions/DataModels/RelationType.cs @@ -1,5 +1,4 @@ -#nullable enable namespace Shoko.Plugin.Abstractions.DataModels; /// diff --git a/Shoko.Plugin.Abstractions/DataModels/RenameScriptImpl.cs b/Shoko.Plugin.Abstractions/DataModels/RenameScriptImpl.cs deleted file mode 100644 index d89570ca9..000000000 --- a/Shoko.Plugin.Abstractions/DataModels/RenameScriptImpl.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Shoko.Plugin.Abstractions.DataModels -{ - public class RenameScriptImpl : IRenameScript - { - public string Script { get; set; } - - public string Type { get; set; } - - public string ExtraData { get; set; } - } -} diff --git a/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoEpisode.cs b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoEpisode.cs new file mode 100644 index 000000000..e944fa62b --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoEpisode.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Shoko.Plugin.Abstractions.DataModels.Shoko; + +/// +/// Shoko episode metadata. +/// +public interface IShokoEpisode : IEpisode +{ + /// + /// The id of the anidb episode linked to the shoko episode. + /// + int AnidbEpisodeID { get; } + + /// + /// Get the shoko series info for the episode, if available. + /// + new IShokoSeries? Series { get; } + + /// + /// A direct link to the anidb episode metadata. + /// + IEpisode AnidbEpisode { get; } + + /// + /// All episodes linked to this shoko episode. + /// + IReadOnlyList LinkedEpisodes { get; } + + /// + /// All movies linked to this shoko episode. + /// + IReadOnlyList LinkedMovies { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoGroup.cs b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoGroup.cs index 37b5fb379..5478ae962 100644 --- a/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoGroup.cs +++ b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoGroup.cs @@ -3,6 +3,9 @@ namespace Shoko.Plugin.Abstractions.DataModels.Shoko; +/// +/// Shoko group metadata. +/// public interface IShokoGroup : IWithTitles, IWithDescriptions, IMetadata { /// diff --git a/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoSeries.cs b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoSeries.cs index 580e0f7d7..d09a60e63 100644 --- a/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoSeries.cs +++ b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoSeries.cs @@ -1,13 +1,17 @@ +using System.Collections.Generic; namespace Shoko.Plugin.Abstractions.DataModels.Shoko; +/// +/// Shoko series metadata. +/// public interface IShokoSeries : ISeries { /// /// AniDB anime id linked to the Shoko series. /// int AnidbAnimeID { get; } - + /// /// The id of the direct parent group of the series /// @@ -23,6 +27,16 @@ public interface IShokoSeries : ISeries /// ISeries AnidbAnime { get; } + /// + /// All series linked to this shoko series. + /// + IReadOnlyList LinkedSeries { get; } + + /// + /// All movies linked to this shoko series. + /// + IReadOnlyList LinkedMovies { get; } + /// /// The direct parent group of the series. /// @@ -34,4 +48,15 @@ public interface IShokoSeries : ISeries /// structure is. /// IShokoGroup TopLevelGroup { get; } + + /// + /// Get an enumerable for all parent groups, starting at the + /// all the way up to the . + /// + IReadOnlyList AllParentGroups { get; } + + /// + /// All episodes for the the shoko series. + /// + new IReadOnlyList Episodes { get; } } diff --git a/Shoko.Plugin.Abstractions/DataModels/TextDescription.cs b/Shoko.Plugin.Abstractions/DataModels/TextDescription.cs index 932e04554..77af2c2cc 100644 --- a/Shoko.Plugin.Abstractions/DataModels/TextDescription.cs +++ b/Shoko.Plugin.Abstractions/DataModels/TextDescription.cs @@ -1,15 +1,35 @@ - using Shoko.Plugin.Abstractions.Enums; namespace Shoko.Plugin.Abstractions.DataModels; +/// +/// Represents a text description from a data source. +/// public class TextDescription { + /// + /// The source. + /// public DataSourceEnum Source { get; set; } + /// + /// The language. + /// public TitleLanguage Language { get; set; } + /// + /// The language code. + /// public string LanguageCode { get; set; } = string.Empty; + /// + /// The country code. + /// + public string? CountryCode { get; set; } + + /// + /// The value. + /// public string Value { get; set; } = string.Empty; } + diff --git a/Shoko.Plugin.Abstractions/DataModels/TitleLanguage.cs b/Shoko.Plugin.Abstractions/DataModels/TitleLanguage.cs new file mode 100644 index 000000000..8e9d9b2fb --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/TitleLanguage.cs @@ -0,0 +1,623 @@ + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// The language of a title. +/// +public enum TitleLanguage +{ + /// + /// Main. + /// + Main = -2, + + /// + /// None. + /// + None = -1, + + /// + /// Unknown. + /// + Unknown = 0, + + /// + /// English (Any). + /// + English = 1, + + /// + /// Japanese (Romaji / Transcription). + /// + Romaji, + + /// + /// Japanese (Kanji). + /// + Japanese, + + /// + /// Afrikaans. + /// + Afrikaans, + + /// + /// Arabic. + /// + Arabic, + + /// + /// Bangladeshi. + /// + Bangladeshi, + + /// + /// Bulgarian. + /// + Bulgarian, + + /// + /// French Canadian. + /// + FrenchCanadian, + + /// + /// Czech. + /// + Czech, + + /// + /// Danish. + /// + Danish, + + /// + /// German. + /// + German, + + /// + /// Greek. + /// + Greek, + + /// + /// Spanish. + /// + Spanish, + + /// + /// Estonian. + /// + Estonian, + + /// + /// Finnish. + /// + Finnish, + + /// + /// French. + /// + French, + + /// + /// Galician. + /// + Galician, + + /// + /// Hebrew. + /// + Hebrew, + + /// + /// Hungarian. + /// + Hungarian, + + /// + /// Italian. + /// + Italian, + + /// + /// Korean. + /// + Korean, + + /// + /// Lithuanian. + /// + Lithuanian, + + /// + /// Mongolian. + /// + Mongolian, + + /// + /// Malaysian. + /// + Malaysian, + + /// + /// Dutch. + /// + Dutch, + + /// + /// Norwegian. + /// + Norwegian, + + /// + /// Polish. + /// + Polish, + + /// + /// Portuguese. + /// + Portuguese, + + /// + /// Brazilian Portuguese. + /// + BrazilianPortuguese, + + /// + /// Romanian. + /// + Romanian, + + /// + /// Russian. + /// + Russian, + + /// + /// Slovak. + /// + Slovak, + + /// + /// Slovenian. + /// + Slovenian, + + /// + /// Serbian. + /// + Serbian, + + /// + /// Swedish. + /// + Swedish, + + /// + /// Thai. + /// + Thai, + + /// + /// Turkish. + /// + Turkish, + + /// + /// Ukrainian. + /// + Ukrainian, + + /// + /// Vietnamese. + /// + Vietnamese, + + /// + /// Chinese (Any). + /// + Chinese, + + /// + /// Chinese (Simplified). + /// + ChineseSimplified, + + /// + /// Chinese (Traditional). + /// + ChineseTraditional, + + /// + /// Chinese (Pinyin / Transcription). + /// + Pinyin, + + /// + /// Latin. + /// + Latin, + + /// + /// Albanian. + /// + Albanian, + + /// + /// Basque. + /// + Basque, + + /// + /// Bengali. + /// + Bengali, + + /// + /// Bosnian. + /// + Bosnian, + + /// + /// Amharic. + /// + Amharic, + + /// + /// Armenian. + /// + Armenian, + + /// + /// Azerbaijani. + /// + Azerbaijani, + + /// + /// Belarusian. + /// + Belarusian, + + /// + /// Catalan. + /// + Catalan, + + /// + /// Chichewa. + /// + Chichewa, + + /// + /// Corsican. + /// + Corsican, + + /// + /// Croatian. + /// + Croatian, + + /// + /// Divehi. + /// + Divehi, + + /// + /// Esperanto. + /// + Esperanto, + + /// + /// Fijian. + /// + Fijian, + + /// + /// Georgian. + /// + Georgian, + + /// + /// Gujarati. + /// + Gujarati, + + /// + /// Haitian Creole. + /// + HaitianCreole, + + /// + /// Hausa. + /// + Hausa, + + /// + /// Icelandic. + /// + Icelandic, + + /// + /// Igbo. + /// + Igbo, + + /// + /// Indonesian. + /// + Indonesian, + + /// + /// Irish. + /// + Irish, + + /// + /// Javanese. + /// + Javanese, + + /// + /// Kannada. + /// + Kannada, + + /// + /// Kazakh. + /// + Kazakh, + + /// + /// Khmer. + /// + Khmer, + + /// + /// Kurdish. + /// + Kurdish, + + /// + /// Kyrgyz. + /// + Kyrgyz, + + /// + /// Lao. + /// + Lao, + + /// + /// Latvian. + /// + Latvian, + + /// + /// Luxembourgish. + /// + Luxembourgish, + + /// + /// Macedonian. + /// + Macedonian, + + /// + /// Malagasy. + /// + Malagasy, + + /// + /// Malayalam. + /// + Malayalam, + + /// + /// Maltese. + /// + Maltese, + + /// + /// Maori. + /// + Maori, + + /// + /// Marathi. + /// + Marathi, + + /// + /// Myanmar Burmese. + /// + MyanmarBurmese, + + /// + /// Nepali. + /// + Nepali, + + /// + /// Oriya. + /// + Oriya, + + /// + /// Pashto. + /// + Pashto, + + /// + /// Persian. + /// + Persian, + + /// + /// Punjabi. + /// + Punjabi, + + /// + /// Quechua. + /// + Quechua, + + /// + /// Samoan. + /// + Samoan, + + /// + /// Scots Gaelic. + /// + ScotsGaelic, + + /// + /// Sesotho. + /// + Sesotho, + + /// + /// Shona. + /// + Shona, + + /// + /// Sindhi. + /// + Sindhi, + + /// + /// Sinhala. + /// + Sinhala, + + /// + /// Somali. + /// + Somali, + + /// + /// Swahili. + /// + Swahili, + + /// + /// Tajik. + /// + Tajik, + + /// + /// Tamil. + /// + Tamil, + + /// + /// Tatar. + /// + Tatar, + + /// + /// Telugu. + /// + Telugu, + + /// + /// Turkmen. + /// + Turkmen, + + /// + /// Uighur. + /// + Uighur, + + /// + /// Uzbek. + /// + Uzbek, + + /// + /// Welsh. + /// + Welsh, + + /// + /// Xhosa. + /// + Xhosa, + + /// + /// Yiddish. + /// + Yiddish, + + /// + /// Yoruba. + /// + Yoruba, + + /// + /// Zulu. + /// + Zulu, + + /// + /// Hindi. + /// + Hindi, + + /// + /// Filipino. + /// + Filipino, + + /// + /// Korean (Transcription). + /// + KoreanTranscription, + + /// + /// Thai (Transcription). + /// + ThaiTranscription, + + /// + /// Urdu. + /// + Urdu, + + /// + /// English (American). + /// + EnglishAmerican, + + /// + /// English (British). + /// + EnglishBritish, + + /// + /// English (Australian). + /// + EnglishAustralian, + + /// + /// English (Canadian). + /// + EnglishCanadian, + + /// + /// English (India). + /// + EnglishIndia, + + /// + /// English (New Zealand). + /// + EnglishNewZealand, +} diff --git a/Shoko.Plugin.Abstractions/DataModels/TitleType.cs b/Shoko.Plugin.Abstractions/DataModels/TitleType.cs new file mode 100644 index 000000000..9e87fba03 --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/TitleType.cs @@ -0,0 +1,52 @@ +using System.Xml.Serialization; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Represents the type of a title. +/// +public enum TitleType +{ + /// + /// No type specified. + /// + [XmlEnum("none")] + None = 0, + + /// + /// Main title. + /// + [XmlEnum("main")] + Main = 1, + + /// + /// Official title. + /// + [XmlEnum("official")] + Official = 2, + + /// + /// Short title. + /// + [XmlEnum("short")] + Short = 3, + + /// + /// Synonym title. + /// + [XmlEnum("syn")] + Synonym = 4, + + /// + /// Title card. + /// + [XmlEnum("card")] + TitleCard = 5, + + /// + /// Kana reading of the kanji title. + /// + [XmlEnum("kana")] + KanjiReading = 6, +} + diff --git a/Shoko.Plugin.Abstractions/Enums/CodeLanguage.cs b/Shoko.Plugin.Abstractions/Enums/CodeLanguage.cs new file mode 100644 index 000000000..1e91246c9 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Enums/CodeLanguage.cs @@ -0,0 +1,62 @@ +namespace Shoko.Plugin.Abstractions.Enums; + +/// +/// Coding languages for . +/// +public enum CodeLanguage +{ + /// + /// Plain text. + /// + PlainText = 0, + + /// + /// C#. + /// + CSharp = 1, + + /// + /// Java. + /// + Java = 2, + + /// + /// JavaScript. + /// + JavaScript = 3, + + /// + /// TypeScript. + /// + TypeScript = 4, + + /// + /// Lua. + /// + Lua = 5, + + /// + /// Python. + /// + Python = 6, + + /// + /// INI. + /// + Ini = 7, + + /// + /// JSON. + /// + Json = 8, + + /// + /// YAML. + /// + Yaml = 9, + + /// + /// XML. + /// + Xml = 10, +} diff --git a/Shoko.Plugin.Abstractions/Enums/DataSourceEnum.cs b/Shoko.Plugin.Abstractions/Enums/DataSourceEnum.cs index 5bfb92136..9abb5ae4c 100644 --- a/Shoko.Plugin.Abstractions/Enums/DataSourceEnum.cs +++ b/Shoko.Plugin.Abstractions/Enums/DataSourceEnum.cs @@ -1,18 +1,48 @@ -#nullable enable namespace Shoko.Plugin.Abstractions.Enums; /// -/// Just a list of possible data sources. Not all are going to be used...probably +/// Data sources. /// public enum DataSourceEnum { + /// + /// User (Manual). + /// User = -2, + + /// + /// Shoko. + /// Shoko = -1, + + /// + /// AniDB. + /// AniDB = 0, - MovieDB = 1, + + /// + /// The Movie DataBase (TMDB). + /// + TMDB = 1, + + /// + /// The Tv DataBase (TvDB). + /// TvDB = 2, + + /// + /// AniList. + /// AniList = 3, + + /// + /// Animeshon. + /// Animeshon = 4, + + /// + /// TraktTv. + /// Trakt = 5, } diff --git a/Shoko.Plugin.Abstractions/Enums/ImageEntityType.cs b/Shoko.Plugin.Abstractions/Enums/ImageEntityType.cs new file mode 100644 index 000000000..715babc26 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Enums/ImageEntityType.cs @@ -0,0 +1,58 @@ + +namespace Shoko.Plugin.Abstractions.Enums; + +/// +/// Image entity types. +/// +public enum ImageEntityType +{ + /// + /// No image type. + /// + None = 0, + + /// + /// Backdrop image. + /// + Backdrop = 1, + + /// + /// Banner image. + /// + Banner = 2, + + /// + /// Logo image. + /// + Logo = 3, + + /// + /// Art image. + /// + Art = 4, + + /// + /// Disc image. + /// + Disc = 5, + + /// + /// Poster image. + /// + Poster = 6, + + /// + /// Thumbnail image. + /// + Thumbnail = 7, + + /// + /// Person image. + /// + Person = 8, + + /// + /// Character image. + /// + Character = 9, +} diff --git a/Shoko.Plugin.Abstractions/Enums/NetworkAvailibility.cs b/Shoko.Plugin.Abstractions/Enums/NetworkAvailibility.cs index 596050e10..8547d9fd8 100644 --- a/Shoko.Plugin.Abstractions/Enums/NetworkAvailibility.cs +++ b/Shoko.Plugin.Abstractions/Enums/NetworkAvailibility.cs @@ -1,31 +1,33 @@ -namespace Shoko.Plugin.Abstractions.Enums +namespace Shoko.Plugin.Abstractions.Enums; + +/// +/// Network availability. +/// +public enum NetworkAvailability { - public enum NetworkAvailability - { - /// - /// Shoko was unable to find any network interfaces. - /// - NoInterfaces = 0, + /// + /// We were unable to find any network interfaces. + /// + NoInterfaces = 0, - /// - /// Shoko was unable to find any local gateways to use. - /// - NoGateways, + /// + /// We were unable to find any local gateways to use. + /// + NoGateways, - /// - /// Shoko was able to find a local gateway. - /// - LocalOnly, + /// + /// We were able to find a local gateway. + /// + LocalOnly, - /// - /// Shoko was able to connect to some internet endpoints in WAN. - /// - PartialInternet, + /// + /// We were able to connect to some internet endpoints in WAN. + /// + PartialInternet, - /// - /// Shoko was able to connect to all internet endpoints in WAN. - /// - Internet, - } + /// + /// We were able to connect to all internet endpoints in WAN. + /// + Internet, } diff --git a/Shoko.Plugin.Abstractions/Enums/RenamerSettingType.cs b/Shoko.Plugin.Abstractions/Enums/RenamerSettingType.cs new file mode 100644 index 000000000..57fbb5997 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Enums/RenamerSettingType.cs @@ -0,0 +1,44 @@ +namespace Shoko.Plugin.Abstractions.Enums; + +/// +/// Types of settings that can be used on the settings object of a +/// . +/// +public enum RenamerSettingType +{ + /// + /// Auto is a special setting where it figures out what type of setting it + /// is using type reflection. + /// + Auto = 0, + + /// + /// A code setting is a setting that requires a special code to be executed. + /// + Code = 1, + + /// + /// A text setting is a setting that contains a string of text. + /// + Text = 2, + + /// + /// A large text setting is a setting that contains a multi-line string of text. + /// + LargeText = 3, + + /// + /// An integer setting is a setting that contains a number. + /// + Integer = 4, + + /// + /// A decimal setting is a setting that contains a decimal number. + /// + Decimal = 5, + + /// + /// A boolean setting is a setting that contains a true or false value. + /// + Boolean = 6, +} diff --git a/Shoko.Plugin.Abstractions/Enums/UpdateReason.cs b/Shoko.Plugin.Abstractions/Enums/UpdateReason.cs index 7b31c5aab..a5d84cf6c 100644 --- a/Shoko.Plugin.Abstractions/Enums/UpdateReason.cs +++ b/Shoko.Plugin.Abstractions/Enums/UpdateReason.cs @@ -1,11 +1,28 @@ -#nullable enable namespace Shoko.Plugin.Abstractions.Enums; +/// +/// Reason for an metadata update event to be dispatched. +/// public enum UpdateReason { + /// + /// Nothing occirred. + /// None = 0, + + /// + /// The metadata was added. + /// Added = 1, + + /// + /// The metadata was updated. + /// Updated = 2, + + /// + /// The metadata was removed. + /// Removed = 4, } diff --git a/Shoko.Plugin.Abstractions/Events/AVDumpEventArgs.cs b/Shoko.Plugin.Abstractions/Events/AVDumpEventArgs.cs index f9a2ae6ba..8faad2df0 100644 --- a/Shoko.Plugin.Abstractions/Events/AVDumpEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/AVDumpEventArgs.cs @@ -1,10 +1,11 @@ - using System; using System.Collections.Generic; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// AVDump event. +/// public class AVDumpEventArgs : EventArgs { /// @@ -83,12 +84,22 @@ public class AVDumpEventArgs : EventArgs /// public DateTime? EndedAt { get; set; } + /// + /// Create a new AVDump event. + /// + /// The type of event. + /// The message. public AVDumpEventArgs(AVDumpEventType messageType, string? message = null) { Type = messageType; Message = message; } + /// + /// Create a new AVDump event. + /// + /// The type of event. + /// The exception. public AVDumpEventArgs(AVDumpEventType messageType, Exception ex) { Type = messageType; @@ -96,6 +107,9 @@ public AVDumpEventArgs(AVDumpEventType messageType, Exception ex) } } +/// +/// The type of AVDump event. +/// public enum AVDumpEventType { /// diff --git a/Shoko.Plugin.Abstractions/Events/AniDBBannedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/AniDBBannedEventArgs.cs index 7f28fa59b..6a546a34e 100644 --- a/Shoko.Plugin.Abstractions/Events/AniDBBannedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/AniDBBannedEventArgs.cs @@ -1,8 +1,10 @@ using System; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when an AniDB ban is detected. +/// public class AniDBBannedEventArgs : EventArgs { /// @@ -19,6 +21,12 @@ public class AniDBBannedEventArgs : EventArgs /// public DateTime ResumeTime { get; } + /// + /// Initializes a new instance of the class. + /// + /// The type. + /// The time the ban occurred. + /// The resume time. public AniDBBannedEventArgs(AniDBBanType type, DateTime time, DateTime resumeTime) { Type = type; @@ -27,8 +35,18 @@ public AniDBBannedEventArgs(AniDBBanType type, DateTime time, DateTime resumeTim } } +/// +/// Represents the type of AniDB ban. +/// public enum AniDBBanType { + /// + /// UDP ban. + /// UDP, + + /// + /// HTTP ban. + /// HTTP, } diff --git a/Shoko.Plugin.Abstractions/Events/EpisodeInfoUpdatedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/EpisodeInfoUpdatedEventArgs.cs index 943eafacb..c6052b2fa 100644 --- a/Shoko.Plugin.Abstractions/Events/EpisodeInfoUpdatedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/EpisodeInfoUpdatedEventArgs.cs @@ -2,12 +2,14 @@ using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.Enums; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; /// -/// Currently, these will fire a lot in succession, as these are updated in batch with a series. +/// Dispatched when episode data was updated. /// +/// +/// Currently, these will fire a lot in succession, as these are updated in batch with a series. +/// public class EpisodeInfoUpdatedEventArgs : EventArgs { /// @@ -22,10 +24,16 @@ public class EpisodeInfoUpdatedEventArgs : EventArgs /// /// This is the full data. A diff was not performed for this. - /// This is provided for convenience, use + /// This is provided for convenience, use /// public ISeries SeriesInfo { get; } + /// + /// Initializes a new instance of the class. + /// + /// The series info. + /// The episode info. + /// The reason it was updated. public EpisodeInfoUpdatedEventArgs(ISeries seriesInfo, IEpisode episodeInfo, UpdateReason reason) { Reason = reason; diff --git a/Shoko.Plugin.Abstractions/Events/FileDeletedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileDeletedEventArgs.cs deleted file mode 100644 index 16d6a004c..000000000 --- a/Shoko.Plugin.Abstractions/Events/FileDeletedEventArgs.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using Shoko.Plugin.Abstractions.DataModels; - -#nullable enable -namespace Shoko.Plugin.Abstractions; - -public class FileDeletedEventArgs : FileEventArgs -{ - public FileDeletedEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) - : base(relativePath, importFolder, fileInfo, videoInfo, episodeInfo, animeInfo, groupInfo) { } -} diff --git a/Shoko.Plugin.Abstractions/Events/FileDetectedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileDetectedEventArgs.cs index 79d3f5f58..f0411ae96 100644 --- a/Shoko.Plugin.Abstractions/Events/FileDetectedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/FileDetectedEventArgs.cs @@ -2,18 +2,20 @@ using System.IO; using Shoko.Plugin.Abstractions.DataModels; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when a file is detected. +/// public class FileDetectedEventArgs : EventArgs { /// - /// The relative path from the 's root. Uses an OS dependent directory seperator. + /// The relative path from the 's root. Uses an OS dependent directory separator. /// public string RelativePath { get; } /// - /// The raw for the file. Don't go and accidentially delete the file now, okay? + /// The raw for the file. Don't go and accidentally delete the file now, okay? /// public FileInfo FileInfo { get; } @@ -22,6 +24,12 @@ public class FileDetectedEventArgs : EventArgs /// public IImportFolder ImportFolder { get; } + /// + /// Initializes a new instance of the class. + /// + /// The relative path from the 's root. Uses an OS dependent directory separator. + /// The raw for the file. Don't go and accidentally delete the file now, okay? + /// The import folder that the file is in. public FileDetectedEventArgs(string relativePath, FileInfo fileInfo, IImportFolder importFolder) { relativePath = relativePath diff --git a/Shoko.Plugin.Abstractions/Events/FileEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileEventArgs.cs index b4046f412..bfe4e0b47 100644 --- a/Shoko.Plugin.Abstractions/Events/FileEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/FileEventArgs.cs @@ -3,15 +3,35 @@ using System.IO; using System.Linq; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when a file event is needed. This is shared across a few events, +/// and also the base class for more specific events. +/// public class FileEventArgs : EventArgs { + /// + /// Initializes a new instance of the class. + /// + /// The import folder that the file is in. + /// The raw for the file. + /// The video information for the file. + /// + /// This constructor is used to create an instance of when the relative path of the file is not known. + /// public FileEventArgs(IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo) : this(fileInfo.Path.Substring(importFolder.Path.Length), importFolder, fileInfo, videoInfo) { } + /// + /// Initializes a new instance of the class. + /// + /// Relative path to the file. + /// Import folder. + /// File info. + /// Video info. public FileEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo) { relativePath = relativePath @@ -21,19 +41,40 @@ public FileEventArgs(string relativePath, IImportFolder importFolder, IVideoFile relativePath = Path.DirectorySeparatorChar + relativePath; RelativePath = relativePath; ImportFolder = importFolder; - FileInfo = fileInfo; - VideoInfo = videoInfo; - EpisodeInfo = VideoInfo.EpisodeInfo; - AnimeInfo = VideoInfo.SeriesInfo - .OfType() - .ToArray(); - GroupInfo = VideoInfo.GroupInfo; + File = fileInfo; + Video = videoInfo; + Episodes = Video.Episodes; + Series = Video.Series; + Groups = Video.Groups; } - public FileEventArgs(IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) + /// + /// Initializes a new instance of the class. + /// + /// The import folder that the file is in. + /// The raw for the file. + /// The info for the file. + /// The collection of info for the file. + /// The collection of info for the file. + /// The collection of info for the file. + /// + /// This constructor is intended to be used when the relative path is not known. + /// It is recommended to use the other constructor whenever possible. + /// + public FileEventArgs(IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) : this(fileInfo.Path.Substring(importFolder.Path.Length), importFolder, fileInfo, videoInfo, episodeInfo, animeInfo, groupInfo) { } - public FileEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) + /// + /// Initializes a new instance of the class. + /// + /// Relative path to the file. + /// Import folder. + /// File info. + /// Video info. + /// Episode info. + /// Series info. + /// /// Group info. + public FileEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) { relativePath = relativePath .Replace('/', Path.DirectorySeparatorChar) @@ -42,45 +83,46 @@ public FileEventArgs(string relativePath, IImportFolder importFolder, IVideoFile relativePath = Path.DirectorySeparatorChar + relativePath; RelativePath = relativePath; ImportFolder = importFolder; - FileInfo = fileInfo; - VideoInfo = videoInfo; - EpisodeInfo = episodeInfo.ToArray(); - AnimeInfo = animeInfo.ToArray(); - GroupInfo = groupInfo.ToArray(); + File = fileInfo; + Video = videoInfo; + Episodes = episodeInfo.ToArray(); + Series = animeInfo.ToArray(); + Groups = groupInfo.ToArray(); } /// - /// The relative path from the 's root. Uses an OS dependent directory seperator. + /// The relative path from the 's root. + /// Uses an OS dependent directory separator. /// public string RelativePath { get; } /// - /// Information about the video and video file location, such as ids, media info, hashes, etc.. + /// The video file location. /// - public IVideoFile FileInfo { get; } + public IVideoFile File { get; } /// - /// Information about the video. + /// The video. /// - public IVideo VideoInfo { get; } + public IVideo Video { get; } /// - /// The import folder that the file is in. + /// The import folder that the file is located in. /// public IImportFolder ImportFolder { get; } /// - /// Episodes Linked to the file. + /// Episodes linked to the video. /// - public IReadOnlyList EpisodeInfo { get; } + public IReadOnlyList Episodes { get; } /// - /// Information about the Anime, such as titles + /// Series linked to the video. /// - public IReadOnlyList AnimeInfo { get; } + public IReadOnlyList Series { get; } /// - /// Information about the group + /// Groups linked to the series that are in turn linked to the video. /// - public IReadOnlyList GroupInfo { get; } + public IReadOnlyList Groups { get; } } diff --git a/Shoko.Plugin.Abstractions/Events/FileHashedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileHashedEventArgs.cs deleted file mode 100644 index dcb0b215b..000000000 --- a/Shoko.Plugin.Abstractions/Events/FileHashedEventArgs.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using Shoko.Plugin.Abstractions.DataModels; - -#nullable enable -namespace Shoko.Plugin.Abstractions; - -public class FileHashedEventArgs : FileEventArgs -{ - public FileHashedEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) - : base(relativePath, importFolder, fileInfo, videoInfo, episodeInfo, animeInfo, groupInfo) { } -} diff --git a/Shoko.Plugin.Abstractions/Events/FileMatchedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileMatchedEventArgs.cs deleted file mode 100644 index a1e8d9d0d..000000000 --- a/Shoko.Plugin.Abstractions/Events/FileMatchedEventArgs.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; -using Shoko.Plugin.Abstractions.DataModels; - -#nullable enable -namespace Shoko.Plugin.Abstractions; - -public class FileMatchedEventArgs : FileEventArgs -{ - public FileMatchedEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) - : base(relativePath, importFolder, fileInfo, videoInfo, episodeInfo, animeInfo, groupInfo) { } -} - diff --git a/Shoko.Plugin.Abstractions/Events/FileMovedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileMovedEventArgs.cs index ea887f0d4..b145680eb 100644 --- a/Shoko.Plugin.Abstractions/Events/FileMovedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/FileMovedEventArgs.cs @@ -2,10 +2,13 @@ using System.Collections.Generic; using System.IO; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when a file is moved. +/// public class FileMovedEventArgs : FileEventArgs { /// @@ -19,23 +22,19 @@ public class FileMovedEventArgs : FileEventArgs /// public IImportFolder PreviousImportFolder { get; set; } - #region To-be-removed - - [Obsolete("Use ImportFolder instead.")] - public IImportFolder NewImportFolder => ImportFolder; - - [Obsolete("Use RelativePath instead.")] - public string NewRelativePath => RelativePath; - - [Obsolete("Use PreviousImportFolder instead.")] - public IImportFolder OldImportFolder => PreviousImportFolder; - - [Obsolete("Use PreviousRelativePath instead.")] - public string OldRelativePath => PreviousRelativePath; - - #endregion - - public FileMovedEventArgs(string relativePath, IImportFolder importFolder, string previousRelativePath, IImportFolder previousImportFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) + /// + /// Initializes a new instance of the class. + /// + /// Relative path to the file. + /// Import folder. + /// Previous relative path to the file from the 's base location. + /// Previous import folder that the file was in. + /// File info. + /// Video info. + /// Episode info. + /// Series info. + /// Group info. + public FileMovedEventArgs(string relativePath, IImportFolder importFolder, string previousRelativePath, IImportFolder previousImportFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) : base(relativePath, importFolder, fileInfo, videoInfo, episodeInfo, animeInfo, groupInfo) { previousRelativePath = previousRelativePath diff --git a/Shoko.Plugin.Abstractions/Events/FileNotMatchedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileNotMatchedEventArgs.cs index 7ae134703..d0ebd1f20 100644 --- a/Shoko.Plugin.Abstractions/Events/FileNotMatchedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/FileNotMatchedEventArgs.cs @@ -1,9 +1,16 @@ using System.Collections.Generic; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when a file is not matched, be it when it's the first check and +/// there isn't a match yet, or for existing matches if it didn't change from +/// the last check, or if we're UDP banned. Look at +/// , and/or +/// for more info. +/// public class FileNotMatchedEventArgs : FileEventArgs { /// @@ -12,7 +19,7 @@ public class FileNotMatchedEventArgs : FileEventArgs public int AutoMatchAttempts { get; } /// - /// True if this file had existing cross-refernces before this match + /// True if this file had existing cross-references before this match /// attempt. /// public bool HasCrossReferences { get; } @@ -22,7 +29,20 @@ public class FileNotMatchedEventArgs : FileEventArgs /// public bool IsUDPBanned { get; } - public FileNotMatchedEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo, int autoMatchAttempts, bool hasCrossReferences, bool isUdpBanned) + /// + /// Initializes a new instance of the class. + /// + /// Relative path to the file. + /// Import folder. + /// File info. + /// Video info. + /// Episode info. + /// Series info. + /// Group info. + /// Number of times we've tried to auto-match this file up until now. + /// True if this file had existing cross-references before this match attempt. + /// True if we're currently UDP banned. + public FileNotMatchedEventArgs(string relativePath, IImportFolder importFolder, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo, int autoMatchAttempts, bool hasCrossReferences, bool isUdpBanned) : base(relativePath, importFolder, fileInfo, videoInfo, episodeInfo, animeInfo, groupInfo) { AutoMatchAttempts = autoMatchAttempts; diff --git a/Shoko.Plugin.Abstractions/Events/FileRenamedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/FileRenamedEventArgs.cs index 330fd9348..2943af7e1 100644 --- a/Shoko.Plugin.Abstractions/Events/FileRenamedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/FileRenamedEventArgs.cs @@ -1,16 +1,18 @@ - using System; using System.Collections.Generic; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when a file is renamed. +/// public class FileRenamedEventArgs : FileEventArgs { /// /// The previous relative path of the file from the - /// 's base location. + /// 's base location. /// public string PreviousRelativePath => RelativePath.Substring(0, RelativePath.Length - FileName.Length) + PreviousFileName; @@ -25,18 +27,20 @@ public class FileRenamedEventArgs : FileEventArgs /// public string PreviousFileName { get; } - #region To-be-removed - - [Obsolete("Use FileName instead.")] - public string NewFileName => FileName; - - [Obsolete("Use PreviousFileName instead.")] - public string OldFileName => PreviousFileName; - - #endregion - - public FileRenamedEventArgs(string relativePath, IImportFolder importFolder, string fileName, string previousFileName, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) - : base(relativePath, importFolder, fileInfo, videoInfo, episodeInfo, animeInfo, groupInfo) + /// + /// Initializes a new instance of the class. + /// + /// Relative path to the file. + /// Import folder. + /// New file name. + /// Previous file name. + /// File info. + /// Video info. + /// Episode info. + /// Series info. + /// Group info. + public FileRenamedEventArgs(string relativePath, IImportFolder importFolder, string fileName, string previousFileName, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable seriesInfo, IEnumerable groupInfo) + : base(relativePath, importFolder, fileInfo, videoInfo, episodeInfo, seriesInfo, groupInfo) { FileName = fileName; PreviousFileName = previousFileName; diff --git a/Shoko.Plugin.Abstractions/Events/MoveEventArgs.cs b/Shoko.Plugin.Abstractions/Events/MoveEventArgs.cs deleted file mode 100644 index 7f4da3f86..000000000 --- a/Shoko.Plugin.Abstractions/Events/MoveEventArgs.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using Shoko.Plugin.Abstractions.DataModels; - -#nullable enable -namespace Shoko.Plugin.Abstractions; - -/// -/// The event arguments for a Move event. It can be cancelled by setting Cancel to true or skipped by not setting the result parameters. -/// -public class MoveEventArgs : CancelEventArgs -{ - /// - /// The renamer script contents - /// - public IRenameScript Script { get; } - - /// - /// The available import folders to choose as a destination. You can set the to one of these. - /// If a Folder has set, then it won't be in this list. - /// - public IReadOnlyList AvailableFolders { get; } - - /// - /// Information about the file itself, such as MediaInfo - /// - public IVideoFile FileInfo { get; } - - /// - /// Information about the video. - /// - public IVideo VideoInfo { get; } - - /// - /// Information about the episode, such as titles - /// - public IReadOnlyList EpisodeInfo { get; } - - /// - /// Information about the Anime, such as titles - /// - public IReadOnlyList AnimeInfo { get; } - - /// - /// Information about the group - /// - public IReadOnlyList GroupInfo { get; } - - public MoveEventArgs(IRenameScript script, IEnumerable availableFolders, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) - { - Script = script; - AvailableFolders = availableFolders.ToArray(); - FileInfo = fileInfo; - VideoInfo = videoInfo; - EpisodeInfo = episodeInfo.ToArray(); - AnimeInfo = animeInfo.ToArray(); - GroupInfo = groupInfo.ToArray(); - } -} - diff --git a/Shoko.Plugin.Abstractions/Events/MovieInfoUpdatedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/MovieInfoUpdatedEventArgs.cs new file mode 100644 index 000000000..b54d280c6 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Events/MovieInfoUpdatedEventArgs.cs @@ -0,0 +1,32 @@ +using System; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; + +namespace Shoko.Plugin.Abstractions.Events; + +/// +/// Dispatched when movie data was updated. +/// +public class MovieInfoUpdatedEventArgs : EventArgs +{ + /// + /// The reason this updated event was dispatched. + /// + public UpdateReason Reason { get; private set; } + + /// + /// This is the full data. A diff was not performed for this. + /// + public IMovie MovieInfo { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + /// The movie info. + /// The reason it was updated. + public MovieInfoUpdatedEventArgs(IMovie movieInfo, UpdateReason reason) + { + Reason = reason; + MovieInfo = movieInfo; + } +} diff --git a/Shoko.Plugin.Abstractions/Events/NetworkAvailabilityChangedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/NetworkAvailabilityChangedEventArgs.cs index 2240d97c2..73c9f8fa0 100644 --- a/Shoko.Plugin.Abstractions/Events/NetworkAvailabilityChangedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/NetworkAvailabilityChangedEventArgs.cs @@ -1,21 +1,28 @@ using System; using Shoko.Plugin.Abstractions.Enums; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when the network availability changes. +/// public class NetworkAvailabilityChangedEventArgs : EventArgs { /// - /// The new network availibility. + /// The new network availability. /// - public NetworkAvailability NetworkAvailability { get; } + public NetworkAvailability NetworkAvailability { get; private set; } /// /// When the last network change was detected. /// - public DateTime LastCheckedAt { get; } + public DateTime LastCheckedAt { get; private set; } + /// + /// Creates a new . + /// + /// The new network availability. + /// When the last network change was detected. public NetworkAvailabilityChangedEventArgs(NetworkAvailability networkAvailability, DateTime lastCheckedAt) { NetworkAvailability = networkAvailability; diff --git a/Shoko.Plugin.Abstractions/Events/RelocationError.cs b/Shoko.Plugin.Abstractions/Events/RelocationError.cs new file mode 100644 index 000000000..ad7e1e260 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Events/RelocationError.cs @@ -0,0 +1,31 @@ +using System; + +namespace Shoko.Plugin.Abstractions.Events; + +/// +/// An error or exception that occurred during a relocation operation. +/// +public class RelocationError +{ + /// + /// The exception that caused the error, if applicable. + /// + public Exception? Exception { get; private set; } + + /// + /// Error message. Should always be set. + /// + + public string Message { get; private set; } + + /// + /// Create a new relocation error. + /// + /// Error message + /// Exception that caused the error + public RelocationError(string message, Exception? exception = null) + { + Message = message; + Exception = exception; + } +} diff --git a/Shoko.Plugin.Abstractions/Events/RelocationEventArgs.cs b/Shoko.Plugin.Abstractions/Events/RelocationEventArgs.cs new file mode 100644 index 000000000..6b1cc669a --- /dev/null +++ b/Shoko.Plugin.Abstractions/Events/RelocationEventArgs.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.ComponentModel; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; + +#pragma warning disable CS8618 +namespace Shoko.Plugin.Abstractions.Events; + +/// +/// Event Args for File Relocation +/// +public class RelocationEventArgs : CancelEventArgs +{ + /// + /// If the settings have moving enabled + /// + public bool MoveEnabled { get; set; } + + /// + /// If the settings have renaming enabled + /// + public bool RenameEnabled { get; set; } + + /// + /// The available import folders to choose as a destination. You can set the to one of these. + /// If a Folder has set, then it won't be in this list. + /// + public IReadOnlyList AvailableFolders { get; set; } + + /// + /// Information about the file and video, such as MediaInfo, current location, size, etc + /// + public IVideoFile File { get; set; } + + /// + /// Information about the episode, such as titles + /// + public IReadOnlyList Episodes { get; set; } + + /// + /// Information about the Anime, such as titles + /// + public IReadOnlyList Series { get; set; } + + /// + /// Information about the group + /// + public IReadOnlyList Groups { get; set; } +} + +/// +/// Event Args for File Relocation with Settings +/// +public class RelocationEventArgs : RelocationEventArgs where T : class +{ + /// + /// The settings for an + /// + public T Settings { get; set; } +} diff --git a/Shoko.Plugin.Abstractions/Events/RelocationResult.cs b/Shoko.Plugin.Abstractions/Events/RelocationResult.cs new file mode 100644 index 000000000..34338298f --- /dev/null +++ b/Shoko.Plugin.Abstractions/Events/RelocationResult.cs @@ -0,0 +1,68 @@ +using System.IO; +using Shoko.Plugin.Abstractions.DataModels; + +namespace Shoko.Plugin.Abstractions.Events; + +/// +/// The result of a relocation operation by a . +/// +public class RelocationResult +{ + /// + /// The new filename, without any path. + /// + /// + /// If the file name contains a path then it will be moved to + /// if it is unset, or otherwise discarded. + /// + /// This shouldn't be null unless a) there was an , b) + /// the renamer doesn't support renaming, or c) the rename operation will be + /// skipped as indicated by . + /// + public string? FileName { get; set; } + + /// + /// The new path without the , relative to the import + /// folder. + /// + /// + /// Sub-folders should be separated with + /// or . This shouldn't be null + /// unless a) there was an , b) the renamer doesn't + /// support moving, or c) the move operation will be skipped as indicated by + /// . + /// + public string? Path { get; set; } + + /// + /// The new import folder where the file should live. + /// + /// + /// This should be set from , + /// and shouldn't be null unless a) there was an , b) the + /// renamer doesn't support moving, or c) the move operation will be skipped + /// as indicated by . + /// + public IImportFolder? DestinationImportFolder { get; set; } + + /// + /// Indicates that the result does not contain a path and destination, and + /// the file should not be moved. + /// + public bool SkipMove { get; set; } = false; + + /// + /// Indicates that the result does not contain a file name, and the file + /// should not be renamed. + /// + public bool SkipRename { get; set; } = false; + + /// + /// Set this object if the event is not successful. If this is set, it + /// assumed that there was a failure, and the rename/move operation should + /// be aborted. It is required to have at least a message. + /// + /// An exception can be provided if relevant. + /// + public RelocationError? Error { get; set; } +} diff --git a/Shoko.Plugin.Abstractions/Events/RenameEventArgs.cs b/Shoko.Plugin.Abstractions/Events/RenameEventArgs.cs deleted file mode 100644 index 955f87cbe..000000000 --- a/Shoko.Plugin.Abstractions/Events/RenameEventArgs.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using Shoko.Plugin.Abstractions.DataModels; - -#nullable enable -namespace Shoko.Plugin.Abstractions; - -public class RenameEventArgs : CancelEventArgs -{ - /// - /// The contents of the renamer scrpipt - /// - public IRenameScript Script { get; } - - /// - /// The available import folders to choose as a destination. You can set the to one of these. - /// If a Folder has set, then it won't be in this list. - /// - public IReadOnlyList AvailableFolders { get; } - - /// - /// Information about the file itself, such as MediaInfo - /// - public IVideoFile FileInfo { get; } - - /// - /// Information about the video. - /// - public IVideo VideoInfo { get; } - - /// - /// Information about the episode, such as titles - /// - public IReadOnlyList EpisodeInfo { get; } - - /// - /// Information about the Anime, such as titles - /// - public IReadOnlyList AnimeInfo { get; } - - /// - /// Information about the group - /// - public IReadOnlyList GroupInfo { get; } - - public RenameEventArgs(IRenameScript script, IEnumerable availableFolders, IVideoFile fileInfo, IVideo videoInfo, IEnumerable episodeInfo, IEnumerable animeInfo, IEnumerable groupInfo) - { - Script = script; - AvailableFolders = availableFolders.ToArray(); - FileInfo = fileInfo; - VideoInfo = videoInfo; - EpisodeInfo = episodeInfo.ToArray(); - AnimeInfo = animeInfo.ToArray(); - GroupInfo = groupInfo.ToArray(); - } -} diff --git a/Shoko.Plugin.Abstractions/Events/SeriesInfoUpdatedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/SeriesInfoUpdatedEventArgs.cs index 155dbfc77..939e23fae 100644 --- a/Shoko.Plugin.Abstractions/Events/SeriesInfoUpdatedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/SeriesInfoUpdatedEventArgs.cs @@ -1,28 +1,41 @@ using System; +using System.Collections.Generic; +using System.Linq; using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.Enums; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; /// -/// Fired on series info updates, currently, AniDB, TvDB, etc will trigger this +/// Dispatched when a series metadata update occurs. /// public class SeriesInfoUpdatedEventArgs : EventArgs { /// /// The reason this updated event was dispatched. /// - public UpdateReason Reason { get; } + public UpdateReason Reason { get; private set; } /// /// Anime info. This is the full data. A diff was not performed for this /// - public ISeries SeriesInfo { get; } + public ISeries SeriesInfo { get; private set; } - public SeriesInfoUpdatedEventArgs(ISeries seriesInfo, UpdateReason reason) + /// + /// The episodes that were added/updated/removed during this event. + /// + public IReadOnlyList Episodes { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + /// The series info. + /// The reason it was updated. + /// The episodes that were added/updated/removed. + public SeriesInfoUpdatedEventArgs(ISeries seriesInfo, UpdateReason reason, IEnumerable? episodes = null) { Reason = reason; SeriesInfo = seriesInfo; + Episodes = episodes?.ToList() ?? []; } } diff --git a/Shoko.Plugin.Abstractions/Events/SettingsSavedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/SettingsSavedEventArgs.cs index 03a2424e0..43b8da3b1 100644 --- a/Shoko.Plugin.Abstractions/Events/SettingsSavedEventArgs.cs +++ b/Shoko.Plugin.Abstractions/Events/SettingsSavedEventArgs.cs @@ -1,8 +1,10 @@ using System; -#nullable enable -namespace Shoko.Plugin.Abstractions; +namespace Shoko.Plugin.Abstractions.Events; +/// +/// Dispatched when server settings have been saved. +/// public class SettingsSavedEventArgs : EventArgs { } diff --git a/Shoko.Plugin.Abstractions/Extensions/LanguageExtensions.cs b/Shoko.Plugin.Abstractions/Extensions/LanguageExtensions.cs index 3b4383e66..aa3400b2e 100644 --- a/Shoko.Plugin.Abstractions/Extensions/LanguageExtensions.cs +++ b/Shoko.Plugin.Abstractions/Extensions/LanguageExtensions.cs @@ -4,19 +4,34 @@ namespace Shoko.Plugin.Abstractions.Extensions; +/// +/// Extensions for the enum. +/// public static class LanguageExtensions { + /// + /// Convert a language code to a . + /// + /// The language code or name. + /// The or if not found. public static TitleLanguage GetTitleLanguage(this string lang) - { - return lang.ToUpper() switch + => lang?.ToUpper() switch { "EN" or "ENG" => TitleLanguage.English, + "EN-US" => TitleLanguage.EnglishAmerican, + "EN-GB" => TitleLanguage.EnglishBritish, + "EN-AU" => TitleLanguage.EnglishAustralian, + "EN-CA" => TitleLanguage.EnglishCanadian, + "EN-IN" => TitleLanguage.EnglishIndia, + "EN-NZ" => TitleLanguage.EnglishNewZealand, "X-JAT" => TitleLanguage.Romaji, "JA" or "JPN" => TitleLanguage.Japanese, "AR" or "ARA" => TitleLanguage.Arabic, "BD" or "BAN" => TitleLanguage.Bangladeshi, "BG" or "BUL" => TitleLanguage.Bulgarian, - "CA" => TitleLanguage.FrenchCanadian, + // CA isn't actually french canadian, but we have it mapped as such + // because anidb have it mapped as such. + "CA" or "FR-CA" => TitleLanguage.FrenchCanadian, "CS" or "CES" or "CZ" => TitleLanguage.Czech, "DA" or "DAN" or "DK" => TitleLanguage.Danish, "DE" or "DEU" => TitleLanguage.German, @@ -24,12 +39,13 @@ public static TitleLanguage GetTitleLanguage(this string lang) "ES" or "SPA" => TitleLanguage.Spanish, "ET" or "EST" => TitleLanguage.Estonian, "FI" or "FIN" => TitleLanguage.Finnish, - "FR" or "FRA" or "CA" => TitleLanguage.French, + "FR" or "FRA" => TitleLanguage.French, "GL" or "GLG" => TitleLanguage.Galician, "HE" or "HEB" or "IL" => TitleLanguage.Hebrew, "HU" or "HUN" => TitleLanguage.Hungarian, "IT" or "ITA" => TitleLanguage.Italian, "KO" or "KOR" => TitleLanguage.Korean, + "X-KOT" => TitleLanguage.KoreanTranscription, "LT" or "LIT" => TitleLanguage.Lithuanian, "MN" or "MON" => TitleLanguage.Mongolian, "MS" or "MSA" or "MY" => TitleLanguage.Malaysian, @@ -67,11 +83,13 @@ public static TitleLanguage GetTitleLanguage(this string lang) "HR" or "HRV" => TitleLanguage.Croatian, "DV" or "DIV" => TitleLanguage.Divehi, "EO" or "EPO" => TitleLanguage.Esperanto, + "TL" or "FIL" => TitleLanguage.Filipino, "FJ" or "FIJ" => TitleLanguage.Fijian, "KA" or "KAT" => TitleLanguage.Georgian, "GU" or "GUJ" => TitleLanguage.Gujarati, "HT" or "HAT" => TitleLanguage.HaitianCreole, "HA" or "HAU" => TitleLanguage.Hausa, + "HI" or "HIN" => TitleLanguage.Hindi, "IS" or "ISL" => TitleLanguage.Icelandic, "IG" or "IBO" => TitleLanguage.Igbo, "ID" or "IND" => TitleLanguage.Indonesian, @@ -119,39 +137,73 @@ public static TitleLanguage GetTitleLanguage(this string lang) "YI" or "YID" => TitleLanguage.Yiddish, "YO" or "YOR" => TitleLanguage.Yoruba, "ZU" or "ZUL" => TitleLanguage.Zulu, - "UNK" => TitleLanguage.Unknown, - _ => TitleLanguage.Unknown, + "UR" or "URD" => TitleLanguage.Urdu, + "GREEK (ANCIENT)" => TitleLanguage.Greek, + "JAVANESE" or "MALAY" or "INDONESIAN" => TitleLanguage.Malaysian, + "PORTUGUESE (BRAZILIAN)" => TitleLanguage.BrazilianPortuguese, + "THAI (TRANSCRIPTION)" => TitleLanguage.ThaiTranscription, + "CHINESE (SIMPLIFIED)" => TitleLanguage.ChineseSimplified, + "CHINESE (TRADITIONAL)" => TitleLanguage.ChineseTraditional, + "CHINESE (CANTONESE)" or "CHINESE (MANDARIN)" or + "CHINESE (UNSPECIFIED)" or "TAIWANESE" => TitleLanguage.Chinese, + "CHINESE (TRANSCRIPTION)" => TitleLanguage.Pinyin, + "JAPANESE (TRANSCRIPTION)" => TitleLanguage.Romaji, + "CATALAN" or "SPANISH (LATIN AMERICAN)" => TitleLanguage.Spanish, + "KOREAN (TRANSCRIPTION)" => TitleLanguage.KoreanTranscription, + "FILIPINO (TAGALOG)" => TitleLanguage.Filipino, + "" => TitleLanguage.None, + "X-MAIN" => TitleLanguage.Main, + null => TitleLanguage.None, + _ => Enum.TryParse(lang.ToLowerInvariant(), true, out var titleLanguage) ? + titleLanguage : TitleLanguage.Unknown, }; - } - + + /// + /// Get a user friendly description of a . + /// + /// Language value. + /// The friendly description. public static string GetDescription(this TitleLanguage lang) - { - return lang switch + => lang switch { - TitleLanguage.Romaji => "Japanese (romanji/x-jat)", - TitleLanguage.Japanese => "Japanese (kanji)", + TitleLanguage.English => "English (Any)", + TitleLanguage.EnglishAmerican => "English (American)", + TitleLanguage.EnglishBritish => "English (British)", + TitleLanguage.EnglishAustralian => "English (Australian)", + TitleLanguage.EnglishCanadian => "English (Canadian)", + TitleLanguage.EnglishIndia => "English (India)", + TitleLanguage.EnglishNewZealand => "English (New Zealand)", + TitleLanguage.Romaji => "Japanese (Romaji / Transcription)", + TitleLanguage.Japanese => "Japanese (Kanji)", TitleLanguage.Bangladeshi => "Bangladesh", TitleLanguage.FrenchCanadian => "Canadian-French", TitleLanguage.BrazilianPortuguese => "Brazilian Portuguese", TitleLanguage.Chinese => "Chinese (any)", - TitleLanguage.ChineseSimplified => "Chinese (simplified)", - TitleLanguage.ChineseTraditional => "Chinese (traditional)", - TitleLanguage.Pinyin => "Chinese (pinyin/x-zhn)", + TitleLanguage.ChineseSimplified => "Chinese (Simplified)", + TitleLanguage.ChineseTraditional => "Chinese (Traditional)", + TitleLanguage.Pinyin => "Chinese (Pinyin / Transcription)", + TitleLanguage.KoreanTranscription => "Korean (Transcription)", + TitleLanguage.ThaiTranscription => "Thai (Transcription)", _ => lang.ToString(), }; - } + /// + /// Get the preferred language-code for a . + /// + /// Language value. + /// The preferred language-code. public static string GetString(this TitleLanguage lang) - { - return lang switch + => lang switch { + TitleLanguage.Main => "x-main", + TitleLanguage.None => "none", TitleLanguage.English => "en", TitleLanguage.Romaji => "x-jat", TitleLanguage.Japanese => "ja", TitleLanguage.Arabic => "ar", TitleLanguage.Bangladeshi => "bd", TitleLanguage.Bulgarian => "bg", - TitleLanguage.FrenchCanadian => "ca", + TitleLanguage.FrenchCanadian => "fr-CA", TitleLanguage.Czech => "cz", TitleLanguage.Danish => "da", TitleLanguage.German => "de", @@ -165,6 +217,7 @@ public static string GetString(this TitleLanguage lang) TitleLanguage.Hungarian => "hu", TitleLanguage.Italian => "it", TitleLanguage.Korean => "ko", + TitleLanguage.KoreanTranscription => "x-kot", TitleLanguage.Lithuanian => "lt", TitleLanguage.Mongolian => "mn", TitleLanguage.Malaysian => "ms", @@ -172,7 +225,7 @@ public static string GetString(this TitleLanguage lang) TitleLanguage.Norwegian => "no", TitleLanguage.Polish => "pl", TitleLanguage.Portuguese => "pt", - TitleLanguage.BrazilianPortuguese => "pt-br", + TitleLanguage.BrazilianPortuguese => "pt-BR", TitleLanguage.Romanian => "ro", TitleLanguage.Russian => "ru", TitleLanguage.Slovak => "sk", @@ -180,6 +233,7 @@ public static string GetString(this TitleLanguage lang) TitleLanguage.Serbian => "sr", TitleLanguage.Swedish => "sv", TitleLanguage.Thai => "th", + TitleLanguage.ThaiTranscription => "x-tha", TitleLanguage.Turkish => "tr", TitleLanguage.Ukrainian => "uk", TitleLanguage.Vietnamese => "vi", @@ -202,11 +256,13 @@ public static string GetString(this TitleLanguage lang) TitleLanguage.Croatian => "hr", TitleLanguage.Divehi => "dv", TitleLanguage.Esperanto => "eo", + TitleLanguage.Filipino => "tl", TitleLanguage.Fijian => "fj", TitleLanguage.Georgian => "ka", TitleLanguage.Gujarati => "gu", TitleLanguage.HaitianCreole => "ht", TitleLanguage.Hausa => "ha", + TitleLanguage.Hindi => "hi", TitleLanguage.Icelandic => "is", TitleLanguage.Igbo => "ig", TitleLanguage.Indonesian => "id", @@ -249,34 +305,317 @@ public static string GetString(this TitleLanguage lang) TitleLanguage.Turkmen => "tk", TitleLanguage.Uighur => "ug", TitleLanguage.Uzbek => "uz", + TitleLanguage.Urdu => "ur", TitleLanguage.Welsh => "cy", TitleLanguage.Xhosa => "xh", TitleLanguage.Yiddish => "yi", TitleLanguage.Yoruba => "yo", TitleLanguage.Zulu => "zu", + TitleLanguage.EnglishAmerican => "en-US", + TitleLanguage.EnglishBritish => "en-GB", + TitleLanguage.EnglishAustralian => "en-AU", + TitleLanguage.EnglishCanadian => "en-CA", + TitleLanguage.EnglishIndia => "en-IN", + TitleLanguage.EnglishNewZealand => "en-NZ", _ => "unk", }; - } - + + /// + /// Get the language and country code for a . + /// + /// Language value. + public static (string languageCode, string? countryCode) GetLanguageAndCountryCode(this TitleLanguage lang) + => lang.GetString() is { Length: 5 } l && l[2] == '-' ? (l.Substring(0, 2), l.Substring(3)) : (lang.GetString(), null); + + /// + /// Get the text form of a title language. + /// + /// + /// public static string GetString(this TitleType type) - { - return type.ToString().ToLowerInvariant(); - } + => type.ToString().ToLowerInvariant(); + /// + /// Convert from a string to a . + /// + /// Name or value. + /// The parse or if not found. public static TitleType GetTitleType(this string name) - { - foreach (var type in Enum.GetValues(typeof(TitleType)).Cast()) - { - if (type.GetString().Equals(name.ToLowerInvariant())) return type; - } - - return name.ToLowerInvariant() switch + => name.ToLowerInvariant() switch { "syn" => TitleType.Synonym, "card" => TitleType.TitleCard, "kana" => TitleType.KanjiReading, "kanareading" => TitleType.KanjiReading, - _ => TitleType.None, + _ => Enum.TryParse(name, true, out var type) ? type : TitleType.None, + }; + + /// + /// Convert from an ISO3166 Alpha-2 or Alpha-3 country code to an ISO639-1 + /// language code. + /// + /// + /// This conversion list was compiled using + /// https://github.com/annexare/Countries as a base, since it was the most + /// complete library i could find that contained some kind of mapping + /// between countries and languages, and with some minor modifications + /// afterwards. + /// + /// Alpha-2 or Alpha-3 country code. + /// + public static string FromIso3166ToIso639(this string? countryCode) + => countryCode?.ToUpper() switch + { + "AD" or "AND" => "CA", + "AE" or "ARE" => "AR", + "AF" or "AFG" => "PS", + "AG" or "ATG" => "EN", + "AI" or "AIA" => "EN", + "AL" or "ALB" => "SQ", + "AM" or "ARM" => "HY", + "AO" or "AGO" => "PT", + "AQ" or "ATA" => "EN", + "AR" or "ARG" => "ES", + "AS" or "ASM" => "EN", + "AT" or "AUT" => "DE", + "AU" or "AUS" => "EN-AU", + "AW" or "ABW" => "NL", + "AX" or "ALA" => "SV", + "AZ" or "AZE" => "AZ", + "BA" or "BIH" => "BS", + "BB" or "BRB" => "EN", + "BD" or "BGD" => "BN", + "BE" or "BEL" => "NL", + "BF" or "BFA" => "FR", + "BG" or "BGR" => "BG", + "BH" or "BHR" => "AR", + "BI" or "BDI" => "FR", + "BJ" or "BEN" => "FR", + "BL" or "BLM" => "FR", + "BM" or "BMU" => "EN", + "BN" or "BRN" => "MS", + "BO" or "BOL" => "ES", + "BQ" or "BES" => "NL", + "BR" or "BRA" => "PT-BR", + "BS" or "BHS" => "EN", + "BT" or "BTN" => "DZ", + "BV" or "BVT" => "NO", + "BW" or "BWA" => "EN", + "BY" or "BLR" => "BE", + "BZ" or "BLZ" => "EN", + "CA" or "CAN" => "EN-CA", + "CC" or "CCK" => "EN", + "CD" or "COD" => "FR", + "CF" or "CAF" => "FR", + "CG" or "COG" => "FR", + "CH" or "CHE" => "DE", + "CI" or "CIV" => "FR", + "CK" or "COK" => "EN", + "CL" or "CHL" => "ES", + "CM" or "CMR" => "EN", + "CN" or "CHN" => "ZH-HANS", + "CO" or "COL" => "ES", + "CR" or "CRI" => "ES", + "CU" or "CUB" => "ES", + "CV" or "CPV" => "PT", + "CW" or "CUW" => "NL", + "CX" or "CXR" => "EN", + "CY" or "CYP" => "EL", + "CZ" or "CZE" => "CS", + "DE" or "DEU" => "DE", + "DJ" or "DJI" => "FR", + "DK" or "DNK" => "DA", + "DM" or "DMA" => "EN", + "DO" or "DOM" => "ES", + "DZ" or "DZA" => "AR", + "EC" or "ECU" => "ES", + "EE" or "EST" => "ET", + "EG" or "EGY" => "AR", + "EH" or "ESH" => "ES", + "ER" or "ERI" => "TI", + "ES" or "ESP" => "ES", + "ET" or "ETH" => "AM", + "FI" or "FIN" => "FI", + "FJ" or "FJI" => "EN", + "FK" or "FLK" => "EN", + "FM" or "FSM" => "EN", + "FO" or "FRO" => "FO", + "FR" or "FRA" => "FR", + "GA" or "GAB" => "FR", + "GB" or "GBR" => "EN-GB", + "GD" or "GRD" => "EN", + "GE" or "GEO" => "KA", + "GF" or "GUF" => "FR", + "GG" or "GGY" => "EN", + "GH" or "GHA" => "EN", + "GI" or "GIB" => "EN", + "GL" or "GRL" => "KL", + "GM" or "GMB" => "EN", + "GN" or "GIN" => "FR", + "GP" or "GLP" => "FR", + "GQ" or "GNQ" => "ES", + "GR" or "GRC" => "EL", + "GS" or "SGS" => "EN", + "GT" or "GTM" => "ES", + "GU" or "GUM" => "EN", + "GW" or "GNB" => "PT", + "GY" or "GUY" => "EN", + "HK" or "HKG" => "ZH-HANT", + "HM" or "HMD" => "EN", + "HN" or "HND" => "ES", + "HR" or "HRV" => "HR", + "HT" or "HTI" => "FR", + "HU" or "HUN" => "HU", + "ID" or "IDN" => "ID", + "IE" or "IRL" => "GA", + "IL" or "ISR" => "HE", + "IM" or "IMN" => "EN", + "IN" or "IND" => "EN-IN", + "IO" or "IOT" => "EN", + "IQ" or "IRQ" => "AR", + "IR" or "IRN" => "FA", + "IS" or "ISL" => "IS", + "IT" or "ITA" => "IT", + "JE" or "JEY" => "EN", + "JM" or "JAM" => "EN", + "JO" or "JOR" => "AR", + "JP" or "JPN" => "JA", + "KE" or "KEN" => "EN", + "KG" or "KGZ" => "KY", + "KH" or "KHM" => "KM", + "KI" or "KIR" => "EN", + "KM" or "COM" => "AR", + "KN" or "KNA" => "EN", + "KP" or "PRK" => "KO", + "KR" or "KOR" => "KO", + "KW" or "KWT" => "AR", + "KY" or "CYM" => "EN", + "KZ" or "KAZ" => "KK", + "LA" or "LAO" => "LO", + "LB" or "LBN" => "AR", + "LC" or "LCA" => "EN", + "LI" or "LIE" => "DE", + "LK" or "LKA" => "SI", + "LR" or "LBR" => "EN", + "LS" or "LSO" => "EN", + "LT" or "LTU" => "LT", + "LU" or "LUX" => "FR", + "LV" or "LVA" => "LV", + "LY" or "LBY" => "AR", + "MA" or "MAR" => "AR", + "MC" or "MCO" => "FR", + "MD" or "MDA" => "RO", + "ME" or "MNE" => "SR", + "MF" or "MAF" => "EN", + "MG" or "MDG" => "FR", + "MH" or "MHL" => "EN", + "MK" or "MKD" => "MK", + "ML" or "MLI" => "FR", + "MM" or "MMR" => "MY", + "MN" or "MNG" => "MN", + "MO" or "MAC" => "ZH", + "MP" or "MNP" => "EN", + "MQ" or "MTQ" => "FR", + "MR" or "MRT" => "AR", + "MS" or "MSR" => "EN", + "MT" or "MLT" => "MT", + "MU" or "MUS" => "EN", + "MV" or "MDV" => "DV", + "MW" or "MWI" => "EN", + "MX" or "MEX" => "ES", + "MY" or "MYS" => "MS", + "MZ" or "MOZ" => "PT", + "NA" or "NAM" => "EN", + "NC" or "NCL" => "FR", + "NE" or "NER" => "FR", + "NF" or "NFK" => "EN", + "NG" or "NGA" => "EN", + "NI" or "NIC" => "ES", + "NL" or "NLD" => "NL", + "NO" or "NOR" => "NO", + "NP" or "NPL" => "NE", + "NR" or "NRU" => "EN", + "NU" or "NIU" => "EN", + "NZ" or "NZL" => "EN-NZ", + "OM" or "OMN" => "AR", + "PA" or "PAN" => "ES", + "PE" or "PER" => "ES", + "PF" or "PYF" => "FR", + "PG" or "PNG" => "EN", + "PH" or "PHL" => "EN", + "PK" or "PAK" => "EN", + "PL" or "POL" => "PL", + "PM" or "SPM" => "FR", + "PN" or "PCN" => "EN", + "PR" or "PRI" => "ES", + "PS" or "PSE" => "AR", + "PT" or "PRT" => "PT", + "PW" or "PLW" => "EN", + "PY" or "PRY" => "ES", + "QA" or "QAT" => "AR", + "RE" or "REU" => "FR", + "RO" or "ROU" => "RO", + "RS" or "SRB" => "SR", + "RU" or "RUS" => "RU", + "RW" or "RWA" => "RW", + "SA" or "SAU" => "AR", + "SB" or "SLB" => "EN", + "SC" or "SYC" => "FR", + "SD" or "SDN" => "AR", + "SE" or "SWE" => "SV", + "SG" or "SGP" => "EN", + "SH" or "SHN" => "EN", + "SI" or "SVN" => "SL", + "SJ" or "SJM" => "NO", + "SK" or "SVK" => "SK", + "SL" or "SLE" => "EN", + "SM" or "SMR" => "IT", + "SN" or "SEN" => "FR", + "SO" or "SOM" => "SO", + "SR" or "SUR" => "NL", + "SS" or "SSD" => "EN", + "ST" or "STP" => "PT", + "SV" or "SLV" => "ES", + "SX" or "SXM" => "NL", + "SY" or "SYR" => "AR", + "SZ" or "SWZ" => "EN", + "TC" or "TCA" => "EN", + "TD" or "TCD" => "FR", + "TF" or "ATF" => "FR", + "TG" or "TGO" => "FR", + "TH" or "THA" => "TH", + "TJ" or "TJK" => "TG", + "TK" or "TKL" => "EN", + "TL" or "TLS" => "PT", + "TM" or "TKM" => "TK", + "TN" or "TUN" => "AR", + "TO" or "TON" => "EN", + "TR" or "TUR" => "TR", + "TT" or "TTO" => "EN", + "TV" or "TUV" => "EN", + "TW" or "TWN" => "ZH-HANT", + "TZ" or "TZA" => "SW", + "UA" or "UKR" => "UK", + "UG" or "UGA" => "EN", + "UM" or "UMI" => "EN", + "US" or "USA" => "EN-US", + "UY" or "URY" => "ES", + "UZ" or "UZB" => "UZ", + "VA" or "VAT" => "IT", + "VC" or "VCT" => "EN", + "VE" or "VEN" => "ES", + "VG" or "VGB" => "EN", + "VI" or "VIR" => "EN", + "VN" or "VNM" => "VI", + "VU" or "VUT" => "BI", + "WF" or "WLF" => "FR", + "WS" or "WSM" => "SM", + "XK" or "XKX" => "SQ", + "YE" or "YEM" => "AR", + "YT" or "MYT" => "FR", + "ZA" or "ZAF" => "AF", + "ZM" or "ZMB" => "EN", + "ZW" or "ZWE" => "EN", + _ => countryCode?.ToUpper() ?? "UNK", }; - } } diff --git a/Shoko.Plugin.Abstractions/Extensions/NetworkAvailabilityExtensions.cs b/Shoko.Plugin.Abstractions/Extensions/NetworkAvailabilityExtensions.cs index 37bad467b..03722b5a8 100644 --- a/Shoko.Plugin.Abstractions/Extensions/NetworkAvailabilityExtensions.cs +++ b/Shoko.Plugin.Abstractions/Extensions/NetworkAvailabilityExtensions.cs @@ -1,10 +1,17 @@ using Shoko.Plugin.Abstractions.Enums; -namespace Shoko.Plugin.Abstractions.Extensions +namespace Shoko.Plugin.Abstractions.Extensions; + +/// +/// Extensions for the enum. +/// +public static class NetworkAvailabilityExtensions { - public static class NetworkAvailabilityExtensions - { - public static bool HasInternet(this NetworkAvailability value) - => value is NetworkAvailability.Internet or NetworkAvailability.PartialInternet; - } + /// + /// Returns true if the is or + /// + /// Value to check. + /// True if the is or . + public static bool HasInternet(this NetworkAvailability value) + => value is NetworkAvailability.Internet or NetworkAvailability.PartialInternet; } diff --git a/Shoko.Plugin.Abstractions/IPlugin.cs b/Shoko.Plugin.Abstractions/IPlugin.cs index 566da9310..cb86f23ec 100644 --- a/Shoko.Plugin.Abstractions/IPlugin.cs +++ b/Shoko.Plugin.Abstractions/IPlugin.cs @@ -1,24 +1,27 @@  -namespace Shoko.Plugin.Abstractions +namespace Shoko.Plugin.Abstractions; + +/// +/// Interface for plugins to register themselves automagically. +/// +public interface IPlugin { /// - /// This can specify the static method - /// static void ConfigureServices(IServiceCollection serviceCollection) - /// This will allow you to inject other services to the Shoko DI container which can be accessed via Utils.ServiceContainer - /// if you want a Logger, you can use + /// Friendly name of the plugin. + /// + string Name { get; } + + /// + /// Load event. + /// + void Load(); + + /// + /// This will be called with the created settings object if you have an in the Plugin. + /// You can cast to your desired type and set the settings within it. /// - public interface IPlugin - { - string Name { get; } - void Load(); - - /// - /// This will be called with the created settings object if you have an in the Plugin. - /// You can cast to your desired type and set the settings within it. - /// - /// - void OnSettingsLoaded(IPluginSettings settings); + /// + void OnSettingsLoaded(IPluginSettings settings); - // static void ConfigureServices(IServiceCollection serviceCollection); - } -} + // static void ConfigureServices(IServiceCollection serviceCollection); +} diff --git a/Shoko.Plugin.Abstractions/IPluginSettings.cs b/Shoko.Plugin.Abstractions/IPluginSettings.cs index 028cf5b55..fc1a4bcc3 100644 --- a/Shoko.Plugin.Abstractions/IPluginSettings.cs +++ b/Shoko.Plugin.Abstractions/IPluginSettings.cs @@ -1,11 +1,8 @@ -namespace Shoko.Plugin.Abstractions -{ - /// - /// This interface is primarily an identifier. The final model is serialized as json using the Server Settings manager. - /// The Settings are saved in the data folder under Plugins. There can be only one! (per assembly) - /// - public interface IPluginSettings - { - - } -} \ No newline at end of file + +namespace Shoko.Plugin.Abstractions; + +/// +/// This interface is primarily an identifier. The final model is serialized as json using the Server Settings manager. +/// The Settings are saved in the data folder under Plugins. There can be only one! (per assembly) +/// +public interface IPluginSettings { } diff --git a/Shoko.Plugin.Abstractions/IRenameScript.cs b/Shoko.Plugin.Abstractions/IRenameScript.cs deleted file mode 100644 index 209de1906..000000000 --- a/Shoko.Plugin.Abstractions/IRenameScript.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Shoko.Plugin.Abstractions -{ - public interface IRenameScript - { - /// - /// The script contents - /// - public string Script { get; } - /// - /// The type of the renamer, always should be checked against the Renamer ID to ensure that the script should be executable against your renamer. - /// - public string Type { get; } - /// - /// Any extra data provided. - /// - public string ExtraData { get; } - } -} diff --git a/Shoko.Plugin.Abstractions/IRenamer.cs b/Shoko.Plugin.Abstractions/IRenamer.cs index 5aac656fb..c3a886725 100644 --- a/Shoko.Plugin.Abstractions/IRenamer.cs +++ b/Shoko.Plugin.Abstractions/IRenamer.cs @@ -1,18 +1,88 @@ -using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Events; namespace Shoko.Plugin.Abstractions; +/// +/// The base interface for any renamer. +/// +public interface IBaseRenamer +{ + /// + /// The human-readable name of the renamer + /// + public string Name { get; } + + /// + /// The description of the renamer + /// + public string Description { get; } + + /// + /// This should be true if the renamer supports moving. + /// If it is set to false, it will use an with even attempting to use the result from or + /// + bool SupportsMoving { get; } + + /// + /// This should be true if the renamer supports renaming. + /// If it is set to false, it will use an with even attempting to use the result from or + /// + bool SupportsRenaming { get; } +} + /// /// A renamer that knows how to operate on recognized files. /// -public interface IRenamer +public interface IRenamer : IBaseRenamer +{ + /// + /// Get the new path for moving and/or renaming. See and its for details on the return value. + /// + /// + /// + RelocationResult GetNewPath(RelocationEventArgs args); +} +/// +/// A renamer with a settings model. +/// +/// Type of the settings model +public interface IRenamer : IBaseRenamer where T : class { - string GetFilename(RenameEventArgs args); + /// + /// Allows the IRenamer to return a default settings model. + /// + T? DefaultSettings { get; } - (IImportFolder destination, string subfolder) GetDestination(MoveEventArgs args); + /// + /// Get the new path for moving and/or renaming. See and its for details on the return value. + /// + /// + /// + RelocationResult GetNewPath(RelocationEventArgs args); } /// /// A renamer that knows how to operate on both recognized and unrecognized files. /// -public interface IUnrecognizedRenamer : IRenamer { } +public interface IUnrecognizedRenamer : IRenamer; + +/// +/// A renamer that knows how to operate on both recognized and unrecognized files. +/// This version also takes in a settings model. +/// +/// Type of the settings model +public interface IUnrecognizedRenamer : IRenamer where T : class; + +/// +/// A renamer that should be used if the primary renamer does not support the operation. +/// The Legacy/WebAOM renamer does not explicitly implement this, but will be used as a fallback if another is not provided and the primary renamer does not support the operation. +/// +public interface IFallbackRenamer : IRenamer; + +/// +/// A renamer that should be used if the primary renamer does not support the operation. +/// The Legacy/WebAOM renamer does not explicitly implement this, but will be used as a fallback if another is not provided and the primary renamer does not support the operation. +/// This version also takes in a settings model. +/// +/// Type of the settings model +public interface IFallbackRenamer : IRenamer where T : class; diff --git a/Shoko.Plugin.Abstractions/IRenamerConfig.cs b/Shoko.Plugin.Abstractions/IRenamerConfig.cs new file mode 100644 index 000000000..96cdccddb --- /dev/null +++ b/Shoko.Plugin.Abstractions/IRenamerConfig.cs @@ -0,0 +1,36 @@ +using System; + +namespace Shoko.Plugin.Abstractions; + +/// +/// Interface for a renamer configuration type. +/// +public interface IRenamerConfig +{ + /// + /// The ID of the renamer instance + /// + public int ID { get; } + + /// + /// The name of the renamer instance, mostly for user distinction + /// + public string Name { get; } + + /// + /// The type of the renamer, always should be checked against the Renamer ID to ensure that the script should be executable against your renamer. + /// + public Type? Type { get; } +} + +/// +/// Interface for a renamer configuration type with settings. +/// +/// The type of the settings. +public interface IRenamerConfig : IRenamerConfig where T : class +{ + /// + /// The settings for the renamer + /// + T Settings { get; set; } +} diff --git a/Shoko.Plugin.Abstractions/ISettingsProvider.cs b/Shoko.Plugin.Abstractions/ISettingsProvider.cs index e749708ce..33bc36e19 100644 --- a/Shoko.Plugin.Abstractions/ISettingsProvider.cs +++ b/Shoko.Plugin.Abstractions/ISettingsProvider.cs @@ -1,7 +1,14 @@ -namespace Shoko.Plugin.Abstractions + +namespace Shoko.Plugin.Abstractions; + +/// +/// Plugin settings provider. +/// +public interface ISettingsProvider { - public interface ISettingsProvider - { - void SaveSettings(IPluginSettings settings); - } -} \ No newline at end of file + /// + /// Save the plugin settings. + /// + /// Settings to save. + void SaveSettings(IPluginSettings settings); +} diff --git a/Shoko.Plugin.Abstractions/IShokoEventHandler.cs b/Shoko.Plugin.Abstractions/IShokoEventHandler.cs index 9cb63a7c8..d3b3bcab7 100644 --- a/Shoko.Plugin.Abstractions/IShokoEventHandler.cs +++ b/Shoko.Plugin.Abstractions/IShokoEventHandler.cs @@ -1,61 +1,80 @@ using System; +using Shoko.Plugin.Abstractions.Events; -namespace Shoko.Plugin.Abstractions +namespace Shoko.Plugin.Abstractions; + +/// +/// Interface for Shoko event handlers. +/// +public interface IShokoEventHandler { - public interface IShokoEventHandler - { - /// - /// Fired when a file is deleted and removed from Shoko. - /// - event EventHandler FileDeleted; - /// - /// Fired when a file is detected, either during a forced import/scan or a watched folder. - /// Nothing has been done with the file yet here. - /// - event EventHandler FileDetected; - /// - /// Fired when a file is hashed. Has hashes and stuff. - /// - event EventHandler FileHashed; - /// - /// Fired when a file is scanned but no changes to the cross-refernce - /// were made. It can be because the file is unrecognized, or because - /// there was no changes to the existing cross-references linked to the - /// file. - /// - event EventHandler FileNotMatched; - /// - /// Fired when a cross reference is made and data is gathered for a file. This has most if not all relevant data for a file. TvDB may take longer. - /// Use with a filter on the data source to ensure the desired data is gathered. - /// - event EventHandler FileMatched; - /// - /// Fired when a file is renamed - /// - event EventHandler FileRenamed; - /// - /// Fired when a file is moved - /// - event EventHandler FileMoved; - /// - /// Fired when an AniDB Ban happens...and it will. - /// - event EventHandler AniDBBanned; - /// - /// Fired on series info updates. Currently, AniDB, TvDB, etc will trigger this. - /// - event EventHandler SeriesUpdated; - /// - /// Fired on episode info updates. Currently, AniDB, TvDB, etc will trigger this. - /// - event EventHandler EpisodeUpdated; - /// - /// Fired when the core settings has been saved. - /// - event EventHandler SettingsSaved; - /// - /// Fired when an avdump event occurs. - /// - event EventHandler AVDumpEvent; - } + /// + /// Fired when a file is deleted and removed from Shoko. + /// + event EventHandler FileDeleted; + + /// + /// Fired when a file is detected, either during a forced import/scan or a watched folder. + /// Nothing has been done with the file yet here. + /// + event EventHandler FileDetected; + + /// + /// Fired when a file is hashed. Has hashes and stuff. + /// + event EventHandler FileHashed; + + /// + /// Fired when a file is scanned but no changes to the cross-reference + /// were made. It can be because the file is unrecognized, or because + /// there was no changes to the existing cross-references linked to the + /// file. + /// + event EventHandler FileNotMatched; + + /// + /// Fired when a cross reference is made and data is gathered for a file. This has most if not all relevant data for a file. + /// Use with a filter on the data source to ensure the desired data is gathered. + /// + event EventHandler FileMatched; + + /// + /// Fired when a file is renamed + /// + event EventHandler FileRenamed; + + /// + /// Fired when a file is moved + /// + event EventHandler FileMoved; + + /// + /// Fired when an AniDB Ban happens...and it will. + /// + event EventHandler AniDBBanned; + + /// + /// Fired on series info updates. Currently, AniDB, TMDB, etc will trigger this. + /// + event EventHandler SeriesUpdated; + + /// + /// Fired on episode info updates. Currently, AniDB, TMDB, etc will trigger this. + /// + event EventHandler EpisodeUpdated; + + /// + /// Fired on movie info updates. Currently only TMDB will trigger this. + /// + event EventHandler MovieUpdated; + + /// + /// Fired when the core settings has been saved. + /// + event EventHandler SettingsSaved; + + /// + /// Fired when an avdump event occurs. + /// + event EventHandler AVDumpEvent; } diff --git a/Shoko.Plugin.Abstractions/PluginAttribute.cs b/Shoko.Plugin.Abstractions/PluginAttribute.cs index 07519ef00..03fe583eb 100644 --- a/Shoko.Plugin.Abstractions/PluginAttribute.cs +++ b/Shoko.Plugin.Abstractions/PluginAttribute.cs @@ -1,15 +1,24 @@ using System; -namespace Shoko.Plugin.Abstractions +namespace Shoko.Plugin.Abstractions; + +/// +/// An attribute for defining a plugin. +/// +[AttributeUsage(AttributeTargets.Class)] +public class PluginAttribute : Attribute { - [AttributeUsage(AttributeTargets.Class)] - public class PluginAttribute : Attribute - { - public string PluginId { get; set; } + /// + /// The ID of the plugin. + /// + public string PluginId { get; set; } - public PluginAttribute(string pluginId) - { - PluginId = pluginId; - } + /// + /// Initializes a new instance of the class. + /// + /// The ID of the plugin. + public PluginAttribute(string pluginId) + { + PluginId = pluginId; } -} \ No newline at end of file +} diff --git a/Shoko.Plugin.Abstractions/PluginUtilities.cs b/Shoko.Plugin.Abstractions/PluginUtilities.cs index f027b0928..235d0ce74 100644 --- a/Shoko.Plugin.Abstractions/PluginUtilities.cs +++ b/Shoko.Plugin.Abstractions/PluginUtilities.cs @@ -1,47 +1,65 @@ using System; -namespace Shoko.Plugin.Abstractions +namespace Shoko.Plugin.Abstractions; + +/// +/// Plugin utilities. +/// +public static class PluginUtilities { - public static class PluginUtilities + /// + /// Remove invalid path characters. + /// + /// The path. + /// The sanitized path. + public static string RemoveInvalidPathCharacters(this string path) { - public static string RemoveInvalidPathCharacters(this string path) - { - string ret = path.Replace(@"*", string.Empty); - ret = ret.Replace(@"|", string.Empty); - ret = ret.Replace(@"\", string.Empty); - ret = ret.Replace(@"/", string.Empty); - ret = ret.Replace(@":", string.Empty); - ret = ret.Replace("\"", string.Empty); // double quote - ret = ret.Replace(@">", string.Empty); - ret = ret.Replace(@"<", string.Empty); - ret = ret.Replace(@"?", string.Empty); - while (ret.EndsWith(".")) - ret = ret.Substring(0, ret.Length - 1); - return ret.Trim(); - } + var ret = path.Replace(@"*", string.Empty); + ret = ret.Replace(@"|", string.Empty); + ret = ret.Replace(@"\", string.Empty); + ret = ret.Replace(@"/", string.Empty); + ret = ret.Replace(@":", string.Empty); + ret = ret.Replace("\"", string.Empty); // double quote + ret = ret.Replace(@">", string.Empty); + ret = ret.Replace(@"<", string.Empty); + ret = ret.Replace(@"?", string.Empty); + while (ret.EndsWith(".")) + ret = ret.Substring(0, ret.Length - 1); + return ret.Trim(); + } - public static string ReplaceInvalidPathCharacters(this string path) - { - string ret = path.Replace(@"*", "\u2605"); // ★ (BLACK STAR) - ret = ret.Replace(@"|", "\u00a6"); // ¦ (BROKEN BAR) - ret = ret.Replace(@"\", "\u29F9"); // ⧹ (BIG REVERSE SOLIDUS) - ret = ret.Replace(@"/", "\u2044"); // ⁄ (FRACTION SLASH) - ret = ret.Replace(@":", "\u0589"); // ։ (ARMENIAN FULL STOP) - ret = ret.Replace("\"", "\u2033"); // ″ (DOUBLE PRIME) - ret = ret.Replace(@">", "\u203a"); // › (SINGLE RIGHT-POINTING ANGLE QUOTATION MARK) - ret = ret.Replace(@"<", "\u2039"); // ‹ (SINGLE LEFT-POINTING ANGLE QUOTATION MARK) - ret = ret.Replace(@"?", "\uff1f"); // ? (FULL WIDTH QUESTION MARK) - ret = ret.Replace(@"...", "\u2026"); // … (HORIZONTAL ELLIPSIS) - if (ret.StartsWith(".", StringComparison.Ordinal)) ret = "․" + ret.Substring(1, ret.Length - 1); - if (ret.EndsWith(".", StringComparison.Ordinal)) // U+002E - ret = ret.Substring(0, ret.Length - 1) + "․"; // U+2024 - return ret.Trim(); - } + /// + /// Replace invalid path characters. + /// + /// The path. + /// The sanitized path. + public static string ReplaceInvalidPathCharacters(this string path) + { + var ret = path.Replace(@"*", "\u2605"); // ★ (BLACK STAR) + ret = ret.Replace(@"|", "\u00a6"); // ¦ (BROKEN BAR) + ret = ret.Replace(@"\", "\u29F9"); // ⧹ (BIG REVERSE SOLIDUS) + ret = ret.Replace(@"/", "\u2044"); // ⁄ (FRACTION SLASH) + ret = ret.Replace(@":", "\u0589"); // ։ (ARMENIAN FULL STOP) + ret = ret.Replace("\"", "\u2033"); // ″ (DOUBLE PRIME) + ret = ret.Replace(@">", "\u203a"); // › (SINGLE RIGHT-POINTING ANGLE QUOTATION MARK) + ret = ret.Replace(@"<", "\u2039"); // ‹ (SINGLE LEFT-POINTING ANGLE QUOTATION MARK) + ret = ret.Replace(@"?", "\uff1f"); // ? (FULL WIDTH QUESTION MARK) + ret = ret.Replace(@"...", "\u2026"); // … (HORIZONTAL ELLIPSIS) + if (ret.StartsWith(".", StringComparison.Ordinal)) ret = "․" + ret.Substring(1, ret.Length - 1); + if (ret.EndsWith(".", StringComparison.Ordinal)) // U+002E + ret = ret.Substring(0, ret.Length - 1) + "․"; // U+2024 + return ret.Trim(); + } - public static string PadZeroes(this int num, int total) - { - int zeroPadding = total.ToString().Length; - return num.ToString().PadLeft(zeroPadding, '0'); - } + /// + /// Pads the number with zeroes and returns it as a string. + /// + /// Number to pad. + /// The highest number that num can be, used to determine how many zeroes to add. + /// The padded number as a string. + public static string PadZeroes(this int num, int total) + { + var zeroPadding = total.ToString().Length; + return num.ToString().PadLeft(zeroPadding, '0'); } -} \ No newline at end of file +} diff --git a/Shoko.Plugin.Abstractions/Services/IAniDBService.cs b/Shoko.Plugin.Abstractions/Services/IAniDBService.cs index 5c5fb22e7..49b232b59 100644 --- a/Shoko.Plugin.Abstractions/Services/IAniDBService.cs +++ b/Shoko.Plugin.Abstractions/Services/IAniDBService.cs @@ -1,15 +1,20 @@ namespace Shoko.Plugin.Abstractions.Services; +/// +/// AniDB service. +/// public interface IAniDBService { /// /// Is the AniDB UDP API currently reachable? /// public bool IsAniDBUdpReachable { get; } + /// /// Are we currently banned from using the AniDB HTTP API? /// public bool IsAniDBHttpBanned { get; } + /// /// Are we currently banned from using the AniDB UDP API? /// diff --git a/Shoko.Plugin.Abstractions/Services/IConnectivityService.cs b/Shoko.Plugin.Abstractions/Services/IConnectivityService.cs index a086bf2a9..2a85aba4a 100644 --- a/Shoko.Plugin.Abstractions/Services/IConnectivityService.cs +++ b/Shoko.Plugin.Abstractions/Services/IConnectivityService.cs @@ -2,33 +2,33 @@ using System; using System.Threading.Tasks; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; -namespace Shoko.Plugin.Abstractions.Services +namespace Shoko.Plugin.Abstractions.Services; + +/// +/// A service used to check or monitor the current network availability. +/// +public interface IConnectivityService { /// - /// A service used to check or monitor the current network availability. + /// Dispatched when the network availibility has changed. /// - public interface IConnectivityService - { - /// - /// Dispatched when the network availibility has changed. - /// - event EventHandler NetworkAvailabilityChanged; + event EventHandler NetworkAvailabilityChanged; - /// - /// Current network availibility. - /// - public NetworkAvailability NetworkAvailability { get; } + /// + /// Current network availibility. + /// + public NetworkAvailability NetworkAvailability { get; } - /// - /// When the last network change was detected. - /// - public DateTime LastChangedAt { get; } + /// + /// When the last network change was detected. + /// + public DateTime LastChangedAt { get; } - /// - /// Check for network availability now. - /// - /// The updated network availability status. - public Task CheckAvailability(); - } + /// + /// Check for network availability now. + /// + /// The updated network availability status. + public Task CheckAvailability(); } diff --git a/Shoko.Plugin.Abstractions/Services/IRelocationService.cs b/Shoko.Plugin.Abstractions/Services/IRelocationService.cs new file mode 100644 index 000000000..c3466dc82 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Services/IRelocationService.cs @@ -0,0 +1,25 @@ +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Events; + +namespace Shoko.Plugin.Abstractions.Services; + +/// +/// Relocation service. +/// +public interface IRelocationService +{ + /// + /// Get the first destination with enough space for the given file, if any + /// + /// The relocation event args + /// + IImportFolder? GetFirstDestinationWithSpace(RelocationEventArgs args); + + /// + /// Check if the given import folder has enough space for the given file. + /// + /// The import folder + /// The file + /// + bool ImportFolderHasSpace(IImportFolder folder, IVideoFile file); +} diff --git a/Shoko.Plugin.Abstractions/Shoko.Plugin.Abstractions.csproj b/Shoko.Plugin.Abstractions/Shoko.Plugin.Abstractions.csproj index 1ad31bacb..af8c0013a 100644 --- a/Shoko.Plugin.Abstractions/Shoko.Plugin.Abstractions.csproj +++ b/Shoko.Plugin.Abstractions/Shoko.Plugin.Abstractions.csproj @@ -4,6 +4,7 @@ Library latest true + true Interfaces for Shoko Plugins Shoko Team https://shokoanime.com @@ -11,8 +12,8 @@ icon.png https://github.com/ShokoAnime/ShokoServer plugins, shoko, anime, metadata, tagging - File Events - 3.0.0-alpha9 + Renamer Rewrite + 4.0.0 Debug;Release;Benchmarks AnyCPU;x64 false diff --git a/Shoko.Server.sln b/Shoko.Server.sln index d6a57bb23..de0fbdaa8 100644 --- a/Shoko.Server.sln +++ b/Shoko.Server.sln @@ -18,6 +18,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig .git-blame-ignore-revs = .git-blame-ignore-revs .gitignore = .gitignore + Shoko.Server.sln.DotSettings = Shoko.Server.sln.DotSettings EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shoko.CLI", "Shoko.CLI\Shoko.CLI.csproj", "{3A8E0177-9701-4A59-A6CD-16C6908839EA}" @@ -41,6 +42,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\issue-no-response.yml = .github\workflows\issue-no-response.yml .github\workflows\ReplaceAVD3URL.ps1 = .github\workflows\ReplaceAVD3URL.ps1 .github\workflows\ReplaceSentryDSN.ps1 = .github\workflows\ReplaceSentryDSN.ps1 + .github\workflows\ReplaceTmdbApiKey.ps1 = .github\workflows\ReplaceTmdbApiKey.ps1 .github\workflows\UploadRelease.ps1 = .github\workflows\UploadRelease.ps1 EndProjectSection EndProject diff --git a/Shoko.Server.sln.DotSettings b/Shoko.Server.sln.DotSettings index fc15ad7c1..8764318c3 100644 --- a/Shoko.Server.sln.DotSettings +++ b/Shoko.Server.sln.DotSettings @@ -7,6 +7,10 @@ MD SHA DB + TMDB + DBID + TvDB + TVDB HTTP ID OS @@ -23,4 +27,5 @@ True True - True \ No newline at end of file + True + diff --git a/Shoko.Server/API/APIExtensions.cs b/Shoko.Server/API/APIExtensions.cs index aae86bad7..786b4690a 100644 --- a/Shoko.Server/API/APIExtensions.cs +++ b/Shoko.Server/API/APIExtensions.cs @@ -14,6 +14,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Sentry; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.ActionFilters; using Shoko.Server.API.Authentication; using Shoko.Server.API.SignalR; @@ -24,6 +25,7 @@ using Shoko.Server.API.v3.Models.Shoko; using Shoko.Server.API.WebUI; using Shoko.Server.Plugin; +using Shoko.Server.Services; using Shoko.Server.Utilities; using File = System.IO.File; using AniDBEmitter = Shoko.Server.API.SignalR.Aggregate.AniDBEmitter; @@ -48,8 +50,8 @@ public static IServiceCollection AddAPI(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddAuthentication(options => @@ -121,6 +123,8 @@ public static IServiceCollection AddAPI(this IServiceCollection services) options.SchemaFilter>(); options.SchemaFilter>(); + options.SchemaFilter>(); + options.SchemaFilter>(); options.SchemaFilter>(); options.CustomSchemaIds(GetTypeName); @@ -204,7 +208,7 @@ private static string GetGenericTypeName(Type genericType) { return genericType.Name.Replace("+", ".").Replace("`1", "") + "[" + string.Join(",", genericType.GetGenericArguments().Select(GetTypeName)) + "]"; } - + private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) { var info = new OpenApiInfo @@ -269,8 +273,13 @@ public static IApplicationBuilder UseAPI(this IApplicationBuilder app) DefaultContentType = "text/html", OnPrepareResponse = ctx => { - ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate"); - ctx.Context.Response.Headers.Append("Expires", "0"); + var requestPath = ctx.File.PhysicalPath; + // We set the cache headers only for index.html file because it doesn't have a different hash when changed + if (requestPath?.EndsWith("index.html", StringComparison.OrdinalIgnoreCase) ?? false) + { + ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate"); + ctx.Context.Response.Headers.Append("Expires", "0"); + } } }); @@ -329,7 +338,7 @@ public static IApplicationBuilder UseAPI(this IApplicationBuilder app) return app; } - + private static void CopyFilesRecursively(DirectoryInfo source, DirectoryInfo target) { foreach (var dir in source.GetDirectories()) diff --git a/Shoko.Server/API/APIHelper.cs b/Shoko.Server/API/APIHelper.cs index f62928774..8d0bfec63 100644 --- a/Shoko.Server/API/APIHelper.cs +++ b/Shoko.Server/API/APIHelper.cs @@ -1,7 +1,8 @@ using System.Linq; +using System.Runtime.CompilerServices; using System.Security.Claims; using Microsoft.AspNetCore.Http; -using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.Authentication; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.Models; @@ -12,13 +13,8 @@ namespace Shoko.Server.API; public static class APIHelper { - public static string ConstructImageLinkFromTypeAndId(HttpContext ctx, int type, int id, bool short_url = true) - { - var imgType = (ImageEntityType)type; - return ProperURL(ctx, - $"/api/v3/image/{Image.GetSourceFromType(imgType)}/{Image.GetSimpleTypeFromImageType(imgType)}/{id}", - short_url); - } + public static string ConstructImageLinkFromTypeAndId(HttpContext ctx, ImageEntityType imageType, DataSourceEnum dataType, int id, bool short_url = true) + => ProperURL(ctx, $"/api/v3/Image/{dataType.ToV3Dto()}/{imageType.ToV3Dto()}/{id}", short_url); public static string ProperURL(HttpContext ctx, string path, bool short_url = false) { @@ -32,17 +28,26 @@ public static string ProperURL(HttpContext ctx, string path, bool short_url = fa return string.Empty; } + // Only get the user once from the db for the same request, and let the GC + // automagically clean up the user object reference mapping when the request + // is disposed. + private static readonly ConditionalWeakTable _userTable = []; + public static SVR_JMMUser GetUser(this ClaimsPrincipal identity) { if (!ServerState.Instance.ServerOnline) return InitUser.Instance; var authenticated = identity?.Identity?.IsAuthenticated ?? false; if (!authenticated) return null; + if (_userTable.TryGetValue(identity, out var user)) + return user; var nameIdentifier = identity.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value; if (nameIdentifier == null) return null; if (!int.TryParse(nameIdentifier, out var id) || id == 0) return null; - return RepoFactory.JMMUser.GetByID(id); + user = RepoFactory.JMMUser.GetByID(id); + _userTable.AddOrUpdate(identity, user); + return user; } public static SVR_JMMUser GetUser(this HttpContext ctx) diff --git a/Shoko.Server/API/Resolvers/EmitEmptyEnumerableInsteadOfNullResolver.cs b/Shoko.Server/API/Resolvers/EmitEmptyEnumerableInsteadOfNullResolver.cs index bf3cf2b7f..87ade0b4d 100644 --- a/Shoko.Server/API/Resolvers/EmitEmptyEnumerableInsteadOfNullResolver.cs +++ b/Shoko.Server/API/Resolvers/EmitEmptyEnumerableInsteadOfNullResolver.cs @@ -31,9 +31,12 @@ public override void OnActionExecuted(ActionExecutedContext ctx) } // It would be nice if we could cache this somehow, but IDK - objectResult.Formatters.Add(new NewtonsoftJsonOutputFormatter(SerializerSettings, + objectResult.Formatters.Add(new NewtonsoftJsonOutputFormatter( + SerializerSettings, ctx.HttpContext.RequestServices.GetRequiredService>(), - MvcOptions)); + MvcOptions, + null + )); } } diff --git a/Shoko.Server/API/SignalR/Aggregate/AVDumpEmitter.cs b/Shoko.Server/API/SignalR/Aggregate/AVDumpEmitter.cs index 4c3ebad0a..6214ca87d 100644 --- a/Shoko.Server/API/SignalR/Aggregate/AVDumpEmitter.cs +++ b/Shoko.Server/API/SignalR/Aggregate/AVDumpEmitter.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.AspNetCore.SignalR; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.API.SignalR.Models; using Shoko.Server.Utilities; diff --git a/Shoko.Server/API/SignalR/Aggregate/AniDBEmitter.cs b/Shoko.Server/API/SignalR/Aggregate/AniDBEmitter.cs index 20faf598c..b47c86aeb 100644 --- a/Shoko.Server/API/SignalR/Aggregate/AniDBEmitter.cs +++ b/Shoko.Server/API/SignalR/Aggregate/AniDBEmitter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.SignalR; +using Shoko.Server.API.SignalR.Models; using Shoko.Server.Providers.AniDB; using Shoko.Server.Providers.AniDB.Interfaces; @@ -27,32 +28,20 @@ public void Dispose() private async void OnUDPStateUpdate(object sender, AniDBStateUpdate e) { - await SendAsync("AniDBUDPStateUpdate", e); + await SendAsync("AniDBUDPStateUpdate", new AniDBStatusUpdateSignalRModel(e)); } private async void OnHttpStateUpdate(object sender, AniDBStateUpdate e) { - await SendAsync("AniDBHttpStateUpdate", e); + await SendAsync("AniDBHttpStateUpdate", new AniDBStatusUpdateSignalRModel(e)); } public override object GetInitialMessage() { - return new List + return new List { - new() - { - UpdateType = UpdateType.UDPBan, - UpdateTime = UDPHandler.BanTime ?? DateTime.Now, - Value = UDPHandler.IsBanned, - PauseTimeSecs = (int)TimeSpan.FromHours(UDPHandler.BanTimerResetLength).TotalSeconds - }, - new() - { - UpdateType = UpdateType.HTTPBan, - UpdateTime = HttpHandler.BanTime ?? DateTime.Now, - Value = HttpHandler.IsBanned, - PauseTimeSecs = (int)TimeSpan.FromHours(HttpHandler.BanTimerResetLength).TotalSeconds - } + new(UpdateType.UDPBan, UDPHandler.IsBanned, UDPHandler.BanTime ?? DateTime.Now, (int)TimeSpan.FromHours(UDPHandler.BanTimerResetLength).TotalSeconds), + new(UpdateType.HTTPBan, HttpHandler.IsBanned, HttpHandler.BanTime ?? DateTime.Now, (int)TimeSpan.FromHours(HttpHandler.BanTimerResetLength).TotalSeconds), }; } } diff --git a/Shoko.Server/API/SignalR/Aggregate/NetworkEmitter.cs b/Shoko.Server/API/SignalR/Aggregate/NetworkEmitter.cs index 595e09ffa..cb569aa79 100644 --- a/Shoko.Server/API/SignalR/Aggregate/NetworkEmitter.cs +++ b/Shoko.Server/API/SignalR/Aggregate/NetworkEmitter.cs @@ -1,6 +1,7 @@ using System; using Microsoft.AspNetCore.SignalR; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.SignalR.Models; diff --git a/Shoko.Server/API/SignalR/Aggregate/ShokoEventEmitter.cs b/Shoko.Server/API/SignalR/Aggregate/ShokoEventEmitter.cs index fe0b2c2dd..1809a35ca 100644 --- a/Shoko.Server/API/SignalR/Aggregate/ShokoEventEmitter.cs +++ b/Shoko.Server/API/SignalR/Aggregate/ShokoEventEmitter.cs @@ -1,6 +1,7 @@ using System; using Microsoft.AspNetCore.SignalR; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.API.SignalR.Models; namespace Shoko.Server.API.SignalR.Aggregate; @@ -21,6 +22,7 @@ public ShokoEventEmitter(IHubContext hub, IShokoEventHandler event EventHandler.FileDeleted += OnFileDeleted; EventHandler.SeriesUpdated += OnSeriesUpdated; EventHandler.EpisodeUpdated += OnEpisodeUpdated; + EventHandler.MovieUpdated += OnMovieUpdated; } public void Dispose() @@ -34,6 +36,7 @@ public void Dispose() EventHandler.FileDeleted -= OnFileDeleted; EventHandler.SeriesUpdated -= OnSeriesUpdated; EventHandler.EpisodeUpdated -= OnEpisodeUpdated; + EventHandler.MovieUpdated -= OnMovieUpdated; } private async void OnFileDetected(object sender, FileDetectedEventArgs e) @@ -41,19 +44,19 @@ private async void OnFileDetected(object sender, FileDetectedEventArgs e) await SendAsync("FileDetected", new FileDetectedEventSignalRModel(e)); } - private async void OnFileDeleted(object sender, FileDeletedEventArgs e) + private async void OnFileDeleted(object sender, FileEventArgs e) { - await SendAsync("FileDeleted", new FileDeletedEventSignalRModel(e)); + await SendAsync("FileDeleted", new FileEventSignalRModel(e)); } - private async void OnFileHashed(object sender, FileHashedEventArgs e) + private async void OnFileHashed(object sender, FileEventArgs e) { - await SendAsync("FileHashed", new FileHashedEventSignalRModel(e)); + await SendAsync("FileHashed", new FileEventSignalRModel(e)); } - private async void OnFileMatched(object sender, FileMatchedEventArgs e) + private async void OnFileMatched(object sender, FileEventArgs e) { - await SendAsync("FileMatched", new FileMatchedEventSignalRModel(e)); + await SendAsync("FileMatched", new FileEventSignalRModel(e)); } private async void OnFileRenamed(object sender, FileRenamedEventArgs e) @@ -81,6 +84,11 @@ private async void OnEpisodeUpdated(object sender, EpisodeInfoUpdatedEventArgs e await SendAsync("EpisodeUpdated", new EpisodeInfoUpdatedEventSignalRModel(e)); } + private async void OnMovieUpdated(object sender, MovieInfoUpdatedEventArgs e) + { + await SendAsync("MovieUpdated", new MovieInfoUpdatedEventSignalRModel(e)); + } + public override object GetInitialMessage() { // No back data for this diff --git a/Shoko.Server/API/SignalR/Legacy/AniDBEmitter.cs b/Shoko.Server/API/SignalR/Legacy/AniDBEmitter.cs index b59f0243c..f7f59105d 100644 --- a/Shoko.Server/API/SignalR/Legacy/AniDBEmitter.cs +++ b/Shoko.Server/API/SignalR/Legacy/AniDBEmitter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; +using Shoko.Server.API.SignalR.Models; using Shoko.Server.Providers.AniDB; using Shoko.Server.Providers.AniDB.Interfaces; @@ -33,21 +34,21 @@ public async Task OnConnectedAsync(IClientProxy caller) await caller.SendAsync("AniDBState", new Dictionary { {"UDPBanned", UDPHandler.IsBanned}, - {"UDPBanTime", UDPHandler.BanTime}, + {"UDPBanTime", UDPHandler.BanTime?.ToUniversalTime()}, {"UDPBanWaitPeriod", UDPHandler.BanTimerResetLength}, {"HttpBanned", HttpHandler.IsBanned}, - {"HttpBanTime", HttpHandler.BanTime}, + {"HttpBanTime", HttpHandler.BanTime?.ToUniversalTime()}, {"HttpBanWaitPeriod", HttpHandler.BanTimerResetLength}, }); } private async void OnUDPStateUpdate(object sender, AniDBStateUpdate e) { - await Hub.Clients.All.SendAsync("AniDBUDPStateUpdate", e); + await Hub.Clients.All.SendAsync("AniDBUDPStateUpdate", new AniDBStatusUpdateSignalRModel(e)); } private async void OnHttpStateUpdate(object sender, AniDBStateUpdate e) { - await Hub.Clients.All.SendAsync("AniDBHttpStateUpdate", e); + await Hub.Clients.All.SendAsync("AniDBHttpStateUpdate", new AniDBStatusUpdateSignalRModel(e)); } } diff --git a/Shoko.Server/API/SignalR/Legacy/ShokoEventEmitter.cs b/Shoko.Server/API/SignalR/Legacy/ShokoEventEmitter.cs index 4229ceebc..6045690ec 100644 --- a/Shoko.Server/API/SignalR/Legacy/ShokoEventEmitter.cs +++ b/Shoko.Server/API/SignalR/Legacy/ShokoEventEmitter.cs @@ -1,6 +1,7 @@ using System; using Microsoft.AspNetCore.SignalR; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.API.SignalR.Models; namespace Shoko.Server.API.SignalR.Legacy; @@ -19,9 +20,11 @@ public ShokoEventEmitter(IHubContext hub, IShokoEventHandler even EventHandler.FileMatched += OnFileMatched; EventHandler.FileRenamed += OnFileRenamed; EventHandler.FileMoved += OnFileMoved; + EventHandler.FileDeleted += OnFileDeleted; EventHandler.FileNotMatched += OnFileNotMatched; EventHandler.SeriesUpdated += OnSeriesUpdated; EventHandler.EpisodeUpdated += OnEpisodeUpdated; + EventHandler.MovieUpdated += OnMovieUpdated; } public void Dispose() @@ -31,9 +34,11 @@ public void Dispose() EventHandler.FileMatched -= OnFileMatched; EventHandler.FileRenamed -= OnFileRenamed; EventHandler.FileMoved -= OnFileMoved; + EventHandler.FileDeleted -= OnFileDeleted; EventHandler.FileNotMatched -= OnFileNotMatched; EventHandler.SeriesUpdated -= OnSeriesUpdated; EventHandler.EpisodeUpdated -= OnEpisodeUpdated; + EventHandler.MovieUpdated -= OnMovieUpdated; } private async void OnFileDetected(object sender, FileDetectedEventArgs e) @@ -41,14 +46,19 @@ private async void OnFileDetected(object sender, FileDetectedEventArgs e) await Hub.Clients.All.SendAsync("FileDetected", new FileDetectedEventSignalRModel(e)); } - private async void OnFileHashed(object sender, FileHashedEventArgs e) + private async void OnFileDeleted(object sender, FileEventArgs e) { - await Hub.Clients.All.SendAsync("FileHashed", new FileHashedEventSignalRModel(e)); + await Hub.Clients.All.SendAsync("FileDeleted", new FileEventSignalRModel(e)); } - private async void OnFileMatched(object sender, FileMatchedEventArgs e) + private async void OnFileHashed(object sender, FileEventArgs e) { - await Hub.Clients.All.SendAsync("FileMatched", new FileMatchedEventSignalRModel(e)); + await Hub.Clients.All.SendAsync("FileHashed", new FileEventSignalRModel(e)); + } + + private async void OnFileMatched(object sender, FileEventArgs e) + { + await Hub.Clients.All.SendAsync("FileMatched", new FileEventSignalRModel(e)); } private async void OnFileRenamed(object sender, FileRenamedEventArgs e) @@ -75,4 +85,9 @@ private async void OnEpisodeUpdated(object sender, EpisodeInfoUpdatedEventArgs e { await Hub.Clients.All.SendAsync("EpisodeUpdated", new EpisodeInfoUpdatedEventSignalRModel(e)); } + + private async void OnMovieUpdated(object sender, MovieInfoUpdatedEventArgs e) + { + await Hub.Clients.All.SendAsync("MovieUpdated", new MovieInfoUpdatedEventSignalRModel(e)); + } } diff --git a/Shoko.Server/API/SignalR/Models/AVDumpEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/AVDumpEventSignalRModel.cs index 3712e34aa..30eca2c69 100644 --- a/Shoko.Server/API/SignalR/Models/AVDumpEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/AVDumpEventSignalRModel.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.Utilities; #nullable enable diff --git a/Shoko.Server/API/SignalR/Models/AniDBStatusUpdateSignalRModel.cs b/Shoko.Server/API/SignalR/Models/AniDBStatusUpdateSignalRModel.cs new file mode 100644 index 000000000..a13d24b02 --- /dev/null +++ b/Shoko.Server/API/SignalR/Models/AniDBStatusUpdateSignalRModel.cs @@ -0,0 +1,51 @@ +using System; +using Shoko.Server.Providers.AniDB; + +#nullable enable +namespace Shoko.Server.API.SignalR.Models; + +public class AniDBStatusUpdateSignalRModel +{ + /// + /// The value of the UpdateType, is the ban active, is it waiting on a response, etc + /// + public bool Value { get; set; } + + /// + /// Auxiliary Message for some states + /// + public string Message { get; set; } + + /// + /// Update type, Ban, Invalid Session, Waiting on Response, etc + /// + public UpdateType UpdateType { get; set; } + + /// + /// When was it updated, usually Now, but may not be + /// + public DateTime UpdateTime { get; set; } + + /// + /// If we are pausing the queue, then for how long(er) + /// + public int PauseTimeSecs { get; set; } + + public AniDBStatusUpdateSignalRModel(UpdateType updateType, bool value, DateTime updateTime, int pauseTimeSecs, string message = "") + { + Value = value; + Message = message; + UpdateType = updateType; + UpdateTime = updateTime.ToUniversalTime(); + PauseTimeSecs = pauseTimeSecs; + } + + public AniDBStatusUpdateSignalRModel(AniDBStateUpdate update) + { + Value = update.Value; + Message = update.Message; + UpdateType = update.UpdateType; + UpdateTime = update.UpdateTime.ToUniversalTime(); + PauseTimeSecs = update.PauseTimeSecs; + } +} diff --git a/Shoko.Server/API/SignalR/Models/EpisodeInfoUpdatedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/EpisodeInfoUpdatedEventSignalRModel.cs index 8c010be26..037eb1cc9 100644 --- a/Shoko.Server/API/SignalR/Models/EpisodeInfoUpdatedEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/EpisodeInfoUpdatedEventSignalRModel.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; -using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Plugin.Abstractions; using Shoko.Plugin.Abstractions.Enums; -using Shoko.Server.Models; +using Shoko.Plugin.Abstractions.Events; #nullable enable namespace Shoko.Server.API.SignalR.Models; @@ -17,29 +16,8 @@ public EpisodeInfoUpdatedEventSignalRModel(EpisodeInfoUpdatedEventArgs eventArgs Reason = eventArgs.Reason; EpisodeID = eventArgs.EpisodeInfo.ID; SeriesID = eventArgs.SeriesInfo.ID; - ShokoEpisodeIDs = []; - ShokoSeriesIDs = []; - ShokoGroupIDs = []; - // TODO: Add support for more metadata sources when they're hooked up internally. - switch (Source) - { - case DataSourceEnum.Shoko when eventArgs.EpisodeInfo is SVR_AnimeEpisode shokoEpisode: - { - ShokoEpisodeIDs = [shokoEpisode.AnimeEpisodeID]; - ShokoSeriesIDs = [shokoEpisode.AnimeSeriesID]; - if (eventArgs.SeriesInfo is SVR_AnimeSeries series) - ShokoGroupIDs = series.AllGroupsAbove.Select(g => g.AnimeGroupID).ToArray(); - } - break; - case DataSourceEnum.AniDB when eventArgs.EpisodeInfo is SVR_AniDB_Episode anidbEpisode && anidbEpisode.AnimeEpisode is SVR_AnimeEpisode shokoEpisode: - { - ShokoEpisodeIDs = [shokoEpisode.AnimeEpisodeID]; - ShokoSeriesIDs = [shokoEpisode.AnimeSeriesID]; - if (shokoEpisode.AnimeSeries is SVR_AnimeSeries series) - ShokoGroupIDs = series.AllGroupsAbove.Select(g => g.AnimeGroupID).ToArray(); - } - break; - } + ShokoEpisodeIDs = eventArgs.EpisodeInfo.ShokoEpisodeIDs; + ShokoSeriesIDs = eventArgs.SeriesInfo.ShokoSeriesIDs; } /// @@ -73,9 +51,4 @@ public EpisodeInfoUpdatedEventSignalRModel(EpisodeInfoUpdatedEventArgs eventArgs /// Shoko series ids affected by this update. /// public IReadOnlyList ShokoSeriesIDs { get; } - - /// - /// Shoko group ids affected by this update. - /// - public IReadOnlyList ShokoGroupIDs { get; } } diff --git a/Shoko.Server/API/SignalR/Models/FileDeletedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileDeletedEventSignalRModel.cs deleted file mode 100644 index 46dd6b568..000000000 --- a/Shoko.Server/API/SignalR/Models/FileDeletedEventSignalRModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Shoko.Plugin.Abstractions; - -#nullable enable -namespace Shoko.Server.API.SignalR.Models; - -public class FileDeletedEventSignalRModel : FileEventSignalRModel -{ - public FileDeletedEventSignalRModel(FileDeletedEventArgs eventArgs) : base(eventArgs) { } -} diff --git a/Shoko.Server/API/SignalR/Models/FileDetectedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileDetectedEventSignalRModel.cs index 6b8427193..ed1271887 100644 --- a/Shoko.Server/API/SignalR/Models/FileDetectedEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/FileDetectedEventSignalRModel.cs @@ -1,4 +1,5 @@ using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; #nullable enable namespace Shoko.Server.API.SignalR.Models; diff --git a/Shoko.Server/API/SignalR/Models/FileEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileEventSignalRModel.cs index a07ae3479..84320e315 100644 --- a/Shoko.Server/API/SignalR/Models/FileEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/FileEventSignalRModel.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Linq; +using Shoko.Commons.Extensions; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.Models; #nullable enable @@ -11,28 +13,26 @@ public class FileEventSignalRModel public FileEventSignalRModel(FileEventArgs eventArgs) { RelativePath = eventArgs.RelativePath; - FileID = eventArgs.FileInfo.VideoID; - FileLocationID = eventArgs.FileInfo.ID; + FileID = eventArgs.File.VideoID; + FileLocationID = eventArgs.File.ID; ImportFolderID = eventArgs.ImportFolder.ID; - var xrefs = eventArgs.VideoInfo.CrossReferences; - var episodeDict = eventArgs.EpisodeInfo - .Cast() - .Select(e => e.AnimeEpisode) - .Where(e => e != null) - .ToDictionary(e => e!.AniDB_EpisodeID, e => e!); + var xrefs = eventArgs.Video.CrossReferences; + var episodeDict = eventArgs.Episodes + .WhereNotNull() + .ToDictionary(e => e.AnidbEpisodeID, e => e); var animeToGroupDict = episodeDict.Values - .DistinctBy(e => e.AnimeSeriesID) - .Select(e => e.AnimeSeries) - .Where(s => s != null) - .ToDictionary(s => s.AniDB_ID, s => (s.AnimeSeriesID, s.AnimeGroupID)); + .DistinctBy(e => e.SeriesID) + .Select(e => e.Series) + .WhereNotNull() + .ToDictionary(s => s.AnidbAnimeID, s => (s.ID, s.ParentGroupID)); CrossReferences = xrefs .Select(xref => new FileCrossReferenceSignalRModel { - EpisodeID = episodeDict.TryGetValue(xref.AnidbEpisodeID, out var shokoEpisode) ? shokoEpisode.AnimeEpisodeID : null, + EpisodeID = episodeDict.TryGetValue(xref.AnidbEpisodeID, out var shokoEpisode) ? shokoEpisode.ID : null, AnidbEpisodeID = xref.AnidbEpisodeID, - SeriesID = animeToGroupDict.TryGetValue(xref.AnidbAnimeID, out var tuple) ? tuple.AnimeSeriesID : null, + SeriesID = animeToGroupDict.TryGetValue(xref.AnidbAnimeID, out var tuple) ? tuple.ID : null, AnidbAnimeID = xref.AnidbAnimeID, - GroupID = animeToGroupDict.TryGetValue(xref.AnidbAnimeID, out tuple) ? tuple.AnimeGroupID : null, + GroupID = animeToGroupDict.TryGetValue(xref.AnidbAnimeID, out tuple) ? tuple.ParentGroupID : null, }) .ToList(); } diff --git a/Shoko.Server/API/SignalR/Models/FileHashedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileHashedEventSignalRModel.cs deleted file mode 100644 index e29865b9f..000000000 --- a/Shoko.Server/API/SignalR/Models/FileHashedEventSignalRModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Shoko.Plugin.Abstractions; - -#nullable enable -namespace Shoko.Server.API.SignalR.Models; - -public class FileHashedEventSignalRModel : FileEventSignalRModel -{ - public FileHashedEventSignalRModel(FileHashedEventArgs eventArgs) : base(eventArgs) { } -} diff --git a/Shoko.Server/API/SignalR/Models/FileMatchedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileMatchedEventSignalRModel.cs deleted file mode 100644 index 3ecca0a67..000000000 --- a/Shoko.Server/API/SignalR/Models/FileMatchedEventSignalRModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Shoko.Plugin.Abstractions; -using Shoko.Server.Models; - -#nullable enable -namespace Shoko.Server.API.SignalR.Models; - -public class FileMatchedEventSignalRModel : FileEventSignalRModel -{ - public FileMatchedEventSignalRModel(FileMatchedEventArgs eventArgs) : base(eventArgs) { } -} diff --git a/Shoko.Server/API/SignalR/Models/FileMovedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileMovedEventSignalRModel.cs index 8435a9b09..88c75ea88 100644 --- a/Shoko.Server/API/SignalR/Models/FileMovedEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/FileMovedEventSignalRModel.cs @@ -1,4 +1,5 @@ using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; namespace Shoko.Server.API.SignalR.Models; diff --git a/Shoko.Server/API/SignalR/Models/FileNotMatchedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileNotMatchedEventSignalRModel.cs index e7a3fd105..6a6b99998 100644 --- a/Shoko.Server/API/SignalR/Models/FileNotMatchedEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/FileNotMatchedEventSignalRModel.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.Models; namespace Shoko.Server.API.SignalR.Models; diff --git a/Shoko.Server/API/SignalR/Models/FileRenamedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/FileRenamedEventSignalRModel.cs index cd14f4363..1f7189e9c 100644 --- a/Shoko.Server/API/SignalR/Models/FileRenamedEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/FileRenamedEventSignalRModel.cs @@ -1,4 +1,5 @@ using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; namespace Shoko.Server.API.SignalR.Models; diff --git a/Shoko.Server/API/SignalR/Models/MovieInfoUpdatedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/MovieInfoUpdatedEventSignalRModel.cs new file mode 100644 index 000000000..8fbe5f627 --- /dev/null +++ b/Shoko.Server/API/SignalR/Models/MovieInfoUpdatedEventSignalRModel.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; + +#nullable enable +namespace Shoko.Server.API.SignalR.Models; + +public class MovieInfoUpdatedEventSignalRModel +{ + public MovieInfoUpdatedEventSignalRModel(MovieInfoUpdatedEventArgs eventArgs) + { + Source = eventArgs.MovieInfo.Source; + Reason = eventArgs.Reason; + MovieID = eventArgs.MovieInfo.ID; + ShokoEpisodeIDs = eventArgs.MovieInfo.ShokoEpisodeIDs; + ShokoSeriesIDs = eventArgs.MovieInfo.ShokoSeriesIDs; + } + + /// + /// The provider metadata source. + /// + [JsonConverter(typeof(StringEnumConverter))] + public DataSourceEnum Source { get; } + + /// + /// The update reason. + /// + [JsonConverter(typeof(StringEnumConverter))] + public UpdateReason Reason { get; } + + /// + /// The provided metadata movie id. + /// + public int MovieID { get; } + + /// + /// Shoko episode ids affected by this update. + /// + public IReadOnlyList ShokoEpisodeIDs { get; } + + /// + /// Shoko series ids affected by this update. + /// + public IReadOnlyList ShokoSeriesIDs { get; } +} diff --git a/Shoko.Server/API/SignalR/Models/NetworkAvailabilitySignalRModel.cs b/Shoko.Server/API/SignalR/Models/NetworkAvailabilitySignalRModel.cs index f15187693..0c53bf3a7 100644 --- a/Shoko.Server/API/SignalR/Models/NetworkAvailabilitySignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/NetworkAvailabilitySignalRModel.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json.Converters; using Shoko.Plugin.Abstractions; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; namespace Shoko.Server.API.SignalR.Models; diff --git a/Shoko.Server/API/SignalR/Models/QueueStateSignalRModel.cs b/Shoko.Server/API/SignalR/Models/QueueStateSignalRModel.cs index 1c21532f2..62245673c 100644 --- a/Shoko.Server/API/SignalR/Models/QueueStateSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/QueueStateSignalRModel.cs @@ -35,5 +35,5 @@ public class QueueStateSignalRModel /// /// The currently executing jobs and their details /// - public List CurrentlyExecuting { get; set; } + public List CurrentlyExecuting { get; set; } = []; } diff --git a/Shoko.Server/API/SignalR/Models/SeriesInfoUpdatedEventSignalRModel.cs b/Shoko.Server/API/SignalR/Models/SeriesInfoUpdatedEventSignalRModel.cs index caf795c5f..676ba54b6 100644 --- a/Shoko.Server/API/SignalR/Models/SeriesInfoUpdatedEventSignalRModel.cs +++ b/Shoko.Server/API/SignalR/Models/SeriesInfoUpdatedEventSignalRModel.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Plugin.Abstractions; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; #nullable enable namespace Shoko.Server.API.SignalR.Models; @@ -14,9 +16,8 @@ public SeriesInfoUpdatedEventSignalRModel(SeriesInfoUpdatedEventArgs eventArgs) Source = eventArgs.SeriesInfo.Source; Reason = eventArgs.Reason; SeriesID = eventArgs.SeriesInfo.ID; - ShokoSeriesIDs = eventArgs.SeriesInfo.ShokoSeriesIDs; - ShokoGroupIDs = eventArgs.SeriesInfo.ShokoGroupIDs; + Episodes = eventArgs.Episodes.Select(e => new SeriesInfoUpdatedEventEpisodeDetailsSignalRModel(e)).ToList(); } /// @@ -42,7 +43,33 @@ public SeriesInfoUpdatedEventSignalRModel(SeriesInfoUpdatedEventArgs eventArgs) public IReadOnlyList ShokoSeriesIDs { get; } /// - /// Shoko group ids affected by this update. + /// The episodes that were added/updated/removed during this event. /// - public IReadOnlyList ShokoGroupIDs { get; } + public IReadOnlyList Episodes { get; } + + public class SeriesInfoUpdatedEventEpisodeDetailsSignalRModel + { + /// + /// The update reason. + /// + [JsonConverter(typeof(StringEnumConverter))] + public UpdateReason Reason { get; } + + /// + /// The provided metadata episode id. + /// + public int EpisodeID { get; } + + /// + /// Shoko episode ids affected by this update. + /// + public IReadOnlyList ShokoEpisodeIDs { get; } + + public SeriesInfoUpdatedEventEpisodeDetailsSignalRModel(EpisodeInfoUpdatedEventArgs eventArgs) + { + Reason = eventArgs.Reason; + EpisodeID = eventArgs.EpisodeInfo.ID; + ShokoEpisodeIDs = eventArgs.EpisodeInfo.ShokoEpisodeIDs; + } + } } diff --git a/Shoko.Server/API/SignalR/NLog/SignalRTarget.cs b/Shoko.Server/API/SignalR/NLog/SignalRTarget.cs index 174d5ccbc..efc54e14b 100644 --- a/Shoko.Server/API/SignalR/NLog/SignalRTarget.cs +++ b/Shoko.Server/API/SignalR/NLog/SignalRTarget.cs @@ -39,7 +39,6 @@ public SignalRTarget() ConnectMethodName = "GetBacklog"; MaxLogsCount = 10; Logs = new List(MaxLogsCount); - OptimizeBufferReuse = true; } protected override void Write(LogEventInfo logEvent) diff --git a/Shoko.Server/API/WebUI/WebUIHelper.cs b/Shoko.Server/API/WebUI/WebUIHelper.cs index f45e96358..2fdf1ce19 100644 --- a/Shoko.Server/API/WebUI/WebUIHelper.cs +++ b/Shoko.Server/API/WebUI/WebUIHelper.cs @@ -1,35 +1,56 @@ using System; using System.IO; using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; using Newtonsoft.Json; using SharpCompress.Common; using SharpCompress.Readers; using Shoko.Server.Utilities; +#nullable enable namespace Shoko.Server.API.WebUI; -public static class WebUIHelper +public static partial class WebUIHelper { + [GeneratedRegex(@"^[a-z0-9_\-\.]+/[a-z0-9_\-\.]+$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex CompiledRepoNameRegex(); + + private static string? _clientRepoName = null; + + public static string ClientRepoName => + _clientRepoName ??= Environment.GetEnvironmentVariable("SHOKO_CLIENT_REPO") is { } envVar && CompiledRepoNameRegex().IsMatch(envVar) ? envVar : "ShokoAnime/Shoko-WebUI"; + + private static string? _serverRepoName = null; + + public static string ServerRepoName => + _serverRepoName ??= Environment.GetEnvironmentVariable("SHOKO_SERVER_REPO") is { } envVar && CompiledRepoNameRegex().IsMatch(envVar) ? envVar : "ShokoAnime/ShokoServer"; + /// /// Web UI Version Info. /// - public record WebUIVersionInfo { + public record WebUIVersionInfo + { /// /// Package version. /// - public string package = "1.0.0"; + [JsonProperty("package")] + public string Package { get; set; } = "1.0.0"; /// /// Short-form git commit sha digest. /// - public string git = "0000000"; + [JsonProperty("git")] + public string Git { get; set; } = "0000000"; /// /// True if this is a debug package. /// - public bool debug = false; + [JsonProperty("debug")] + public bool Debug { get; set; } = false; /// /// Release date for web ui release. /// - public DateTime? date = null; + [JsonProperty("date")] + public DateTime? Date { get; set; } = null; } /// @@ -43,14 +64,15 @@ public static void GetUrlAndUpdate(string tagName) { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12; var release = DownloadApiResponse($"releases/tags/{tagName}"); - string url = null; + if (release is null) + return; + + string? url = null; foreach (var assets in release.assets) { // We don't care what the zip is named, only that it is attached. - // This is because we changed the signature from "latest.zip" to - // "Shoko-WebUI-{obj.tag_name}.zip" in the upgrade to web ui v2 string fileName = assets.name; - if (fileName == "latest.zip" || fileName == $"Shoko-WebUI-{release.tag_name}.zip") + if (Path.GetExtension(fileName) is ".zip") { url = assets.browser_download_url; break; @@ -76,7 +98,6 @@ private static void DownloadAndInstallUpdate(string url, DateTime releaseDate) { var webuiDir = Path.Combine(Utils.ApplicationPath, "webui"); var backupDir = Path.Combine(webuiDir, "old"); - var zipFile = Path.Combine(webuiDir, "update.zip"); var files = Directory.GetFiles(webuiDir); var directories = Directory.GetDirectories(webuiDir); @@ -85,11 +106,9 @@ private static void DownloadAndInstallUpdate(string url, DateTime releaseDate) Directory.CreateDirectory(webuiDir); // Download the zip file. - using (var client = new WebClient()) - { - client.Headers.Add("User-Agent", $"ShokoServer/{Utils.GetApplicationVersion()}"); - client.DownloadFile(url, zipFile); - } + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", $"ShokoServer/{Utils.GetApplicationVersion()}"); + var zipContent = client.GetByteArrayAsync(url).ConfigureAwait(false).GetAwaiter().GetResult(); // Remove any old lingering backups. if (Directory.Exists(backupDir)) @@ -110,32 +129,27 @@ private static void DownloadAndInstallUpdate(string url, DateTime releaseDate) // Also move all the files directly in the base directory into the backup directory until the update is complete. foreach (var file in files) { - if (file == zipFile || !File.Exists(file)) - continue; var newFile = file.Replace(webuiDir, backupDir); File.Move(file, newFile); } // Extract the zip contents into the folder. - using (var stream = new FileStream(zipFile, FileMode.Open)) - using (var reader = ReaderFactory.Open(stream)) + using var stream = new MemoryStream(zipContent); + using var reader = ReaderFactory.Open(stream); + while (reader.MoveToNextEntry()) { - while (reader.MoveToNextEntry()) + if (!reader.Entry.IsDirectory) { - if (!reader.Entry.IsDirectory) + reader.WriteEntryToDirectory(webuiDir, new ExtractionOptions { - reader.WriteEntryToDirectory(webuiDir, new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true - }); - } + ExtractFullPath = true, + Overwrite = true + }); } } // Clean up the now unneeded backup and zip file because we have an updated install. Directory.Delete(backupDir, true); - File.Delete(zipFile); // Add release date to json AddReleaseDate(releaseDate); @@ -147,21 +161,21 @@ private static void AddReleaseDate(DateTime releaseDate) if (webUIFileInfo.Exists) { // Load the web ui version info from disk. - var webuiVersion = Newtonsoft.Json.JsonConvert.DeserializeObject(System.IO.File.ReadAllText(webUIFileInfo.FullName)); + var webuiVersion = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(webUIFileInfo.FullName)); // Set the release data and save the info again if the date is not set. - if (!webuiVersion.date.HasValue) + if (webuiVersion is not null && !webuiVersion.Date.HasValue) { - webuiVersion.date = releaseDate; - System.IO.File.WriteAllText(webUIFileInfo.FullName, Newtonsoft.Json.JsonConvert.SerializeObject(webuiVersion)); + webuiVersion.Date = releaseDate; + File.WriteAllText(webUIFileInfo.FullName, JsonConvert.SerializeObject(webuiVersion)); } } } - public static WebUIVersionInfo LoadWebUIVersionInfo() + public static WebUIVersionInfo? LoadWebUIVersionInfo() { var webUIFileInfo = new FileInfo(Path.Combine(Utils.ApplicationPath, "webui/version.json")); if (webUIFileInfo.Exists) - return Newtonsoft.Json.JsonConvert.DeserializeObject(System.IO.File.ReadAllText(webUIFileInfo.FullName)); + return JsonConvert.DeserializeObject(File.ReadAllText(webUIFileInfo.FullName)); return null; } @@ -171,14 +185,14 @@ public static WebUIVersionInfo LoadWebUIVersionInfo() /// do version have to be stable /// An error occurred while downloading the resource. /// - public static string WebUIGetLatestVersion(bool stable) + public static string? WebUIGetLatestVersion(bool stable) { // The 'latest' release will always be a stable release, so we can skip // checking it if we're looking for a pre-release. if (!stable) return GetVersionTag(false); var release = DownloadApiResponse("releases/latest"); - return release.tag_name; + return release?.tag_name; } /// @@ -188,9 +202,12 @@ public static string WebUIGetLatestVersion(bool stable) /// do version have to be stable /// An error occurred while downloading the resource. /// - private static string GetVersionTag(bool stable) + private static string? GetVersionTag(bool stable) { var releases = DownloadApiResponse("releases"); + if (releases is null) + return null; + foreach (var release in releases) { // Filter out pre-releases from the stable release channel, but don't @@ -219,14 +236,14 @@ private static string GetVersionTag(bool stable) /// Repository name. /// /// An error occurred while downloading the resource. - internal static dynamic DownloadApiResponse(string endpoint, string repoName = null) + internal static dynamic DownloadApiResponse(string endpoint, string? repoName = null) { - if (string.IsNullOrEmpty(repoName)) - repoName = "shokoanime/shoko-webui"; - var client = new WebClient(); - client.Headers.Add("Accept: application/vnd.github.v3+json"); - client.Headers.Add("User-Agent", $"ShokoServer/{Utils.GetApplicationVersion()}"); - var response = client.DownloadString(new Uri($"https://api.github.com/repos/{repoName}/{endpoint}")); - return JsonConvert.DeserializeObject(response); + repoName ??= ClientRepoName; + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + client.DefaultRequestHeaders.Add("User-Agent", $"ShokoServer/{Utils.GetApplicationVersion()}"); + var response = client.GetStringAsync(new Uri($"https://api.github.com/repos/{repoName}/{endpoint}")) + .ConfigureAwait(false).GetAwaiter().GetResult(); + return JsonConvert.DeserializeObject(response)!; } } diff --git a/Shoko.Server/API/WebUI/WebUIThemeProvider.cs b/Shoko.Server/API/WebUI/WebUIThemeProvider.cs index 12b38ca91..9e4623a09 100644 --- a/Shoko.Server/API/WebUI/WebUIThemeProvider.cs +++ b/Shoko.Server/API/WebUI/WebUIThemeProvider.cs @@ -6,34 +6,41 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Newtonsoft.Json; +using Shoko.Commons.Extensions; +using Shoko.Server.Extensions; using Shoko.Server.Utilities; #nullable enable namespace Shoko.Server.API.WebUI; -public static class WebUIThemeProvider +public static partial class WebUIThemeProvider { - private static Regex VersionRegex = new Regex(@"^\s*(?\d+)(?:\.(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?)?\s*$", RegexOptions.ECMAScript | RegexOptions.Compiled); + [GeneratedRegex(@"^\s*(?\d+)(?:\.(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?)?\s*$", RegexOptions.ECMAScript | RegexOptions.Compiled)] + private static partial Regex VersionRegex(); - private static Regex FileNameRegex = new Regex(@"^\b[A-Za-z][A-Za-z0-9_\-]*\b$", RegexOptions.ECMAScript | RegexOptions.Compiled); + [GeneratedRegex(@"^\b[A-Za-z][A-Za-z0-9_\-]*\b$", RegexOptions.Compiled | RegexOptions.ECMAScript)] + private static partial Regex FileNameRegex(); - private static ISet AllowedMIMEs = new HashSet(StringComparer.InvariantCultureIgnoreCase) { "application/json", "text/json", "text/plain" }; + private static readonly ISet _allowedJsonMime = new HashSet(StringComparer.InvariantCultureIgnoreCase) { "application/json", "text/json", "text/plain" }; - private static DateTime? NextRefreshAfter = null; + private static readonly ISet _allowedCssMime = new HashSet(StringComparer.InvariantCultureIgnoreCase) { "text/css", "text/plain" }; - private static Dictionary? ThemeDict = null; + private static DateTime? _nextRefreshAfter = null; + + private static Dictionary? _themeDict = null; private static Dictionary RefreshThemes(bool forceRefresh = false) { - if (ThemeDict == null || forceRefresh || DateTime.UtcNow > NextRefreshAfter) + if (_themeDict == null || forceRefresh || DateTime.UtcNow > _nextRefreshAfter) { - NextRefreshAfter = DateTime.UtcNow.AddMinutes(10); - ThemeDict = ThemeDefinition.FromDirectory("themes").ToDictionary(theme => theme.ID); + _nextRefreshAfter = DateTime.UtcNow.AddMinutes(10); + _themeDict = ThemeDefinition.FromThemesDirectory().ToDictionary(theme => theme.ID); } - return ThemeDict; + return _themeDict; } /// @@ -47,7 +54,7 @@ public static IEnumerable GetThemes(bool forceRefresh = false) } /// - /// Get a spesified theme from the theme folder. + /// Get a specified theme from the theme folder. /// /// The id of the theme to get. /// Forcefully refresh the theme dict. before checking for the theme. @@ -64,12 +71,16 @@ public static IEnumerable GetThemes(bool forceRefresh = false) /// A boolean indicating the success status of the operation. public static bool RemoveTheme(ThemeDefinition theme) { - var filePath = Path.Combine(Utils.ApplicationPath, "themes", theme.FileName); - if (!File.Exists(filePath)) + var jsonFilePath = Path.Combine(Utils.ApplicationPath, "themes", theme.JsonFileName); + var cssFilePath = Path.Combine(Utils.ApplicationPath, "themes", theme.CssFileName); + if (!File.Exists(jsonFilePath) && !File.Exists(cssFilePath)) return false; - File.Delete(filePath); - ThemeDict?.Remove(theme.ID); + _themeDict?.Remove(theme.ID); + if (File.Exists(jsonFilePath)) + File.Delete(jsonFilePath); + if (File.Exists(cssFilePath)) + File.Delete(cssFilePath); return true; } @@ -80,16 +91,13 @@ public static bool RemoveTheme(ThemeDefinition theme) /// The theme to update. /// Flag indicating whether to enable preview mode. /// The updated theme metadata. - public static async Task UpdateTheme(ThemeDefinition theme, bool preview = false) + public static async Task UpdateThemeOnline(ThemeDefinition theme, bool preview = false) { // Return the local theme if we don't have an update url. - if (string.IsNullOrEmpty(theme.URL)) - if (preview) - throw new ValidationException("No update URL in existing theme definition."); - else - return theme; + if (string.IsNullOrEmpty(theme.UpdateUrl)) + return theme; - if (!(Uri.TryCreate(theme.URL, UriKind.Absolute, out var updateUrl) && (updateUrl.Scheme == Uri.UriSchemeHttp || updateUrl.Scheme == Uri.UriSchemeHttps))) + if (!(Uri.TryCreate(theme.UpdateUrl, UriKind.Absolute, out var updateUrl) && (updateUrl.Scheme == Uri.UriSchemeHttp || updateUrl.Scheme == Uri.UriSchemeHttps))) throw new ValidationException("Invalid update URL in existing theme definition."); using var httpClient = new HttpClient(); @@ -102,7 +110,7 @@ public static async Task UpdateTheme(ThemeDefinition theme, boo // Check if the response is using the correct content-type. var contentType = response.Content.Headers.ContentType?.MediaType; - if (string.IsNullOrEmpty(contentType) || !AllowedMIMEs.Contains(contentType)) + if (string.IsNullOrEmpty(contentType) || !_allowedJsonMime.Contains(contentType)) throw new HttpRequestException("Invalid content-type. Expected JSON."); // Simple sanity check before parsing the response content. @@ -112,23 +120,53 @@ public static async Task UpdateTheme(ThemeDefinition theme, boo throw new HttpRequestException("Invalid theme file format."); // Try to parse the updated theme. - var updatedTheme = ThemeDefinition.FromJson(content, theme.ID, theme.FileName, preview) ?? + var updatedTheme = ThemeDefinition.FromJson(content, theme.ID, preview) ?? throw new HttpRequestException("Failed to parse the updated theme."); - // Save the updated theme file if we're not pre-viewing. - if (!preview) + if (updatedTheme.Version <= theme.Version) + throw new ValidationException("New theme version is lower than the existing theme version."); + + if (!(Uri.TryCreate(updatedTheme.UpdateUrl, UriKind.Absolute, out updateUrl) && (updateUrl.Scheme == Uri.UriSchemeHttp || updateUrl.Scheme == Uri.UriSchemeHttps))) + throw new ValidationException("Invalid update URL in new theme definition."); + + if (!string.IsNullOrEmpty(updatedTheme.CssUrl)) { - var dirPath = Path.Combine(Utils.ApplicationPath, "themes"); - if (!Directory.Exists(dirPath)) - Directory.CreateDirectory(dirPath); + if (!Uri.TryCreate(updatedTheme.CssUrl, UriKind.RelativeOrAbsolute, out var cssUrl)) + throw new ValidationException("Invalid CSS URL in new theme definition."); - var filePath = Path.Combine(dirPath, theme.FileName); - await File.WriteAllTextAsync(filePath, content); + if (!cssUrl.IsAbsoluteUri) + { + if (updateUrl is null) + throw new ValidationException("Unable to resolve CSS URL in new theme definition because it is relative and no update URL was provided."); + cssUrl = new Uri(updateUrl, cssUrl); + updatedTheme.CssUrl = cssUrl.AbsoluteUri; + } + + if (cssUrl.Scheme != Uri.UriSchemeHttp && cssUrl.Scheme != Uri.UriSchemeHttps) + throw new ValidationException("Invalid CSS URL in existing theme definition."); + + if (!string.IsNullOrEmpty(theme.CssContent)) + throw new ValidationException("Theme already has CSS overrides inlined. Remove URL or inline CSS first before proceeding."); + + var cssResponse = await httpClient.GetAsync(theme.CssUrl); + if (cssResponse.StatusCode != HttpStatusCode.OK) + throw new HttpRequestException($"Failed to retrieve CSS file with status code {cssResponse.StatusCode}.", null, cssResponse.StatusCode); - if (ThemeDict != null && !ThemeDict.TryAdd(theme.ID, updatedTheme)) - ThemeDict[theme.ID] = updatedTheme; + var cssContentType = cssResponse.Content.Headers.ContentType?.MediaType; + if (string.IsNullOrEmpty(cssContentType) || !_allowedCssMime.Contains(cssContentType)) + throw new ValidationException("Invalid css content-type for resource. Expected \"text/css\" or \"text/plain\"."); + + var cssContent = (await cssResponse.Content.ReadAsStringAsync())?.Trim(); + if (string.IsNullOrEmpty(cssContent)) + throw new ValidationException("The css url cannot resolve to an empty resource if it is provided in the theme definition."); + + updatedTheme.CssContent = cssContent; } + // Save the updated theme file if we're not pre-viewing. + if (!preview) + await SaveTheme(updatedTheme); + return updatedTheme; } @@ -138,7 +176,7 @@ public static async Task UpdateTheme(ThemeDefinition theme, boo /// The URL leading to where the theme lives online. /// Flag indicating whether to enable preview mode. /// The new or updated theme metadata. - public static async Task InstallTheme(string url, bool preview = false) + public static async Task InstallThemeFromUrl(string url, bool preview = false) { if (!(Uri.TryCreate(url, UriKind.Absolute, out var updateUrl) && (updateUrl.Scheme == Uri.UriSchemeHttp || updateUrl.Scheme == Uri.UriSchemeHttps))) throw new ValidationException("Invalid repository URL."); @@ -155,11 +193,11 @@ public static async Task InstallTheme(string url, bool preview // We _want_ it to be JSON. if (!string.Equals(extName, ".json", StringComparison.InvariantCultureIgnoreCase)) - throw new ValidationException("Invalid theme file format. Expected JSON."); + throw new ValidationException("Invalid theme file name extension. Expected '.json'."); // Check if the file name conforms to our specified format. var fileName = Path.GetFileNameWithoutExtension(lastFragment); - if (string.IsNullOrEmpty(fileName) || !FileNameRegex.IsMatch(fileName)) + if (string.IsNullOrEmpty(fileName) || !FileNameRegex().IsMatch(fileName)) throw new ValidationException("Invalid theme file name."); using var httpClient = new HttpClient(); @@ -172,79 +210,183 @@ public static async Task InstallTheme(string url, bool preview // Check if the response is using the correct content-type. var contentType = response.Content.Headers.ContentType?.MediaType; - if (string.IsNullOrEmpty(contentType) || !AllowedMIMEs.Contains(contentType)) - throw new HttpRequestException("Invalid content-type. Expected JSON."); + if (string.IsNullOrEmpty(contentType) || !_allowedJsonMime.Contains(contentType)) + throw new HttpRequestException("Invalid content-type. Expected 'application/json', 'text/json', or 'text/plain."); // Simple sanity check before parsing the response content. var content = await response.Content.ReadAsStringAsync(); + return await InstallOrUpdateThemeFromJson(content, fileName, preview); + } + + public static async Task InstallOrUpdateThemeFromJson(string? content, string fileName, bool preview = false) + { + fileName = Path.GetFileNameWithoutExtension(fileName); + if (string.IsNullOrEmpty(fileName) || !FileNameRegex().IsMatch(fileName)) + throw new ValidationException("Invalid theme file name."); + content = content?.Trim(); if (string.IsNullOrWhiteSpace(content) || content[0] != '{' || content[^1] != '}') - throw new HttpRequestException("Invalid theme file format."); + throw new HttpRequestException("Pre-validation failed. Resource is not a valid JSON object."); // Try to parse the new theme. var id = FileNameToID(fileName); - var theme = ThemeDefinition.FromJson(content, id, fileName + extName, preview) ?? - throw new HttpRequestException("Failed to parse the new theme."); + var theme = ThemeDefinition.FromJson(content, id, preview) ?? + throw new HttpRequestException("Failed to parse the theme from resource."); - // Save the new theme file if we're not pre-viewing. - if (!preview) + Uri? updateUrl = null; + if (!string.IsNullOrEmpty(theme.UpdateUrl) && !(Uri.TryCreate(theme.UpdateUrl, UriKind.Absolute, out updateUrl) && (updateUrl.Scheme == Uri.UriSchemeHttp || updateUrl.Scheme == Uri.UriSchemeHttps))) + throw new ValidationException("Invalid update URL in new theme definition."); + + if (!string.IsNullOrEmpty(theme.CssUrl)) { - var dirPath = Path.Combine(Utils.ApplicationPath, "themes"); - if (!Directory.Exists(dirPath)) - Directory.CreateDirectory(dirPath); + if (!Uri.TryCreate(theme.CssUrl, UriKind.RelativeOrAbsolute, out var cssUrl)) + throw new ValidationException("Invalid CSS URL in new theme definition."); + + if (!cssUrl.IsAbsoluteUri) + { + if (updateUrl is null) + throw new ValidationException("Unable to resolve CSS URL in theme definition because it is relative and no update URL was provided."); + cssUrl = new Uri(updateUrl, cssUrl); + theme.CssUrl = cssUrl.AbsoluteUri; + } - var filePath = Path.Combine(dirPath, fileName + extName); - await File.WriteAllTextAsync(filePath, content); + if (cssUrl.Scheme != Uri.UriSchemeHttp && cssUrl.Scheme != Uri.UriSchemeHttps) + throw new ValidationException("Invalid CSS URL in theme definition."); - if (ThemeDict != null && !ThemeDict.TryAdd(id, theme)) - ThemeDict[id] = theme; + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromMinutes(1); + var cssResponse = await httpClient.GetAsync(theme.CssUrl); + if (cssResponse.StatusCode != HttpStatusCode.OK) + throw new HttpRequestException($"Failed to retrieve CSS file with status code {cssResponse.StatusCode}.", null, cssResponse.StatusCode); + + var cssContentType = cssResponse.Content.Headers.ContentType?.MediaType; + if (string.IsNullOrEmpty(cssContentType) || !_allowedCssMime.Contains(cssContentType)) + throw new ValidationException("Invalid css content-type for resource. Expected \"text/css\" or \"text/plain\"."); + + var cssContent = await cssResponse.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(cssContent)) + throw new ValidationException("The css url cannot resolve to an empty resource if it is provided in the theme definition."); + + theme.CssContent = cssContent; } + if (theme.Values.Count == 0 && string.IsNullOrWhiteSpace(theme.CssContent)) + throw new ValidationException("The theme definition cannot be empty."); + + // Save the new theme file if we're not pre-viewing. + if (!preview) + await SaveTheme(theme); + + return theme; + } + + public static async Task CreateOrUpdateThemeFromCss(string content, string fileName, bool preview = false) + { + if (string.IsNullOrEmpty(fileName) || !FileNameRegex().IsMatch(fileName)) + throw new ValidationException("Invalid theme file name."); + + if (string.IsNullOrWhiteSpace(content)) + throw new ValidationException("The theme definition cannot be empty."); + + var id = FileNameToID(fileName); + var theme = GetTheme(id, true) ?? new(id, preview); + if (!string.IsNullOrEmpty(theme.UpdateUrl)) + throw new ValidationException("Unable to manually update a theme with an update URL set."); + theme.CssContent = content; + + // Save the new theme file if we're not pre-viewing. + if (!preview) + await SaveTheme(theme); + return theme; } + private static async Task SaveTheme(ThemeDefinition theme) + { + var dirPath = Path.Combine(Utils.ApplicationPath, "themes"); + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + var cssContent = theme.CssContent; + var cssFilePath = Path.Combine(dirPath, theme.CssFileName); + if (!string.IsNullOrEmpty(cssContent)) + await File.WriteAllTextAsync(cssFilePath, cssContent); + else if (File.Exists(cssFilePath)) + File.Delete(cssFilePath); + + var jsonContent = theme.ToJson(); + var jsonFilePath = Path.Combine(dirPath, theme.JsonFileName); + if (!string.IsNullOrEmpty(jsonContent)) + await File.WriteAllTextAsync(jsonFilePath, theme.ToJson()); + else if (File.Exists(jsonFilePath)) + File.Delete(jsonFilePath); + + if (_themeDict != null) + _themeDict[theme.ID] = theme; + } + public class ThemeDefinitionInput { /// /// The display name of the theme. Will be inferred from the filename if omitted. /// - public string? Name = null; + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string? Name { get; set; } = null; /// /// The theme version. /// [Required] - [RegularExpression(@"^\s*(?\d+)(?:\.(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?)?\s*$")] - public string Version = string.Empty; + [MinLength(1)] + [RegularExpression(@"^(?\d+)(?:\.(?\d+)(?:\.(?\d+)(?:\.(?\d+))?)?)?$")] + [JsonProperty("version")] + public string Version { get; set; } = string.Empty; /// /// Optional description for the theme, if any. /// - public string? Description = null; + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + public string? Description { get; set; } = null; - /** - * Optional tags to make it easier to search for the theme. - */ - public IReadOnlyList? Tags; + /// + /// Optional tags to make it easier to search for the theme. + /// + [JsonProperty("tags", NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList? Tags { get; set; } = null; /// /// The author's name. /// [Required] - public string Author = string.Empty; + [JsonProperty("author")] + public string Author { get; set; } = string.Empty; /// /// The CSS variables defined in the theme. /// - [Required] - [MinLength(1)] - public IReadOnlyDictionary Values = new Dictionary(); + [JsonProperty("values", NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyDictionary? Values { get; set; } = null; + + /// + /// The CSS overrides defined in the theme, if any. + /// + [JsonProperty("css", NullValueHandling = NullValueHandling.Ignore)] + public string? CssContent { get; set; } + + /// + /// The URL for where the theme CSS overrides file lives. Will be downloaded locally if provided. It must end in ".css" and the content type must be "text/plain" or "text/css". + /// + [Url] + [JsonProperty("cssUrl", NullValueHandling = NullValueHandling.Ignore)] + public string? CssUrl { get; set; } /// /// The URL for where the theme definition lives. Used for updates. /// [Url] - public string? URL; + [JsonProperty("updateUrl", NullValueHandling = NullValueHandling.Ignore)] + public string? UpdateUrl { get; set; } + } public class ThemeDefinition @@ -258,9 +400,14 @@ public class ThemeDefinition public readonly string ID; /// - /// The file name assosiated with the theme. + /// The file name associated with the theme. + /// + public readonly string JsonFileName; + + /// + /// The name of the CSS file associated with the theme. /// - public readonly string FileName; + public string CssFileName => JsonFileName[..^Path.GetExtension(JsonFileName).Length] + ".css"; /// /// The display name of the theme. @@ -273,14 +420,14 @@ public class ThemeDefinition public readonly string? Description; /// - /// Author-defined tags assosiated with the theme. + /// Author-defined tags associated with the theme. /// public readonly IReadOnlyList Tags; /// /// The name of the author of the theme definition. /// - public readonly string Author; + public readonly string? Author; /// /// The theme version. @@ -296,107 +443,117 @@ public class ThemeDefinition [JsonIgnore] public readonly IReadOnlyDictionary Values; + /// + /// The CSS content to define for the theme. + /// + [JsonIgnore] + public string? CssContent { get; internal set; } = string.Empty; + + /// + /// The URL for where the theme CSS overrides file lives. Will be downloaded locally if provided. It must end in ".css" and the content type must be "text/plain" or "text/css". + /// + public string? CssUrl { get; internal set; } + /// /// The URL for where the theme definition lives. Used for updates. /// - public readonly string? URL; + public readonly string? UpdateUrl; /// /// Indicates this is only a preview of the theme metadata and the theme - /// might not actaully be installed yet. + /// might not actually be installed yet. /// public readonly bool IsPreview; + private bool? _isInstalled; + /// /// Indicates the theme is installed locally. /// - public readonly bool IsInstalled; + public bool IsInstalled => _isInstalled ??= File.Exists(Path.Combine(Utils.ApplicationPath, "themes", JsonFileName)) || File.Exists(Path.Combine(Utils.ApplicationPath, "themes", CssFileName)); + + public ThemeDefinition(string id, bool preview = false) + { + ID = id; + JsonFileName = $"{id}.json"; + Name = NameFromID(ID); + Tags = []; + Version = new Version(1, 0, 0, 0); + Values = new Dictionary(); + IsPreview = preview; + } - public ThemeDefinition(ThemeDefinitionInput input, string id, string fileName, bool preview = false) + public ThemeDefinition(ThemeDefinitionInput input, string id, bool preview = false) { // We use a regex match and parse the result instead of using the built-in version parer // directly because the built-in parser is more rigged then what we want to support. - var versionMatch = VersionRegex.Match(input.Version); + var versionMatch = VersionRegex().Match(input.Version); var major = int.Parse(versionMatch.Groups["major"].Value); var minor = versionMatch.Groups["minor"].Success ? int.Parse(versionMatch.Groups["minor"].Value) : 0; var build = versionMatch.Groups["build"].Success ? int.Parse(versionMatch.Groups["build"].Value) : 0; var revision = versionMatch.Groups["revision"].Success ? int.Parse(versionMatch.Groups["build"].Value) : 0; - if (string.IsNullOrEmpty(fileName)) - fileName = $"{id}.json"; ID = id; - FileName = fileName; + JsonFileName = $"{id}.json"; Name = string.IsNullOrEmpty(input.Name) ? NameFromID(ID) : input.Name; Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description; - Tags = input.Tags ?? new List(); + Tags = input.Tags ?? []; Author = input.Author; Version = new Version(major, minor, build, revision); Values = input.Values ?? new Dictionary(); - URL = input.URL; + CssUrl = input.CssUrl; + CssContent = input.CssContent; + UpdateUrl = input.UpdateUrl; IsPreview = preview; - IsInstalled = File.Exists(Path.Combine(Utils.ApplicationPath, "themes", fileName)); } public string ToCSS() - => $".theme-{ID} {{{string.Join(" ", Values.Select(pair => $" --{pair.Key}: {pair.Value};"))} }}"; - - internal static IReadOnlyList FromDirectory(string dirPath) { - // Resolve path relative to the application directory and check if - // it exists. - dirPath = Path.Combine(Utils.ApplicationPath, dirPath); - if (!Directory.Exists(dirPath)) - return new List(); - - return Directory.GetFiles(dirPath) - .Select(filePath => FromPath(filePath)) - .OfType() - .DistinctBy(theme => theme.ID) - .OrderBy(theme => theme.ID) - .ToList(); + var cssFile = Path.Combine(Utils.ApplicationPath, "themes", CssFileName); + var css = new StringBuilder() + .Append('\n') + .Append($".theme-{ID} {{\n"); + if (Values.Count > 0) + css.Append(" " + Values.Select(pair => $" --{pair.Key}: {pair.Value};").Join("\n ") + "\n"); + + if (Values.Count > 0 && !string.IsNullOrWhiteSpace(CssContent)) + css.Append('\n'); + + if (!string.IsNullOrWhiteSpace(CssContent)) + css + .Append(" " + CssContent.Split(["\r\n", "\r", "\n"], StringSplitOptions.None).Select(line => string.IsNullOrWhiteSpace(line) ? string.Empty : $" {line.TrimEnd()}").Join("\n ") + "\n"); + + return css + .AppendLine("}\n") + .ToString(); } - internal static ThemeDefinition? FromPath(string filePath) - { - // Check file extension. - var extName = Path.GetExtension(filePath); - if (string.IsNullOrEmpty(extName) || !string.Equals(extName, ".json", StringComparison.InvariantCultureIgnoreCase)) - return null; - - var fileName = Path.GetFileNameWithoutExtension(filePath); - if (string.IsNullOrEmpty(fileName) || !FileNameRegex.IsMatch(fileName)) - return null; - - if (!File.Exists(filePath)) - return null; - - // Safely try to read - string? fileContents; - try + public string? ToJson() => !string.IsNullOrEmpty(Author) + ? JsonConvert.SerializeObject(new ThemeDefinitionInput() { - fileContents = File.ReadAllText(filePath)?.Trim(); - } - catch - { - return null; - } - // Simple sanity check before parsing the file contents. - if (string.IsNullOrWhiteSpace(fileContents) || fileContents[0] != '{' || fileContents[^1] != '}') - return null; - - var id = FileNameToID(fileName); - return FromJson(fileContents, id, Path.GetFileName(filePath)); - } - - internal static ThemeDefinition? FromJson(string json, string id, string fileName, bool preview = false) + Name = Name, + Version = Version.ToString(), + Description = Description, + Tags = Tags.Count is > 0 ? Tags : null, + Author = Author, + Values = Values.Count is > 0 ? Values : null, + CssUrl = string.IsNullOrEmpty(CssUrl) ? null : CssUrl, + UpdateUrl = string.IsNullOrEmpty(UpdateUrl) ? null : UpdateUrl, + }) + : null; + + internal static ThemeDefinition? FromJson(string? json, string id, bool preview = false) { try { + // Simple sanity check before parsing the file contents. + if (string.IsNullOrWhiteSpace(json) || json[0] != '{' || json[^1] != '}') + return null; var input = JsonConvert.DeserializeObject(json); if (input == null) return null; - var theme = new ThemeDefinition(input, id, fileName, preview); + var theme = new ThemeDefinition(input, id, preview); return theme; } catch @@ -404,6 +561,40 @@ internal static IReadOnlyList FromDirectory(string dirPath) return null; } } + + internal static IReadOnlyList FromThemesDirectory() + { + var dirPath = Path.Combine(Utils.ApplicationPath, "themes"); + if (!Directory.Exists(dirPath)) + return new List(); + + var allowedExtensions = new HashSet() { ".json", ".css" }; + return Directory.GetFiles(dirPath) + .GroupBy(a => Path.GetFileNameWithoutExtension(a)) + .Where(a => !string.IsNullOrEmpty(a.Key) && FileNameRegex().IsMatch(a.Key) && a.Any(b => allowedExtensions.Contains(Path.GetExtension(b)))) + .Select(FromPath) + .WhereNotNull() + .DistinctBy(theme => theme.ID) + .OrderBy(theme => theme.ID) + .ToList(); + } + + private static ThemeDefinition? FromPath(IGrouping fileDetails) + { + // Check file extension. + var id = FileNameToID(fileDetails.Key); + var jsonFile = fileDetails.FirstOrDefault(a => string.Equals(Path.GetExtension(a), ".json", StringComparison.InvariantCultureIgnoreCase)); + var theme = string.IsNullOrEmpty(jsonFile) ? new(id) : FromJson(File.ReadAllText(jsonFile)?.Trim(), id); + if (theme is not null) + { + var cssFileName = fileDetails.FirstOrDefault(a => string.Equals(Path.GetExtension(a), ".css", StringComparison.InvariantCultureIgnoreCase)) ?? + Path.Combine(Path.GetDirectoryName(jsonFile)!, theme.CssFileName); + if (File.Exists(cssFileName)) + theme.CssContent = File.ReadAllText(cssFileName); + } + + return theme; + } } private static string FileNameToID(string fileName) @@ -419,5 +610,5 @@ private static string NameFromID(string id) ); public static string ToCSS(this IEnumerable list) - => string.Join(" ", list.Select(theme => theme.ToCSS())); + => list.Select(theme => theme.ToCSS()).Join(""); } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs index 518f9165c..84e90504a 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs @@ -1,4 +1,4 @@ - using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -18,9 +18,8 @@ using Shoko.Server.Filters.Legacy; using Shoko.Server.Plex; using Shoko.Server.Providers.AniDB.Interfaces; -using Shoko.Server.Providers.MovieDB; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Providers.TraktTV; -using Shoko.Server.Providers.TvDB; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Actions; @@ -42,29 +41,43 @@ public partial class ShokoServiceImplementation : Controller, IShokoServer private readonly ILogger _logger; private readonly AnimeGroupCreator _groupCreator; private readonly JobFactory _jobFactory; - private readonly TvDBApiHelper _tvdbHelper; private readonly TraktTVHelper _traktHelper; - private readonly MovieDBHelper _movieDBHelper; + private readonly TmdbLinkingService _tmdbLinkingService; + private readonly TmdbMetadataService _tmdbMetadataService; + private readonly TmdbSearchService _tmdbSearchService; private readonly ISettingsProvider _settingsProvider; private readonly ISchedulerFactory _schedulerFactory; private readonly ActionService _actionService; - private readonly AnimeSeriesService _seriesService; private readonly AnimeEpisodeService _episodeService; private readonly VideoLocalService _videoLocalService; private readonly WatchedStatusService _watchedService; - - public ShokoServiceImplementation(TvDBApiHelper tvdbHelper, TraktTVHelper traktHelper, MovieDBHelper movieDBHelper, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, ILogger logger, ActionService actionService, AnimeGroupCreator groupCreator, JobFactory jobFactory, AnimeSeriesService seriesService, AnimeEpisodeService episodeService, WatchedStatusService watchedService, VideoLocalService videoLocalService) + + public ShokoServiceImplementation( + TraktTVHelper traktHelper, + TmdbLinkingService tmdbLinkingService, + TmdbMetadataService tmdbMetadataService, + TmdbSearchService tmdbSearchService, + ISchedulerFactory schedulerFactory, + ISettingsProvider settingsProvider, + ILogger logger, + ActionService actionService, + AnimeGroupCreator groupCreator, + JobFactory jobFactory, + AnimeEpisodeService episodeService, + WatchedStatusService watchedService, + VideoLocalService videoLocalService + ) { - _tvdbHelper = tvdbHelper; _traktHelper = traktHelper; - _movieDBHelper = movieDBHelper; + _tmdbLinkingService = tmdbLinkingService; + _tmdbMetadataService = tmdbMetadataService; + _tmdbSearchService = tmdbSearchService; _schedulerFactory = schedulerFactory; _settingsProvider = settingsProvider; _logger = logger; _actionService = actionService; _groupCreator = groupCreator; _jobFactory = jobFactory; - _seriesService = seriesService; _episodeService = episodeService; _watchedService = watchedService; _videoLocalService = videoLocalService; @@ -131,7 +144,7 @@ public CL_MainChanges GetAllChanges(DateTime date, int userID) changes[0].LastChange = changes[1].LastChange; } - var groupService = Utils.ServiceContainer.GetRequiredService(); + var groupService = Utils.ServiceContainer.GetRequiredService(); c.Groups.ChangedItems = changes[0] .ChangedItems.Select(a => RepoFactory.AnimeGroup.GetByID(a)) .Where(a => a != null) @@ -365,7 +378,7 @@ public CL_Response SaveServerSettings(CL_ServerSettings contractIn) contract.ErrorMessage += "AniDB Password must have a value" + Environment.NewLine; } } - + if (!ushort.TryParse(contractIn.AniDB_AVDumpClientPort, out var newAniDB_AVDumpClientPort)) { contract.ErrorMessage += "AniDB AVDump port must be a valid port" + Environment.NewLine; @@ -386,7 +399,6 @@ public CL_Response SaveServerSettings(CL_ServerSettings contractIn) settings.AniDb.DownloadRelatedAnime = contractIn.AniDB_DownloadRelatedAnime; settings.AniDb.DownloadReleaseGroups = contractIn.AniDB_DownloadReleaseGroups; settings.AniDb.DownloadReviews = contractIn.AniDB_DownloadReviews; - settings.AniDb.DownloadSimilarAnime = contractIn.AniDB_DownloadSimilarAnime; settings.AniDb.MyList_AddFiles = contractIn.AniDB_MyList_AddFiles; settings.AniDb.MyList_ReadUnwatched = contractIn.AniDB_MyList_ReadUnwatched; @@ -403,39 +415,17 @@ public CL_Response SaveServerSettings(CL_ServerSettings contractIn) (ScheduledUpdateFrequency)contractIn.AniDB_Calendar_UpdateFrequency; settings.AniDb.Anime_UpdateFrequency = (ScheduledUpdateFrequency)contractIn.AniDB_Anime_UpdateFrequency; - settings.AniDb.MyListStats_UpdateFrequency = - (ScheduledUpdateFrequency)contractIn.AniDB_MyListStats_UpdateFrequency; settings.AniDb.File_UpdateFrequency = (ScheduledUpdateFrequency)contractIn.AniDB_File_UpdateFrequency; settings.AniDb.DownloadCharacters = contractIn.AniDB_DownloadCharacters; settings.AniDb.DownloadCreators = contractIn.AniDB_DownloadCreators; - // Web Cache - settings.WebCache.Address = contractIn.WebCache_Address; - settings.WebCache.XRefFileEpisode_Get = contractIn.WebCache_XRefFileEpisode_Get; - settings.WebCache.XRefFileEpisode_Send = contractIn.WebCache_XRefFileEpisode_Send; - settings.WebCache.TvDB_Get = contractIn.WebCache_TvDB_Get; - settings.WebCache.TvDB_Send = contractIn.WebCache_TvDB_Send; - settings.WebCache.Trakt_Get = contractIn.WebCache_Trakt_Get; - settings.WebCache.Trakt_Send = contractIn.WebCache_Trakt_Send; - - // TvDB - settings.TvDB.AutoLink = contractIn.TvDB_AutoLink; - settings.TvDB.AutoFanart = contractIn.TvDB_AutoFanart; - settings.TvDB.AutoFanartAmount = contractIn.TvDB_AutoFanartAmount; - settings.TvDB.AutoPosters = contractIn.TvDB_AutoPosters; - settings.TvDB.AutoPostersAmount = contractIn.TvDB_AutoPostersAmount; - settings.TvDB.AutoWideBanners = contractIn.TvDB_AutoWideBanners; - settings.TvDB.AutoWideBannersAmount = contractIn.TvDB_AutoWideBannersAmount; - settings.TvDB.UpdateFrequency = (ScheduledUpdateFrequency)contractIn.TvDB_UpdateFrequency; - settings.TvDB.Language = contractIn.TvDB_Language; - - // MovieDB - settings.MovieDb.AutoFanart = contractIn.MovieDB_AutoFanart; - settings.MovieDb.AutoFanartAmount = contractIn.MovieDB_AutoFanartAmount; - settings.MovieDb.AutoPosters = contractIn.MovieDB_AutoPosters; - settings.MovieDb.AutoPostersAmount = contractIn.MovieDB_AutoPostersAmount; + // TMDB + settings.TMDB.AutoDownloadBackdrops = contractIn.MovieDB_AutoFanart; + settings.TMDB.MaxAutoBackdrops = contractIn.MovieDB_AutoFanartAmount; + settings.TMDB.AutoDownloadPosters = contractIn.MovieDB_AutoPosters; + settings.TMDB.MaxAutoPosters = contractIn.MovieDB_AutoPostersAmount; // Import settings settings.Import.VideoExtensions = contractIn.VideoExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); @@ -453,19 +443,19 @@ public CL_Response SaveServerSettings(CL_ServerSettings contractIn) settings.Import.RunOnStart = contractIn.RunImportOnStart; settings.Import.ScanDropFoldersOnStart = contractIn.ScanDropFoldersOnStart; - settings.Import.Hash_CRC32 = contractIn.Hash_CRC32; - settings.Import.Hash_MD5 = contractIn.Hash_MD5; - settings.Import.Hash_SHA1 = contractIn.Hash_SHA1; - settings.Import.RenameOnImport = contractIn.Import_RenameOnImport; - settings.Import.MoveOnImport = contractIn.Import_MoveOnImport; + settings.Import.Hasher.CRC = contractIn.Hash_CRC32; + settings.Import.Hasher.MD5 = contractIn.Hash_MD5; + settings.Import.Hasher.SHA1 = contractIn.Hash_SHA1; + settings.Plugins.Renamer.RenameOnImport = contractIn.Import_RenameOnImport; + settings.Plugins.Renamer.MoveOnImport = contractIn.Import_MoveOnImport; settings.Import.SkipDiskSpaceChecks = contractIn.SkipDiskSpaceChecks; // Language - settings.LanguagePreference = contractIn.LanguagePreference.Split(',').ToList(); - settings.LanguageUseSynonyms = contractIn.LanguageUseSynonyms; - settings.EpisodeTitleSource = (DataSourceType)contractIn.EpisodeTitleSource; - settings.SeriesDescriptionSource = (DataSourceType)contractIn.SeriesDescriptionSource; - settings.SeriesNameSource = (DataSourceType)contractIn.SeriesNameSource; + settings.Language.SeriesTitleLanguageOrder = contractIn.LanguagePreference.Split(',').ToList(); + settings.Language.UseSynonyms = contractIn.LanguageUseSynonyms; + settings.Language.EpisodeTitleSourceOrder = [(DataSourceType)contractIn.EpisodeTitleSource]; + settings.Language.DescriptionSourceOrder = [(DataSourceType)contractIn.SeriesDescriptionSource]; + settings.Language.SeriesTitleSourceOrder = [(DataSourceType)contractIn.SeriesNameSource]; // Trakt settings.TraktTv.Enabled = contractIn.Trakt_IsEnabled; @@ -755,86 +745,10 @@ public string EnableDisableImage(bool enabled, int imageID, int imageType) { try { - var imgType = (ImageEntityType)imageType; - int animeID = 0; - - switch (imgType) - { - case ImageEntityType.AniDB_Cover: - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(imageID); - if (anime == null) - { - return "Could not find anime"; - } - - anime.ImageEnabled = enabled ? 1 : 0; - RepoFactory.AniDB_Anime.Save(anime); - break; - - case ImageEntityType.TvDB_Banner: - var banner = RepoFactory.TvDB_ImageWideBanner.GetByID(imageID); - if (banner == null) - { - return "Could not find image"; - } - - banner.Enabled = enabled ? 1 : 0; - RepoFactory.TvDB_ImageWideBanner.Save(banner); - animeID = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(banner.SeriesID).FirstOrDefault()?.AniDBID ?? 0; - break; - - case ImageEntityType.TvDB_Cover: - var poster = RepoFactory.TvDB_ImagePoster.GetByID(imageID); - if (poster == null) - { - return "Could not find image"; - } - - poster.Enabled = enabled ? 1 : 0; - RepoFactory.TvDB_ImagePoster.Save(poster); - animeID = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(poster.SeriesID).FirstOrDefault()?.AniDBID ?? 0; - break; - - case ImageEntityType.TvDB_FanArt: - var fanart = RepoFactory.TvDB_ImageFanart.GetByID(imageID); - if (fanart == null) - { - return "Could not find image"; - } - - fanart.Enabled = enabled ? 1 : 0; - RepoFactory.TvDB_ImageFanart.Save(fanart); - animeID = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(fanart.SeriesID).FirstOrDefault()?.AniDBID ?? 0; - break; - - case ImageEntityType.MovieDB_Poster: - var moviePoster = RepoFactory.MovieDB_Poster.GetByID(imageID); - if (moviePoster == null) - { - return "Could not find image"; - } - - moviePoster.Enabled = enabled ? 1 : 0; - RepoFactory.MovieDB_Poster.Save(moviePoster); - animeID = RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(moviePoster.MovieId, CrossRefType.MovieDB)?.AnimeID ?? 0; - break; + var it = (CL_ImageEntityType)imageType; + if (!ImageUtils.SetEnabled(it.ToServerSource(), it.ToServerType(), imageID, enabled)) + return "Could not find image"; - case ImageEntityType.MovieDB_FanArt: - var movieFanart = RepoFactory.MovieDB_Fanart.GetByID(imageID); - if (movieFanart == null) - { - return "Could not find image"; - } - - movieFanart.Enabled = enabled ? 1 : 0; - RepoFactory.MovieDB_Fanart.Save(movieFanart); - animeID = RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(movieFanart.MovieId, CrossRefType.MovieDB)?.AnimeID ?? 0; - break; - } - - var schedulerFactory = Utils.ServiceContainer.GetRequiredService(); - var scheduler = schedulerFactory.GetScheduler().GetAwaiter().GetResult(); - if (animeID != 0) scheduler.StartJob(a => a.AnimeID = animeID).GetAwaiter().GetResult(); return string.Empty; } catch (Exception ex) @@ -849,59 +763,30 @@ public string SetDefaultImage(bool isDefault, int animeID, int imageID, int imag { try { - var imgType = (ImageEntityType)imageType; - var sizeType = ImageSizeType.Poster; - - switch (imgType) - { - case ImageEntityType.AniDB_Cover: - case ImageEntityType.TvDB_Cover: - case ImageEntityType.MovieDB_Poster: - sizeType = ImageSizeType.Poster; - break; - - case ImageEntityType.TvDB_Banner: - sizeType = ImageSizeType.WideBanner; - break; - - case ImageEntityType.TvDB_FanArt: - case ImageEntityType.MovieDB_FanArt: - sizeType = ImageSizeType.Fanart; - break; - } + var imageEntityType = ((CL_ImageEntityType)imageType).ToServerType(); + var dataSource = ((CL_ImageEntityType)imageType).ToServerSource(); + // Reset the image preference. if (!isDefault) { - // this mean we are removing an image as default - // which essential means deleting the record - - var img = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(animeID, sizeType); - if (img != null) - { - RepoFactory.AniDB_Anime_DefaultImage.Delete(img.AniDB_Anime_DefaultImageID); - } + var defaultImage = RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(animeID, imageEntityType); + if (defaultImage != null) + RepoFactory.AniDB_Anime_PreferredImage.Delete(defaultImage); } + // Mark the image as the preferred/default for it's type. else { - // making the image the default for it's type (poster, fanart, etc) - var img = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(animeID, sizeType); - if (img == null) - { - img = new AniDB_Anime_DefaultImage(); - } - - img.AnimeID = animeID; - img.ImageParentID = imageID; - img.ImageParentType = (int)imgType; - img.ImageType = (int)sizeType; - RepoFactory.AniDB_Anime_DefaultImage.Save(img); + var defaultImage = RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(animeID, imageEntityType) ?? new(animeID, imageEntityType); + defaultImage.ImageID = imageID; + defaultImage.ImageSource = dataSource; + RepoFactory.AniDB_Anime_PreferredImage.Save(defaultImage); } - var schedulerFactory = Utils.ServiceContainer.GetRequiredService(); - var scheduler = schedulerFactory.GetScheduler().GetAwaiter().GetResult(); - if (animeID != 0) scheduler.StartJob(a => a.AnimeID = animeID).GetAwaiter().GetResult(); + if (animeID != 0) + { + var scheduler = _schedulerFactory.GetScheduler().ConfigureAwait(false).GetAwaiter().GetResult(); + scheduler.StartJob(a => a.AnimeID = animeID).GetAwaiter().GetResult(); + } return string.Empty; } @@ -1153,7 +1038,8 @@ public List GetRecommendations(int maxResults, int userID, in var rec = new CL_Recommendation { - BasedOnAnimeID = anime.AnimeID, RecommendedAnimeID = link.SimilarAnimeID + BasedOnAnimeID = anime.AnimeID, + RecommendedAnimeID = link.SimilarAnimeID, }; // if we don't have the anime locally. lets assume the anime has a high rating @@ -1171,9 +1057,9 @@ public List GetRecommendations(int maxResults, int userID, in // check if we have added this recommendation before // this might happen where animes are recommended based on different votes // and could end up with different scores - if (dictRecs.ContainsKey(rec.RecommendedAnimeID)) + if (dictRecs.TryGetValue(rec.RecommendedAnimeID, out var recommendation)) { - if (rec.Score < dictRecs[rec.RecommendedAnimeID].Score) + if (rec.Score < recommendation.Score) { continue; } @@ -1386,17 +1272,17 @@ public List GetCharactersForSeiyuu(int seiyuuID) try { - var seiyuu = RepoFactory.AniDB_Seiyuu.GetByID(seiyuuID); + var seiyuu = RepoFactory.AniDB_Creator.GetByID(seiyuuID); if (seiyuu == null) { return chars; } - var links = RepoFactory.AniDB_Character_Seiyuu.GetBySeiyuuID(seiyuu.SeiyuuID); + var links = RepoFactory.AniDB_Character_Creator.GetByCreatorID(seiyuu.CreatorID); foreach (var chrSei in links) { - var chr = RepoFactory.AniDB_Character.GetByID(chrSei.CharID); + var chr = RepoFactory.AniDB_Character.GetByID(chrSei.CharacterID); if (chr != null) { var aniChars = @@ -1422,11 +1308,11 @@ public List GetCharactersForSeiyuu(int seiyuuID) } [HttpGet("AniDB/Seiyuu/{seiyuuID}")] - public AniDB_Seiyuu GetAniDBSeiyuu(int seiyuuID) + public CL_AniDB_Seiyuu GetAniDBSeiyuu(int seiyuuID) { try { - return RepoFactory.AniDB_Seiyuu.GetByID(seiyuuID); + return RepoFactory.AniDB_Creator.GetByCreatorID(seiyuuID)?.ToClient(); } catch (Exception ex) { diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs index cd8be591a..1044dcd19 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs @@ -30,7 +30,7 @@ public partial class ShokoServiceImplementation : IShokoServer #region Episodes and Files /// - /// Finds the previous episode for use int the next unwatched episode + /// Finds the previous episode for use int the next unwatched episode. /// /// /// @@ -41,34 +41,24 @@ public CL_AnimeEpisode_User GetPreviousEpisodeForUnwatched(int animeSeriesID, in try { var nextEp = GetNextUnwatchedEpisode(animeSeriesID, userID); - if (nextEp == null) - { + if (nextEp is null) return null; - } var epType = nextEp.EpisodeType; var epNum = nextEp.EpisodeNumber - 1; if (epNum <= 0) - { return null; - } var series = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (series == null) - { + if (series is null) return null; - } - var anieps = RepoFactory.AniDB_Episode.GetByAnimeIDAndEpisodeTypeNumber(series.AniDB_ID, - (EpisodeType)epType, - epNum); - if (anieps.Count == 0) - { + var anidbEpisodes = RepoFactory.AniDB_Episode.GetByAnimeIDAndEpisodeTypeNumber(series.AniDB_ID, (EpisodeType)epType, epNum); + if (anidbEpisodes.Count == 0) return null; - } - var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(anieps[0].EpisodeID); + var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(anidbEpisodes[0].EpisodeID); return _episodeService.GetV1Contract(ep, userID); } catch (Exception ex) @@ -85,16 +75,12 @@ public CL_AnimeEpisode_User GetNextUnwatchedEpisode(int animeSeriesID, int userI { var seriesService = Utils.ServiceContainer.GetService(); var series = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (series == null) - { + if (series is null) return null; - } - var episode = seriesService.GetNextEpisode(series, userID); - if (episode == null) - { + var episode = seriesService.GetNextUpEpisode(series, userID, new()); + if (episode is null) return null; - } return _episodeService.GetV1Contract(episode, userID); } @@ -112,15 +98,14 @@ public List GetAllUnwatchedEpisodes(int animeSeriesID, int try { - return - RepoFactory.AnimeEpisode.GetBySeriesID(animeSeriesID) - .Where(a => a != null && !a.IsHidden) - .Select(a => _episodeService.GetV1Contract(a, userID)) - .Where(a => a != null) - .Where(a => a.WatchedCount == 0) - .OrderBy(a => a.EpisodeType) - .ThenBy(a => a.EpisodeNumber) - .ToList(); + return RepoFactory.AnimeEpisode.GetBySeriesID(animeSeriesID) + .Where(a => a != null && !a.IsHidden) + .Select(a => _episodeService.GetV1Contract(a, userID)) + .Where(a => a != null) + .Where(a => a.WatchedCount == 0) + .OrderBy(a => a.EpisodeType) + .ThenBy(a => a.EpisodeNumber) + .ToList(); } catch (Exception ex) { @@ -134,13 +119,13 @@ public CL_AnimeEpisode_User GetNextUnwatchedEpisodeForGroup(int animeGroupID, in { try { - var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID); - if (grp == null) + var group = RepoFactory.AnimeGroup.GetByID(animeGroupID); + if (group is null) { return null; } - var allSeries = grp.AllSeries.OrderBy(a => a.AirDate).ToList(); + var allSeries = group.AllSeries.OrderBy(a => a.AirDate).ToList(); foreach (var ser in allSeries) @@ -168,25 +153,23 @@ public List GetContinueWatchingFilter(int userID, int maxR try { var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) return retEps; + if (user is null) return retEps; // find the locked Continue Watching Filter var lockedGFs = RepoFactory.FilterPreset.GetLockedGroupFilters(); var gf = lockedGFs?.FirstOrDefault(a => a.Name == "Continue Watching"); - if (gf == null) return retEps; + if (gf is null) return retEps; var evaluator = HttpContext.RequestServices.GetRequiredService(); - var groupService = HttpContext.RequestServices.GetRequiredService(); + var groupService = HttpContext.RequestServices.GetRequiredService(); var comboGroups = evaluator.EvaluateFilter(gf, userID).Select(a => RepoFactory.AnimeGroup.GetByID(a.Key)).Where(a => a != null) .Select(a => groupService.GetV1Contract(a, userID)); - foreach (var grp in comboGroups) + foreach (var group in comboGroups) { - var sers = RepoFactory.AnimeSeries.GetByGroupID(grp.AnimeGroupID).OrderBy(a => a.AirDate).ToList(); - + var seriesList = RepoFactory.AnimeSeries.GetByGroupID(group.AnimeGroupID).OrderBy(a => a.AirDate).ToList(); var seriesWatching = new List(); - - foreach (var ser in sers) + foreach (var ser in seriesList) { if (!user.AllowedSeries(ser)) continue; @@ -196,7 +179,7 @@ public List GetContinueWatchingFilter(int userID, int maxR if (!useSeries) continue; var ep = GetNextUnwatchedEpisode(ser.AnimeSeriesID, userID); - if (ep == null) continue; + if (ep is null) continue; retEps.Add(ep); @@ -229,7 +212,7 @@ public List GetEpisodesToWatch_RecentlyWatched(int maxReco var start = DateTime.Now; var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return retEps; } @@ -244,7 +227,7 @@ public List GetEpisodesToWatch_RecentlyWatched(int maxReco foreach (var userRecord in allSeriesUser) { var series = RepoFactory.AnimeSeries.GetByID(userRecord.AnimeSeriesID); - if (series == null) + if (series is null) { continue; } @@ -306,13 +289,13 @@ public List GetEpisodesRecentlyAdded(int maxRecords, int u try { var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return retEps; } // We will deal with a large list, don't perform ops on the whole thing! - var vids = RepoFactory.VideoLocal.GetMostRecentlyAdded(maxRecords*5, userID); + var vids = RepoFactory.VideoLocal.GetMostRecentlyAdded(maxRecords * 5, userID); foreach (var vid in vids) { if (string.IsNullOrEmpty(vid.Hash)) continue; @@ -344,7 +327,7 @@ public List GetEpisodesRecentlyAddedSummary(int maxRecords try { var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return retEps; } @@ -362,7 +345,7 @@ public List GetEpisodesRecentlyAddedSummary(int maxRecords foreach (var res in results) { var ser = RepoFactory.AnimeSeries.GetByID(res); - if (ser == null) + if (ser is null) { continue; } @@ -417,7 +400,7 @@ public List GetSeriesRecentlyAdded(int maxRecords, int user try { var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return retSeries; } @@ -434,13 +417,12 @@ public List GetSeriesRecentlyAdded(int maxRecords, int user return retSeries; } - [HttpGet("Episode/LastWatched/{animeSeriesID}/{jmmuserID}")] - public CL_AnimeEpisode_User GetLastWatchedEpisodeForSeries(int animeSeriesID, int jmmuserID) + [HttpGet("Episode/LastWatched/{animeSeriesID}/{userID}")] + public CL_AnimeEpisode_User GetLastWatchedEpisodeForSeries(int animeSeriesID, int userID) { try { - return _episodeService.GetV1Contract(RepoFactory.AnimeEpisode_User.GetLastWatchedEpisodeForSeries(animeSeriesID, jmmuserID) - ?.GetAnimeEpisode(),jmmuserID); + return _episodeService.GetV1Contract(RepoFactory.AnimeEpisode_User.GetLastWatchedEpisodeForSeries(animeSeriesID, userID)?.GetAnimeEpisode(), userID); } catch (Exception ex) { @@ -499,7 +481,7 @@ public string RemoveAssociationOnFile(int videoLocalID, int animeEpisodeID) { var seriesService = Utils.ServiceContainer.GetRequiredService(); var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video record"; } @@ -562,7 +544,7 @@ public string SetIgnoreStatusOnFile(int videoLocalID, bool isIgnored) try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video record"; } @@ -584,7 +566,7 @@ public string SetVariationStatusOnFile(int videoLocalID, bool isVariation) try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video record"; } @@ -601,9 +583,9 @@ public string SetVariationStatusOnFile(int videoLocalID, bool isVariation) } [NonAction] - private void RemoveXRefsForFile(int VideoLocalID) + private static void RemoveXRefsForFile(int videoLocalID) { - var vlocal = RepoFactory.VideoLocal.GetByID(VideoLocalID); + var vlocal = RepoFactory.VideoLocal.GetByID(videoLocalID); var fileEps = RepoFactory.CrossRef_File_Episode.GetByHash(vlocal.Hash); foreach (var fileEp in fileEps) @@ -625,7 +607,7 @@ public string AssociateSingleFile(int videoLocalID, int animeEpisodeID) try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video record"; } @@ -636,7 +618,7 @@ public string AssociateSingleFile(int videoLocalID, int animeEpisodeID) } var ep = RepoFactory.AnimeEpisode.GetByID(animeEpisodeID); - if (ep == null) + if (ep is null) { return "Could not find episode record"; } @@ -668,18 +650,18 @@ public string AssociateSingleFileWithMultipleEpisodes(int videoLocalID, int anim try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video record"; } - if (vid.Hash == null) + if (vid.Hash is null) { return "Could not associate a cloud file without hash, hash it locally first"; } var ser = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (ser == null) + if (ser is null) { return "Could not find anime series record"; } @@ -690,13 +672,13 @@ public string AssociateSingleFileWithMultipleEpisodes(int videoLocalID, int anim for (var i = startingEpisodeNumber; i <= endEpisodeNumber; i++) { var aniep = RepoFactory.AniDB_Episode.GetByAnimeIDAndEpisodeNumber(ser.AniDB_ID, i)[0]; - if (aniep == null) + if (aniep is null) { return "Could not find the AniDB episode record"; } var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(aniep.EpisodeID); - if (ep == null) + if (ep is null) { return "Could not find episode record"; } @@ -728,7 +710,7 @@ public string AssociateMultipleFiles(List videoLocalIDs, int animeSeriesID, { startingEpisodeNumber ??= "1"; var ser = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (ser == null) + if (ser is null) { return "Could not find anime series record"; } @@ -768,12 +750,12 @@ public string AssociateMultipleFiles(List videoLocalIDs, int animeSeriesID, foreach (var videoLocalID in videoLocalIDs) { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video local record"; } - if (vid.Hash == null) + if (vid.Hash is null) { return "Could not associate a cloud file without hash, hash it locally first"; } @@ -788,7 +770,7 @@ public string AssociateMultipleFiles(List videoLocalIDs, int animeSeriesID, var aniep = anieps[0]; var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(aniep.EpisodeID); - if (ep == null) + if (ep is null) { return "Could not find episode record"; } @@ -838,7 +820,7 @@ public string UpdateFileData(int videoLocalID) try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "File could not be found"; } @@ -867,7 +849,7 @@ public string RescanFile(int videoLocalID) try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "File could not be found"; } @@ -903,7 +885,7 @@ public void RehashFile(int videoLocalID) if (vl != null) { var pl = vl.FirstResolvedPlace; - if (pl == null) + if (pl is null) { _logger.LogError("Unable to hash videolocal with id = {VideoLocalID}, it has no assigned place", videoLocalID); return; @@ -931,7 +913,7 @@ public string DeleteVideoLocalPlaceAndFile(int videoplaceid) try { var place = RepoFactory.VideoLocalPlace.GetByID(videoplaceid); - if (place?.VideoLocal == null) + if (place?.VideoLocal is null) { return "Database entry does not exist"; } @@ -958,7 +940,7 @@ public string DeleteVideoLocalPlaceAndFileSkipFolder(int videoplaceid) try { var place = RepoFactory.VideoLocalPlace.GetByID(videoplaceid); - if (place?.VideoLocal == null) + if (place?.VideoLocal is null) { return "Database entry does not exist"; } @@ -980,7 +962,7 @@ public string SetResumePosition(int videoLocalID, long resumeposition, int userI try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video local record"; } @@ -1051,14 +1033,14 @@ public void IncrementEpisodeStats(int animeEpisodeID, int userID, int statCountT try { var ep = RepoFactory.AnimeEpisode.GetByID(animeEpisodeID); - if (ep == null) + if (ep is null) { return; } var epUserRecord = ep.GetUserRecord(userID); - if (epUserRecord == null) + if (epUserRecord is null) { epUserRecord = new SVR_AnimeEpisode_User(userID, ep.AnimeEpisodeID, ep.AnimeSeriesID); } @@ -1080,7 +1062,7 @@ public void IncrementEpisodeStats(int animeEpisodeID, int userID, int statCountT RepoFactory.AnimeEpisode_User.Save(epUserRecord); var ser = ep.AnimeSeries; - if (ser == null) + if (ser is null) { return; } @@ -1112,7 +1094,7 @@ public void IncrementEpisodeStats(int animeEpisodeID, int userID, int statCountT public void DeleteFileFromMyList(int fileID) { var vl = RepoFactory.VideoLocal.GetByMyListID(fileID); - if (vl == null) + if (vl is null) { return; } @@ -1255,7 +1237,7 @@ public string ToggleWatchedStatusOnVideo(int videoLocalID, bool watchedStatus, i try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return "Could not find video local record"; } @@ -1278,7 +1260,7 @@ public CL_Response ToggleWatchedStatusOnEpisode(int animeE try { var ep = RepoFactory.AnimeEpisode.GetByID(animeEpisodeID); - if (ep == null) + if (ep is null) { response.ErrorMessage = "Could not find anime episode record"; return response; @@ -1311,7 +1293,7 @@ public CL_VideoDetailed GetVideoDetailed(int videoLocalID, int userID) try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return null; } @@ -1332,7 +1314,7 @@ public List GetEpisodesForFile(int videoLocalID, int userI try { var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid == null) + if (vid is null) { return contracts; } @@ -1370,7 +1352,7 @@ public List GetMyReleaseGroupsForAniDBEpisode(int aniDBEpi try { var aniEp = RepoFactory.AniDB_Episode.GetByEpisodeID(aniDBEpisodeID); - if (aniEp == null) + if (aniEp is null) { return relGroups; } @@ -1381,7 +1363,7 @@ public List GetMyReleaseGroupsForAniDBEpisode(int aniDBEpi } var series = RepoFactory.AnimeSeries.GetByAnimeID(aniEp.AnimeID); - if (series == null) + if (series is null) { return relGroups; } @@ -1528,7 +1510,7 @@ public void VoteAnime(int animeID, [FromForm] decimal voteValue, int voteType) { c.AnimeID = animeID; c.VoteType = (AniDBVoteType)voteType; - c.VoteValue = voteValue; + c.VoteValue = Convert.ToDouble(voteValue); } ).GetAwaiter().GetResult(); } @@ -1550,7 +1532,7 @@ public void VoteAnimeRevoke(int animeID) } } - if (thisVote == null) + if (thisVote is null) { return; } @@ -1589,7 +1571,7 @@ public string SetWatchedStatusOnSeries(int animeSeriesID, bool watchedStatus, in var seriesService = Utils.ServiceContainer.GetRequiredService(); foreach (var ep in eps) { - if (ep?.AniDB_Episode == null) + if (ep?.AniDB_Episode is null) { continue; } @@ -1655,7 +1637,7 @@ public bool GetSeriesExistingForAnime(int animeID) try { var series = RepoFactory.AnimeSeries.GetByAnimeID(animeID); - if (series == null) + if (series is null) { return false; } @@ -1726,7 +1708,7 @@ public List GetAllGroupsAboveSeries(int animeSeriesID, int u try { var series = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (series == null) + if (series is null) { return grps; } @@ -1799,7 +1781,7 @@ public string DeleteAnimeGroup(int animeGroupID, bool deleteFiles) try { var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID); - if (grp == null) + if (grp is null) { return "Group does not exist"; } @@ -1828,7 +1810,7 @@ public List GetAnimeGroupsForFilter(int groupFilterID, int u try { var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) return retGroups; + if (user is null) return retGroups; var gf = RepoFactory.FilterPreset.GetByID(groupFilterID); @@ -1884,7 +1866,7 @@ public CL_Response SaveGroup(CL_AnimeGroup_Save_Request cont if (contract.AnimeGroupID is > 0) { group = RepoFactory.AnimeGroup.GetByID(contract.AnimeGroupID.Value); - if (group == null) + if (group is null) { contractout.ErrorMessage = "Could not find existing group with ID: " + contract.AnimeGroupID.Value; @@ -1954,7 +1936,7 @@ public CL_Response SaveGroup(CL_AnimeGroup_Save_Request cont } else { - var mainName = mainSeries.SeriesName; + var mainName = mainSeries.PreferredTitle; if (!string.Equals(group.GroupName, mainName)) { group.GroupName = mainName; @@ -2003,7 +1985,8 @@ public CL_Response SaveGroup(CL_AnimeGroup_Save_Request cont var userRecord = RepoFactory.AnimeGroup_User.GetByUserAndGroupID(userID, group.AnimeGroupID) ?? new AnimeGroup_User { - JMMUserID = userID, AnimeGroupID = group.AnimeGroupID + JMMUserID = userID, + AnimeGroupID = group.AnimeGroupID, }; userRecord.IsFave = contract.IsFave; RepoFactory.AnimeGroup_User.Save(userRecord); @@ -2069,7 +2052,7 @@ public CL_Response SaveSeries(CL_AnimeSeries_Save_Request c if (contract.AnimeSeriesID.HasValue && contract.AnimeSeriesID.Value > 0) { var series = RepoFactory.AnimeSeries.GetByID(contract.AnimeSeriesID.Value); - if (series == null) + if (series is null) { contractout.ErrorMessage = "Could not find existing series with ID: " + contract.AnimeSeriesID.Value; return contractout; @@ -2097,25 +2080,27 @@ public CL_Response SaveSeries(CL_AnimeSeries_Save_Request c } var updated = shouldMove; - if (string.Equals(contract.DefaultAudioLanguage, series.DefaultAudioLanguage)) + if (!string.Equals(contract.DefaultAudioLanguage, series.DefaultAudioLanguage)) { series.DefaultAudioLanguage = contract.DefaultAudioLanguage; updated = true; } - if (string.Equals(contract.DefaultSubtitleLanguage, series.DefaultSubtitleLanguage)) + if (!string.Equals(contract.DefaultSubtitleLanguage, series.DefaultSubtitleLanguage)) { series.DefaultSubtitleLanguage = contract.DefaultSubtitleLanguage; updated = true; } - if (string.Equals(contract.SeriesNameOverride, series.SeriesNameOverride)) + if (!string.Equals(contract.SeriesNameOverride, series.SeriesNameOverride)) { series.SeriesNameOverride = contract.SeriesNameOverride; + series.ResetPreferredTitle(); + series.ResetAnimeTitles(); updated = true; } - if (string.Equals(contract.DefaultFolder, series.DefaultFolder)) + if (!string.Equals(contract.DefaultFolder, series.DefaultFolder)) { series.DefaultFolder = contract.DefaultFolder; updated = true; @@ -2140,7 +2125,7 @@ public CL_Response SaveSeries(CL_AnimeSeries_Save_Request c else { var anime = RepoFactory.AniDB_Anime.GetByAnimeID(contract.AniDB_ID); - if (anime == null) + if (anime is null) { contractout.ErrorMessage = $"Could not find anime record with ID: {contract.AniDB_ID}"; return contractout; @@ -2198,7 +2183,7 @@ public CL_Response CreateSeriesFromAnime(int animeID, int? if (animeGroupID is > 0) { var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID.Value); - if (grp == null) + if (grp is null) { response.ErrorMessage = "Could not find the specified group"; return response; @@ -2226,7 +2211,7 @@ public CL_Response CreateSeriesFromAnime(int animeID, int? }); var anime = job.Process().Result; - if (anime == null) + if (anime is null) { response.ErrorMessage = "Could not get anime information from AniDB"; return response; @@ -2322,13 +2307,13 @@ public void SetDefaultSeriesForGroup(int animeGroupID, int animeSeriesID) try { var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID); - if (grp == null) + if (grp is null) { return; } var ser = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (ser == null) + if (ser is null) { return; } @@ -2348,7 +2333,7 @@ public void RemoveDefaultSeriesForGroup(int animeGroupID) try { var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID); - if (grp == null) + if (grp is null) { return; } @@ -2384,13 +2369,13 @@ public void IgnoreAnime(int animeID, int ignoreType, int userID) try { var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { return; } var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return; } @@ -2419,13 +2404,13 @@ public List GetSimilarAnimeLinks(int animeID, int userID var aniDBAnimeService = Utils.ServiceContainer.GetRequiredService(); var seriesService = Utils.ServiceContainer.GetRequiredService(); var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { return links; } var juser = RepoFactory.JMMUser.GetByID(userID); - if (juser == null) + if (juser is null) { return links; } @@ -2476,13 +2461,13 @@ public List GetRelatedAnimeLinks(int animeID, int userI var aniDBAnimeService = Utils.ServiceContainer.GetRequiredService(); var seriesService = Utils.ServiceContainer.GetRequiredService(); var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { return links; } var juser = RepoFactory.JMMUser.GetByID(userID); - if (juser == null) + if (juser is null) { return links; } @@ -2528,6 +2513,7 @@ public List GetRelatedAnimeLinks(int animeID, int userI /// /// /// also delete the physical files + /// /// [HttpDelete("Series/{animeSeriesID}/{deleteFiles}/{deleteParentGroup}")] public string DeleteAnimeSeries(int animeSeriesID, bool deleteFiles, bool deleteParentGroup) @@ -2535,7 +2521,7 @@ public string DeleteAnimeSeries(int animeSeriesID, bool deleteFiles, bool delete try { var ser = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (ser == null) + if (ser is null) { return "Series does not exist"; } @@ -2656,7 +2642,7 @@ public List GetAnimeRatings(int collectionState, int watchedStat var allVotes = RepoFactory.AniDB_Vote.GetAll(); var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return contracts; } @@ -2717,7 +2703,7 @@ public List GetAnimeRatings(int collectionState, int watchedStat } var userRec = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(userID, series.AnimeSeriesID); - if (userRec == null) + if (userRec is null) { continue; } @@ -2855,7 +2841,7 @@ public List GetSubGroupsForGroup(int animeGroupID, int userI try { var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID); - if (grp == null) + if (grp is null) { return retGroups; } @@ -2885,7 +2871,7 @@ public List GetSeriesForGroup(int animeGroupID, int userID) { var seriesService = Utils.ServiceContainer.GetRequiredService(); var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID); - if (grp == null) + if (grp is null) { return series; } @@ -2916,7 +2902,7 @@ public List GetSeriesForGroupRecursive(int animeGroupID, in try { var grp = RepoFactory.AnimeGroup.GetByID(animeGroupID); - if (grp == null) + if (grp is null) { return series; } @@ -2953,7 +2939,7 @@ public CL_Response SaveGroupFilter(CL_GroupFilter contract) if (contract.GroupFilterID != 0) { gf = RepoFactory.FilterPreset.GetByID(contract.GroupFilterID); - if (gf == null) + if (gf is null) { response.ErrorMessage = "Could not find existing Group Filter with ID: " + contract.GroupFilterID; @@ -2963,7 +2949,7 @@ public CL_Response SaveGroupFilter(CL_GroupFilter contract) var legacyConverter = HttpContext.RequestServices.GetRequiredService(); var newFilter = legacyConverter.FromClient(contract); - if (gf == null) + if (gf is null) { gf = newFilter; } @@ -2988,7 +2974,7 @@ public string DeleteGroupFilter(int groupFilterID) try { var gf = RepoFactory.FilterPreset.GetByID(groupFilterID); - if (gf == null) return "Group Filter not found"; + if (gf is null) return "Group Filter not found"; RepoFactory.FilterPreset.Delete(groupFilterID); @@ -3007,13 +2993,13 @@ public CL_GroupFilterExtended GetGroupFilterExtended(int groupFilterID, int user try { var gf = RepoFactory.FilterPreset.GetByID(groupFilterID); - if (gf == null) + if (gf is null) { return null; } var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return null; } @@ -3042,7 +3028,7 @@ public List GetAllGroupFiltersExtended(int userID) try { var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return gfs; } @@ -3051,7 +3037,8 @@ public List GetAllGroupFiltersExtended(int userID) var legacyConverter = HttpContext.RequestServices.GetRequiredService(); gfs = legacyConverter.ToClient(allGfs).Select(a => new CL_GroupFilterExtended { - GroupFilter = a, GroupCount = a.Groups[userID].Count + GroupFilter = a, + GroupCount = a.Groups[userID].Count, }).ToList(); } catch (Exception ex) @@ -3069,7 +3056,7 @@ public List GetGroupFiltersExtended(int userID, int gfpa try { var user = RepoFactory.JMMUser.GetByID(userID); - if (user == null) + if (user is null) { return gfs; } @@ -3080,7 +3067,8 @@ public List GetGroupFiltersExtended(int userID, int gfpa var legacyConverter = HttpContext.RequestServices.GetRequiredService(); gfs = legacyConverter.ToClient(allGfs).Select(a => new CL_GroupFilterExtended { - GroupFilter = a, GroupCount = a.Groups.FirstOrDefault().Value.Count + GroupFilter = a, + GroupCount = a.Groups.FirstOrDefault().Value.Count, }).ToList(); } catch (Exception ex) @@ -3101,7 +3089,7 @@ public List GetAllGroupFilters() var allGfs = RepoFactory.FilterPreset.GetAll(); var ts = DateTime.Now - start; - _logger.LogInformation("GetAllGroupFilters (Database) in {0} ms", ts.TotalMilliseconds); + _logger.LogInformation("GetAllGroupFilters (Database) in {Milliseconds}ms", ts.TotalMilliseconds); var legacyConverter = HttpContext.RequestServices.GetRequiredService(); gfs = legacyConverter.ToClient(allGfs) @@ -3203,7 +3191,7 @@ public CL_Response SavePlaylist(Playlist contract) if (contract.PlaylistID != 0) { pl = RepoFactory.Playlist.GetByID(contract.PlaylistID); - if (pl == null) + if (pl is null) { contractRet.ErrorMessage = "Could not find existing Playlist with ID: " + contract.PlaylistID; @@ -3247,7 +3235,7 @@ public string DeletePlaylist(int playlistID) try { var pl = RepoFactory.Playlist.GetByID(playlistID); - if (pl == null) + if (pl is null) { return "Playlist not found"; } @@ -3339,7 +3327,7 @@ public string DeleteCustomTagCrossRefByID(int xrefID) try { var pl = RepoFactory.CrossRef_CustomTag.GetByID(xrefID); - if (pl == null) + if (pl is null) { return "Custom Tag not found"; } @@ -3363,7 +3351,7 @@ public string DeleteCustomTagCrossRef(int customTagID, int crossRefType, int cro var xrefs = RepoFactory.CrossRef_CustomTag.GetByUniqueID(customTagID, crossRefType, crossRefID); - if (xrefs == null || xrefs.Count == 0) + if (xrefs is null || xrefs.Count == 0) { return "Custom Tag not found"; } @@ -3391,7 +3379,7 @@ public CL_Response SaveCustomTag(CustomTag contract) if (contract.CustomTagID != 0) { ctag = RepoFactory.CustomTag.GetByID(contract.CustomTagID); - if (ctag == null) + if (ctag is null) { contractRet.ErrorMessage = "Could not find existing custom tag with ID: " + contract.CustomTagID; @@ -3432,7 +3420,7 @@ public string DeleteCustomTag(int customTagID) try { var pl = RepoFactory.CustomTag.GetByID(customTagID); - if (pl == null) + if (pl is null) { return "Custom Tag not found"; } @@ -3514,7 +3502,7 @@ public string ChangePassword(int userID, string newPassword, bool revokeapikey) try { var jmmUser = RepoFactory.JMMUser.GetByID(userID); - if (jmmUser == null) + if (jmmUser is null) { return "User not found"; } @@ -3542,12 +3530,11 @@ public string SaveUser(JMMUser user) { var existingUser = false; var updateStats = false; - var updateGf = false; SVR_JMMUser jmmUser = null; if (user.JMMUserID != 0) { jmmUser = RepoFactory.JMMUser.GetByID(user.JMMUserID); - if (jmmUser == null) + if (jmmUser is null) { return "User not found"; } @@ -3558,7 +3545,6 @@ public string SaveUser(JMMUser user) { jmmUser = new SVR_JMMUser(); updateStats = true; - updateGf = true; } if (existingUser && jmmUser.IsAniDBUser != user.IsAniDBUser) @@ -3566,13 +3552,7 @@ public string SaveUser(JMMUser user) updateStats = true; } - var hcat = string.Join(",", user.HideCategories); - if (jmmUser.HideCategories != hcat) - { - updateGf = true; - } - - jmmUser.HideCategories = hcat; + jmmUser.HideCategories = string.Join(",", user.HideCategories); jmmUser.IsAniDBUser = user.IsAniDBUser; jmmUser.IsTraktUser = user.IsTraktUser; jmmUser.IsAdmin = user.IsAdmin; @@ -3653,7 +3633,7 @@ public string DeleteUser(int userID) try { var jmmUser = RepoFactory.JMMUser.GetByID(userID); - if (jmmUser == null) + if (jmmUser is null) { return "User not found"; } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs index 486463c73..ee62de4cc 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs @@ -10,15 +10,18 @@ using Shoko.Models.Interfaces; using Shoko.Models.Server; using Shoko.Models.TvDB; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Databases; using Shoko.Server.Extensions; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Providers.TraktTV; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Trakt; -using Shoko.Server.Scheduling.Jobs.TvDB; using Shoko.Server.Utilities; +#pragma warning disable ASP0023 +#pragma warning disable CA2012 namespace Shoko.Server; public partial class ShokoServiceImplementation : IShokoServer @@ -28,19 +31,19 @@ public CL_AniDB_AnimeCrossRefs GetCrossRefDetails(int animeID) { var result = new CL_AniDB_AnimeCrossRefs { - CrossRef_AniDB_TvDB = new List(), - TvDBSeries = new List(), - TvDBEpisodes = new List(), - TvDBImageFanarts = new List(), - TvDBImagePosters = new List(), - TvDBImageWideBanners = new List(), + CrossRef_AniDB_TvDB = [], + TvDBSeries = [], + TvDBEpisodes = [], + TvDBImageFanarts = [], + TvDBImagePosters = [], + TvDBImageWideBanners = [], CrossRef_AniDB_MovieDB = null, MovieDBMovie = null, - MovieDBFanarts = new List(), - MovieDBPosters = new List(), + MovieDBFanarts = [], + MovieDBPosters = [], CrossRef_AniDB_MAL = null, - CrossRef_AniDB_Trakt = new List(), - TraktShows = new List(), + CrossRef_AniDB_Trakt = [], + TraktShows = [], AnimeID = animeID }; @@ -53,44 +56,7 @@ public CL_AniDB_AnimeCrossRefs GetCrossRefDetails(int animeID) return result; } - var xrefs = RepoFactory.CrossRef_AniDB_TvDB.GetV2LinksFromAnime(animeID); - - // TvDB - result.CrossRef_AniDB_TvDB = xrefs; - - foreach (var ep in anime.TvDBEpisodes) - { - result.TvDBEpisodes.Add(ep); - } - - foreach (var xref in xrefs.DistinctBy(a => a.TvDBID)) - { - var ser = RepoFactory.TvDB_Series.GetByTvDBID(xref.TvDBID); - if (ser != null) - { - result.TvDBSeries.Add(ser); - } - - foreach (var fanart in RepoFactory.TvDB_ImageFanart.GetBySeriesID(xref.TvDBID)) - { - result.TvDBImageFanarts.Add(fanart); - } - - foreach (var poster in RepoFactory.TvDB_ImagePoster.GetBySeriesID(xref.TvDBID)) - { - result.TvDBImagePosters.Add(poster); - } - - foreach (var banner in RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(xref - .TvDBID)) - { - result.TvDBImageWideBanners.Add(banner); - } - } - // Trakt - - foreach (var xref in anime.GetCrossRefTraktV2()) { result.CrossRef_AniDB_Trakt.Add(xref); @@ -102,31 +68,16 @@ public CL_AniDB_AnimeCrossRefs GetCrossRefDetails(int animeID) } } - - // MovieDB - var xrefMovie = anime.CrossRefMovieDB; - result.CrossRef_AniDB_MovieDB = xrefMovie; - - - result.MovieDBMovie = anime.MovieDBMovie; - - - foreach (var fanart in anime.MovieDBFanarts) + // TMDB + var (xrefMovie, _) = anime.TmdbMovieCrossReferences; + result.CrossRef_AniDB_MovieDB = xrefMovie?.ToClient(); + if (xrefMovie?.TmdbMovie is { } tmdbMovie) { - if (fanart.ImageSize.Equals(Shoko.Models.Constants.MovieDBImageSize.Original, - StringComparison.InvariantCultureIgnoreCase)) - { - result.MovieDBFanarts.Add(fanart); - } - } - - foreach (var poster in anime.MovieDBPosters) - { - if (poster.ImageSize.Equals(Shoko.Models.Constants.MovieDBImageSize.Original, - StringComparison.InvariantCultureIgnoreCase)) - { - result.MovieDBPosters.Add(poster); - } + result.MovieDBMovie = xrefMovie?.TmdbMovie?.ToClient(); + foreach (var fanart in tmdbMovie.GetImages(ImageEntityType.Backdrop)) + result.MovieDBFanarts.Add(fanart.ToClientFanart()); + foreach (var poster in tmdbMovie.GetImages(ImageEntityType.Poster)) + result.MovieDBPosters.Add(poster.ToClientPoster()); } // MAL @@ -137,7 +88,7 @@ public CL_AniDB_AnimeCrossRefs GetCrossRefDetails(int animeID) } else { - result.CrossRef_AniDB_MAL = new List(); + result.CrossRef_AniDB_MAL = []; foreach (var xrefTemp in xrefMAL) { result.CrossRef_AniDB_MAL.Add(xrefTemp); @@ -148,7 +99,7 @@ public CL_AniDB_AnimeCrossRefs GetCrossRefDetails(int animeID) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return result; } } @@ -170,7 +121,7 @@ public Azure_AnimeLink Admin_GetRandomLinkForApproval(int linkType) [HttpGet("WebCache/AdminMessages")] public List GetAdminMessages() { - return new List(); + return []; } #region Admin - TvDB @@ -201,6 +152,8 @@ public string UseMyTvDBLinksWebCache(int animeID) #region Admin - Trakt + + // The interface have these mapped to the same endpoint and same method, so just map them and let them conflict. [HttpPost("WebCache/CrossRef/Trakt/{crossRef_AniDB_TraktId}")] public string ApproveTraktCrossRefWebCache(int crossRef_AniDB_TraktId) { @@ -232,157 +185,55 @@ public string UseMyTraktLinksWebCache(int animeID) [HttpPost("Series/TvDB/Refresh/{seriesID}")] public string UpdateTvDBData(int seriesID) { - try - { - _schedulerFactory.GetScheduler().Result.StartJobNow( - c => - { - c.TvDBSeriesID = seriesID; - c.ForceRefresh = true; - } - ).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - } - return string.Empty; } [HttpGet("TvDB/Language")] public List GetTvDBLanguages() { - try - { - return _tvdbHelper.GetLanguagesAsync().Result; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - } - - return new List(); + return []; } [HttpGet("WebCache/CrossRef/TvDB/{animeID}/{isAdmin}")] public List GetTVDBCrossRefWebCache(int animeID, bool isAdmin) { - try - { - return new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } [HttpGet("TvDB/CrossRef/{animeID}")] public List GetTVDBCrossRefV2(int animeID) { - try - { - return RepoFactory.CrossRef_AniDB_TvDB.GetV2LinksFromAnime(animeID); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } [HttpGet("TvDB/CrossRef/Preview/{animeID}/{tvdbID}")] public List GetTvDBEpisodeMatchPreview(int animeID, int tvdbID) { - return TvDBLinkingHelper.GetMatchPreviewWithOverrides(animeID, tvdbID); + return []; } [HttpGet("TvDB/CrossRef/Episode/{animeID}")] public List GetTVDBCrossRefEpisode(int animeID) { - try - { - return RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAnimeID(animeID).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } [HttpGet("TvDB/Search/{criteria}")] public List SearchTheTvDB(string criteria) { - try - { - return _tvdbHelper.SearchSeriesAsync(criteria).Result; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } [HttpGet("Series/Seasons/{seriesID}")] public List GetSeasonNumbersForSeries(int seriesID) { - var seasonNumbers = new List(); - try - { - // refresh data from TvDB - _tvdbHelper.UpdateSeriesInfoAndImages(seriesID, true, false).GetAwaiter().GetResult(); - - seasonNumbers = RepoFactory.TvDB_Episode.GetSeasonNumbersForSeries(seriesID); - - return seasonNumbers; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return seasonNumbers; - } + return []; } [HttpPost("TvDB/CrossRef")] public string LinkAniDBTvDB(CrossRef_AniDB_TvDBV2 link) { - try - { - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByAniDBAndTvDBID(link.AnimeID, link.TvDBID); - - if (xref != null && link.IsAdditive) - { - var msg = $"You have already linked Anime ID {xref.AniDBID} to this TvDB show/season/ep"; - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(xref.AniDBID); - if (anime != null) - { - msg = - $"You have already linked Anime {anime.MainTitle} ({xref.AniDBID}) to this TvDB show/season/ep"; - } - - return msg; - } - - // we don't need to proactively remove the link here anymore, as all links are removed when it is not marked as additive - _schedulerFactory.GetScheduler().Result.StartJobNow(c => - { - c.AnimeID = link.AnimeID; - c.TvDBID = link.TvDBID; - c.AdditiveLink = link.IsAdditive; - } - ).GetAwaiter().GetResult(); - - return string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return ex.Message; - } + return string.Empty; } [HttpPost("TvDB/CrossRef/FromWebCache")] @@ -394,17 +245,7 @@ public string LinkTvDBUsingWebCacheLinks(List links) [HttpPost("TvDB/CrossRef/Episode/{aniDBID}/{tvDBID}")] public string LinkAniDBTvDBEpisode(int aniDBID, int tvDBID) { - try - { - _tvdbHelper.LinkAniDBTvDBEpisode(aniDBID, tvDBID); - - return string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return ex.Message; - } + return string.Empty; } /// @@ -415,195 +256,43 @@ public string LinkAniDBTvDBEpisode(int aniDBID, int tvDBID) [HttpDelete("TvDB/CrossRef/{animeID}")] public string RemoveLinkAniDBTvDBForAnime(int animeID) { - try - { - var ser = RepoFactory.AnimeSeries.GetByAnimeID(animeID); - - if (ser == null) - { - return "Could not find Series for Anime!"; - } - - var xrefs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(animeID); - if (xrefs == null) - { - return string.Empty; - } - - foreach (var xref in xrefs) - { - // check if there are default images used associated - var images = RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeID(animeID); - foreach (var image in images) - { - if (image.ImageParentType == (int)ImageEntityType.TvDB_Banner || - image.ImageParentType == (int)ImageEntityType.TvDB_Cover || - image.ImageParentType == (int)ImageEntityType.TvDB_FanArt) - { - if (image.ImageParentID == xref.TvDBID) - { - RepoFactory.AniDB_Anime_DefaultImage.Delete(image.AniDB_Anime_DefaultImageID); - } - } - } - - _tvdbHelper.RemoveLinkAniDBTvDB(xref.AniDBID, xref.TvDBID); - } - - return string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return ex.Message; - } + return string.Empty; } [HttpDelete("TvDB/CrossRef")] public string RemoveLinkAniDBTvDB(CrossRef_AniDB_TvDBV2 link) { - try - { - var ser = RepoFactory.AnimeSeries.GetByAnimeID(link.AnimeID); - - if (ser == null) - { - return "Could not find Series for Anime!"; - } - - // check if there are default images used associated - var images = RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeID(link.AnimeID); - foreach (var image in images) - { - if (image.ImageParentType == (int)ImageEntityType.TvDB_Banner || - image.ImageParentType == (int)ImageEntityType.TvDB_Cover || - image.ImageParentType == (int)ImageEntityType.TvDB_FanArt) - { - if (image.ImageParentID == link.TvDBID) - { - RepoFactory.AniDB_Anime_DefaultImage.Delete(image.AniDB_Anime_DefaultImageID); - } - } - } - - _tvdbHelper.RemoveLinkAniDBTvDB(link.AnimeID, link.TvDBID); - - return string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return ex.Message; - } + return string.Empty; } [HttpDelete("TvDB/CrossRef/Episode/{aniDBEpisodeID}/{tvDBEpisodeID}")] public string RemoveLinkAniDBTvDBEpisode(int aniDBEpisodeID, int tvDBEpisodeID) { - try - { - var ep = RepoFactory.AniDB_Episode.GetByEpisodeID(aniDBEpisodeID); - - if (ep == null) - { - return "Could not find Episode"; - } - - var xref = - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBAndTvDBEpisodeIDs(aniDBEpisodeID, - tvDBEpisodeID); - if (xref == null) - { - return "Could not find Link!"; - } - - - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.Delete(xref.CrossRef_AniDB_TvDB_Episode_OverrideID); - - return string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return ex.Message; - } + return string.Empty; } [HttpGet("TvDB/Poster/{tvDBID?}")] public List GetAllTvDBPosters(int? tvDBID) { - var allImages = new List(); - try - { - if (tvDBID.HasValue) - { - return RepoFactory.TvDB_ImagePoster.GetBySeriesID(tvDBID.Value); - } - - return RepoFactory.TvDB_ImagePoster.GetAll().ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } [HttpGet("TvDB/Banner/{tvDBID?}")] public List GetAllTvDBWideBanners(int? tvDBID) { - try - { - if (tvDBID.HasValue) - { - return RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(tvDBID.Value); - } - - return RepoFactory.TvDB_ImageWideBanner.GetAll().ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } [HttpGet("TvDB/Fanart/{tvDBID?}")] public List GetAllTvDBFanart(int? tvDBID) { - try - { - if (tvDBID.HasValue) - { - return RepoFactory.TvDB_ImageFanart.GetBySeriesID(tvDBID.Value); - } - - return RepoFactory.TvDB_ImageFanart.GetAll().ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } [HttpGet("TvDB/Episode/{tvDBID?}")] public List GetAllTvDBEpisodes(int? tvDBID) { - try - { - if (tvDBID.HasValue) - { - return RepoFactory.TvDB_Episode.GetBySeriesID(tvDBID.Value); - } - - return RepoFactory.TvDB_Episode.GetAll().ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.ToString()); - return new List(); - } + return []; } #endregion @@ -624,8 +313,8 @@ public List GetAllTraktEpisodes(int? traktShowID) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); - return new List(); + _logger.LogError(ex, "{ex}", ex.ToString()); + return []; } } @@ -640,12 +329,12 @@ public List GetAllTraktEpisodesByTraktID(string traktID) return GetAllTraktEpisodes(show.Trakt_ShowID); } - return new List(); + return []; } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); - return new List(); + _logger.LogError(ex, "{ex}", ex.ToString()); + return []; } } @@ -654,12 +343,12 @@ public List GetTraktCrossRefWebCache(int animeID, bo { try { - return new List(); + return []; } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); - return new List(); + _logger.LogError(ex, "{ex}", ex.ToString()); + return []; } } @@ -705,7 +394,7 @@ public string LinkAniDBTrakt(int animeID, int aniEpType, int aniEpNumber, string } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return ex.Message; } } @@ -719,15 +408,15 @@ public List GetTraktCrossRefV2(int animeID) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); - return new List(); + _logger.LogError(ex, "{ex}", ex.ToString()); + return []; } } [HttpGet("Trakt/CrossRef/Episode/{animeID}")] public List GetTraktCrossRefEpisode(int animeID) { - return new List(); + return []; } [HttpGet("Trakt/Search/{criteria}")] @@ -747,7 +436,7 @@ public List SearchTrakt(string criteria) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return results; } } @@ -764,17 +453,6 @@ public string RemoveLinkAniDBTraktForAnime(int animeID) return "Could not find Series for Anime!"; } - // check if there are default images used associated - var images = RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeID(animeID); - foreach (var image in images) - { - if (image.ImageParentType == (int)ImageEntityType.Trakt_Fanart || - image.ImageParentType == (int)ImageEntityType.Trakt_Poster) - { - RepoFactory.AniDB_Anime_DefaultImage.Delete(image.AniDB_Anime_DefaultImageID); - } - } - foreach (var xref in RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(animeID)) { _traktHelper.RemoveLinkAniDBTrakt(animeID, (EpisodeType)xref.AniDBStartEpisodeType, @@ -786,7 +464,7 @@ public string RemoveLinkAniDBTraktForAnime(int animeID) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return ex.Message; } } @@ -805,17 +483,6 @@ public string RemoveLinkAniDBTrakt(int animeID, int aniEpType, int aniEpNumber, return "Could not find Series for Anime!"; } - // check if there are default images used associated - var images = RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeID(animeID); - foreach (var image in images) - { - if (image.ImageParentType == (int)ImageEntityType.Trakt_Fanart || - image.ImageParentType == (int)ImageEntityType.Trakt_Poster) - { - RepoFactory.AniDB_Anime_DefaultImage.Delete(image.AniDB_Anime_DefaultImageID); - } - } - _traktHelper.RemoveLinkAniDBTrakt(animeID, (EpisodeType)aniEpType, aniEpNumber, traktID, traktSeasonNumber, traktEpNumber); @@ -823,7 +490,7 @@ public string RemoveLinkAniDBTrakt(int animeID, int aniEpType, int aniEpNumber, } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return ex.Message; } } @@ -852,7 +519,7 @@ public List GetSeasonNumbersForTrakt(string traktID) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return seasonNumbers; } } @@ -934,7 +601,7 @@ public int TraktScrobble(int animeId, int type, int progress, int status) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return 500; } } @@ -948,7 +615,7 @@ public string UpdateTraktData(string traktID) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); } return string.Empty; @@ -977,7 +644,7 @@ public string SyncTraktSeries(int animeID) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return ex.Message; } } @@ -985,7 +652,7 @@ public string SyncTraktSeries(int animeID) [HttpPost("Trakt/Comment/{traktID}/{isSpoiler}")] public CL_Response PostTraktCommentShow(string traktID, string commentText, bool isSpoiler) { - return _traktHelper.PostCommentShow(traktID, commentText, isSpoiler); + return new CL_Response() { Result = false }; } [HttpPost("Trakt/LinkValidity/{slug}/{removeDBEntries}")] @@ -997,7 +664,7 @@ public bool CheckTraktLinkValidity(string slug, bool removeDBEntries) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); } return false; @@ -1012,16 +679,16 @@ public List GetAllTraktCrossRefs() } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); } - return new List(); + return []; } [HttpGet("Trakt/Comment/{animeID}")] public List GetTraktCommentsForAnime(int animeID) { - return new List(); + return []; } [HttpGet("Trakt/DeviceCode")] @@ -1034,7 +701,7 @@ public CL_TraktDeviceCode GetTraktDeviceCode() } catch (Exception ex) { - _logger.LogError(ex, "Error in GetTraktDeviceCode: " + ex); + _logger.LogError(ex, "Error in GetTraktDeviceCode: {ex}", ex.ToString()); return null; } } @@ -1052,7 +719,7 @@ public CL_CrossRef_AniDB_Other_Response GetOtherAnimeCrossRefWebCache(int animeI } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return null; } } @@ -1062,11 +729,14 @@ public CrossRef_AniDB_Other GetOtherAnimeCrossRef(int animeID, int crossRefType) { try { - return RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(animeID, (CrossRefType)crossRefType); + if (crossRefType != (int)CrossRefType.MovieDB) + return null; + var (xref, _) = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(animeID); + return xref?.ToClient(); } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return null; } } @@ -1081,7 +751,11 @@ public string LinkAniDBOther(int animeID, int id, int crossRefType) switch (xrefType) { case CrossRefType.MovieDB: - _movieDBHelper.LinkAniDBMovieDB(animeID, id, false); + var episodeId = RepoFactory.AniDB_Episode.GetByAnimeIDAndEpisodeTypeNumber(animeID, EpisodeType.Episode, 1).FirstOrDefault()?.EpisodeID; + if (!episodeId.HasValue || episodeId <= 0) + return $"Could not find first episode for AniDB Anime {animeID} to link to for TMDB Movie {id}"; + _tmdbLinkingService.AddMovieLinkForEpisode(episodeId.Value, id).ConfigureAwait(false).GetAwaiter().GetResult(); + _tmdbMetadataService.ScheduleUpdateOfMovie(id, downloadImages: true).ConfigureAwait(false).GetAwaiter().GetResult(); break; } @@ -1089,7 +763,7 @@ public string LinkAniDBOther(int animeID, int id, int crossRefType) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return ex.Message; } } @@ -1110,20 +784,7 @@ public string RemoveLinkAniDBOther(int animeID, int crossRefType) switch (xrefType) { case CrossRefType.MovieDB: - - // check if there are default images used associated - var images = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeID(animeID); - foreach (var image in images) - { - if (image.ImageParentType == (int)ImageEntityType.MovieDB_FanArt || - image.ImageParentType == (int)ImageEntityType.MovieDB_Poster) - { - RepoFactory.AniDB_Anime_DefaultImage.Delete(image.AniDB_Anime_DefaultImageID); - } - } - - _movieDBHelper.RemoveLinkAniDBMovieDB(animeID); + _tmdbLinkingService.RemoveAllMovieLinksForAnime(animeID).ConfigureAwait(false).GetAwaiter().GetResult(); break; } @@ -1131,7 +792,7 @@ public string RemoveLinkAniDBOther(int animeID, int crossRefType) } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return ex.Message; } } @@ -1146,18 +807,15 @@ public List SearchTheMovieDB(string criteria) var results = new List(); try { - var movieResults = _movieDBHelper.Search(criteria).Result; + var (movieResults, _) = _tmdbSearchService.SearchMovies(System.Web.HttpUtility.UrlDecode(criteria)).ConfigureAwait(false).GetAwaiter().GetResult(); - foreach (var res in movieResults) - { - results.Add(res.ToContract()); - } + results.AddRange(movieResults.Select(movie => movie.ToContract())); return results; } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return results; } } @@ -1168,16 +826,18 @@ public List GetAllMovieDBPosters(int? movieID) try { if (movieID.HasValue) - { - return RepoFactory.MovieDB_Poster.GetByMovieID(movieID.Value); - } + return RepoFactory.TMDB_Image.GetByTmdbMovieIDAndType(movieID.Value, ImageEntityType.Poster) + .Select(image => image.ToClientPoster()) + .ToList(); - return RepoFactory.MovieDB_Poster.GetAllOriginal(); + return RepoFactory.TMDB_Image.GetByType(ImageEntityType.Poster) + .Select(image => image.ToClientPoster()) + .ToList(); } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); - return new List(); + _logger.LogError(ex, "{ex}", ex.ToString()); + return []; } } @@ -1187,29 +847,31 @@ public List GetAllMovieDBFanart(int? movieID) try { if (movieID.HasValue) - { - return RepoFactory.MovieDB_Fanart.GetByMovieID(movieID.Value); - } + return RepoFactory.TMDB_Image.GetByTmdbMovieIDAndType(movieID.Value, ImageEntityType.Backdrop) + .Select(image => image.ToClientFanart()) + .ToList(); - return RepoFactory.MovieDB_Fanart.GetAllOriginal(); + return RepoFactory.TMDB_Image.GetByType(ImageEntityType.Backdrop) + .Select(image => image.ToClientFanart()) + .ToList(); } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); - return new List(); + _logger.LogError(ex, "{ex}", ex.ToString()); + return []; } } [HttpPost("MovieDB/Refresh/{movieID}")] - public string UpdateMovieDBData(int movieD) + public string UpdateMovieDBData(int movieID) { try { - _movieDBHelper.UpdateMovieInfo(movieD, true); + _tmdbMetadataService.ScheduleUpdateOfMovie(movieID, downloadImages: true, forceRefresh: true).ConfigureAwait(false).GetAwaiter().GetResult(); } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); } return string.Empty; diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs index 1e14125fc..53e245c9e 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs @@ -48,14 +48,16 @@ public List SearchSeriesWithFilename(int uid, [FromForm] st return series.Select(a => a.Result).Select(ser => seriesService.GetV1UserContract(ser, uid)).ToList(); } - private static readonly char[] InvalidPathChars = + private static readonly char[] _invalidPathChars = $"{new string(Path.GetInvalidFileNameChars())}{new string(Path.GetInvalidPathChars())}".ToCharArray(); - private static readonly char[] ReplaceWithSpace = @"[]_-.+&()".ToCharArray(); + private static readonly char[] _replaceWithSpace = @"[]_-.+&()".ToCharArray(); - private static readonly string[] ReplacementStrings = + private static readonly string[] _replacementStrings = {"h264", "x264", "x265", "bluray", "blu-ray", "remux", "avc", "flac", "dvd", "1080p", "720p", "480p", "hevc", "webrip", "web", "h265", "ac3", "aac", "mp3", "dts", "bd"}; + private static readonly char[] _pipeSeparator = ['|']; + private static string ReplaceCaseInsensitive(string input, string search, string replacement) { return Regex.Replace(input, Regex.Escape(search), replacement.Replace("$", "$$"), @@ -66,9 +68,9 @@ internal static string SanitizeFuzzy(string value, bool replaceInvalid) { if (!replaceInvalid) return value; - value = ReplacementStrings.Aggregate(value, (current, c) => ReplaceCaseInsensitive(current, c, string.Empty)); - value = ReplaceWithSpace.Aggregate(value, (current, c) => current.Replace(c, ' ')); - value = value.FilterCharacters(InvalidPathChars, true); + value = _replacementStrings.Aggregate(value, (current, c) => ReplaceCaseInsensitive(current, c, string.Empty)); + value = _replaceWithSpace.Aggregate(value, (current, c) => current.Replace(c, ' ')); + value = value.FilterCharacters(_invalidPathChars, true); // Takes too long //value = RemoveSubgroups(value); @@ -82,9 +84,9 @@ private static double GetLowestLevenshteinDistance(IList languagePrefere if ((titles?.Count ?? 0) == 0) return 1; double dist = 1; var dice = new SorensenDice(); - var languages = new HashSet {"en", "x-jat"}; + var languages = new HashSet { "en", "x-jat" }; languages.UnionWith(languagePreference.Select(b => b.ToLower())); - + foreach (var title in RepoFactory.AniDB_Anime_Title.GetByAnimeID(a.AniDB_ID) .Where(b => b.TitleType != Shoko.Plugin.Abstractions.DataModels.TitleType.Short && languages.Contains(b.LanguageCode)) .Select(b => b.Title?.ToLowerInvariant()).ToList()) @@ -103,7 +105,7 @@ private static double GetLowestLevenshteinDistance(IList languagePrefere } [HttpPost("AniDB/Anime/SearchFilename/{uid}")] - public List SearchAnimeWithFilename(int uid, [FromForm]string query) + public List SearchAnimeWithFilename(int uid, [FromForm] string query) { var aniDBAnimeService = Utils.ServiceContainer.GetRequiredService(); var input = query ?? string.Empty; @@ -113,10 +115,10 @@ public List SearchAnimeWithFilename(int uid, [FromForm]string qu var user = RepoFactory.JMMUser.GetByID(uid); if (user == null) return []; - var languagePreference = _settingsProvider.GetSettings().LanguagePreference; + var languagePreference = _settingsProvider.GetSettings().Language.SeriesTitleLanguageOrder; var animeResults = RepoFactory.AnimeSeries.GetAll() .AsParallel().Select(a => (a, GetLowestLevenshteinDistance(languagePreference, a, input))).OrderBy(a => a.Item2) - .ThenBy(a => a.Item1.SeriesName) + .ThenBy(a => a.Item1.PreferredTitle) .Select(a => a.Item1.AniDB_Anime).ToList(); var seriesList = animeResults.Select(anime => aniDBAnimeService.GetV1Contract(anime)).ToList(); @@ -316,209 +318,36 @@ public List RandomFileRenamePreview(int maxResults, int userID) } [HttpGet("File/Rename/Preview/{videoLocalID}")] - public CL_VideoLocal_Renamed RenameFilePreview(int videoLocalID) - => RenameAndMoveFile(videoLocalID, Shoko.Models.Constants.Renamer.TempFileName, false, true).ConfigureAwait(false).GetAwaiter().GetResult(); + public CL_VideoLocal_Renamed RenameFilePreview(int videoLocalID) => null; [HttpGet("File/Rename/{videoLocalID}/{scriptName}")] - public CL_VideoLocal_Renamed RenameFile(int videoLocalID, string scriptName) - => RenameAndMoveFile(videoLocalID, scriptName, move: false, preview: false).ConfigureAwait(false).GetAwaiter().GetResult(); + public CL_VideoLocal_Renamed RenameFile(int videoLocalID, string scriptName) => null; [HttpGet("File/Rename/{videoLocalID}/{scriptName}/{move}")] - public CL_VideoLocal_Renamed RenameAndMoveFile(int videoLocalID, string scriptName, bool move) - => RenameAndMoveFile(videoLocalID, scriptName, move, preview: false).ConfigureAwait(false).GetAwaiter().GetResult(); - - [NonAction] - private async Task RenameAndMoveFile(int videoLocalID, string scriptName, bool move, bool preview) - { - var ret = new CL_VideoLocal_Renamed - { - VideoLocalID = videoLocalID, - VideoLocal = null, - Success = false, - }; - if (!preview && scriptName != null && scriptName.Equals(Shoko.Models.Constants.Renamer.TempFileName)) - { - ret.NewFileName = "ERROR: Do not attempt to use a temp file to rename."; - return ret; - } - try - { - var script = RepoFactory.RenameScript.GetByName(scriptName); - if (script is null) - { - ret.NewFileName = "ERROR: Could not find script."; - return ret; - } - - var file = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (file == null) - { - ret.NewFileName = "ERROR: Could not find file."; - return ret; - } - - var allLocations = file.Places; - if (allLocations.Count <= 0) - { - ret.NewFileName = "ERROR: No locations were found for the file. Run the \"Remove Missing Files\" action to remove the file."; - return ret; - } - - // First do a dry-run on the best location. - var bestLocation = file.FirstValidPlace; - var service = HttpContext.RequestServices.GetRequiredService(); - var previewResult = await service.AutoRelocateFile(bestLocation, new() { Preview = true, ScriptID = script.RenameScriptID, Move = move }); - if (!previewResult.Success) - { - ret.NewFileName = $"ERROR: {previewResult.ErrorMessage}"; - return ret; - } - - // Relocate the file locations. - var fullPath = string.Empty; - var errorString = string.Empty; - foreach (var place in allLocations) - { - var result = await service.AutoRelocateFile(place, new() { Preview = preview, ScriptID = script.RenameScriptID, Move = move }); - if (result.Success) - fullPath = result.AbsolutePath; - else - errorString = result.ErrorMessage; - } - if (!string.IsNullOrEmpty(errorString)) - { - ret.NewFileName = errorString; - return ret; - } - - // Return the full path if we moved, otherwise return the file name. - ret.Success = true; - ret.VideoLocal = new CL_VideoLocal { VideoLocalID = videoLocalID }; - ret.NewFileName = fullPath; - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while trying to rename/move a file; {ErrorMessage}", ex.Message); - ret.NewFileName = $"ERROR: {ex.Message}"; - } - return ret; - } + public CL_VideoLocal_Renamed RenameAndMoveFile(int videoLocalID, string scriptName, bool move) => null; [HttpGet("RenameScript")] public List GetAllRenameScripts() { - try - { - return RepoFactory.RenameScript.GetAll().Where(a => - !a.ScriptName.EqualsInvariantIgnoreCase(Shoko.Models.Constants.Renamer.TempFileName)) - .ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "{Ex}", ex.ToString()); - } - return new List(); + return []; } [HttpPost("RenameScript")] public CL_Response SaveRenameScript(RenameScript contract) { - var response = new CL_Response - { - ErrorMessage = string.Empty, - Result = null - }; - try - { - RenameScript script; - if (contract.ScriptName.Equals(Shoko.Models.Constants.Renamer.TempFileName)) - { - script = RepoFactory.RenameScript.GetByName(Shoko.Models.Constants.Renamer.TempFileName) ?? - new RenameScript(); - } - else if (contract.RenameScriptID != 0) - { - // update - script = RepoFactory.RenameScript.GetByID(contract.RenameScriptID); - if (script == null) - { - response.ErrorMessage = "Could not find Rename Script ID: " + contract.RenameScriptID; - return response; - } - } - else - { - //response.ErrorMessage = "Could not find Rename Script ID: " + contract.RenameScriptID; - //return response; - script = new RenameScript(); - } - - if (string.IsNullOrEmpty(contract.ScriptName)) - { - response.ErrorMessage = "Must specify a Script Name"; - return response; - } - - - if (contract.IsEnabledOnImport == 1) - { - - // check to make sure we multiple scripts enable on import (only one can be selected) - var allScripts = RepoFactory.RenameScript.GetAll(); - - foreach (var rs in allScripts) - { - if (rs.IsEnabledOnImport == 1 && - (contract.RenameScriptID == 0 || (contract.RenameScriptID != rs.RenameScriptID))) - { - rs.IsEnabledOnImport = 0; - RepoFactory.RenameScript.Save(rs); - } - } - } - - script.IsEnabledOnImport = contract.IsEnabledOnImport; - script.Script = contract.Script; - script.ScriptName = contract.ScriptName; - script.RenamerType = contract.RenamerType; - script.ExtraData = contract.ExtraData; - RepoFactory.RenameScript.Save(script); - - response.Result = script; - - return response; - } - catch (Exception ex) - { - _logger.LogError(ex, "{Ex}", ex.ToString()); - response.ErrorMessage = ex.Message; - return response; - } + return null; } [HttpDelete("RenameScript/{renameScriptID}")] public string DeleteRenameScript(int renameScriptID) { - try - { - var df = RepoFactory.RenameScript.GetByID(renameScriptID); - if (df == null) return "Database entry does not exist"; - RepoFactory.RenameScript.Delete(renameScriptID); - return string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, "{Ex}", ex.ToString()); - return ex.Message; - } + return string.Empty; } [HttpGet("RenameScript/Types")] public IDictionary GetScriptTypes() { - return RenameFileHelper.Renamers - .Select(s => new KeyValuePair(s.Key, s.Value.description)) - .ToDictionary(x => x.Key, x => x.Value); + return new Dictionary(); } [HttpGet("AniDB/Recommendation/{animeID}")] @@ -555,7 +384,7 @@ public List OnlineAnimeTitleSearch(string titleQuery) { AnimeID = anime.AnimeID, MainTitle = anime.MainTitle, - Titles = new HashSet(anime.AllTitles.Split(new[] {'|'}, StringSplitOptions.RemoveEmptyEntries)), + Titles = new HashSet(anime.AllTitles.Split(_pipeSeparator, StringSplitOptions.RemoveEmptyEntries)), }; // check for existing series and group details @@ -690,7 +519,7 @@ public List GetMissingEpisodes(int userID, bool onlyMyGroups, var seriesService = Utils.ServiceContainer.GetRequiredService(); return ser.AllAnimeEpisodes .Where(aep => - aep.AniDB_Episode != null && aep.VideoLocals.Count == 0 && + aep.AniDB_Episode is not null && aep.VideoLocals.Count == 0 && (!regularEpisodesOnly || aep.EpisodeTypeEnum == EpisodeType.Episode)) .Select(aep => aep.AniDB_Episode) .Where(aniep => aniep.HasAired) @@ -1024,7 +853,8 @@ public List GetAllDuplicateFiles() var xref = RepoFactory.CrossRef_File_Episode.GetByHash(vl.Hash); if (xref.Count > 0) { - anime = RepoFactory.AniDB_Anime.GetByAnimeID(xref[0].AnimeID); + if (xref.FirstOrDefault(x => x.AnimeID is not 0)?.AnimeID is { } animeId) + anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeId); episode = RepoFactory.AniDB_Episode.GetByEpisodeID(xref[0].EpisodeID); } @@ -1045,7 +875,7 @@ public List GetAllDuplicateFiles() AnimeName = anime?.MainTitle, EpisodeType = episode?.EpisodeType, EpisodeNumber = episode?.EpisodeNumber, - EpisodeName = episode?.DefaultTitle, + EpisodeName = episode?.DefaultTitle.Title, DuplicateFileID = vl.VideoLocalID, DateTimeUpdated = DateTime.Now }); @@ -1191,10 +1021,10 @@ public List GetFilesByGroupAndResolution(int animeID, string r var thisBitDepth = 8; if (vid.MediaInfo?.VideoStream?.BitDepth != null) thisBitDepth = vid.MediaInfo.VideoStream.BitDepth; - + // Sometimes, especially with older files, the info doesn't quite match for resolution var vidResInfo = vid.VideoResolution; - + _logger.LogTrace("GetFilesByGroupAndResolution -- thisBitDepth: {BitDepth}", thisBitDepth); _logger.LogTrace("GetFilesByGroupAndResolution -- videoBitDepth: {BitDepth}", videoBitDepth); @@ -1210,7 +1040,7 @@ public List GetFilesByGroupAndResolution(int animeID, string r var groupMatches = Constants.NO_GROUP_INFO.EqualsInvariantIgnoreCase(relGroupName) || "null".EqualsInvariantIgnoreCase(relGroupName) || relGroupName == null; _logger.LogTrace("GetFilesByGroupAndResolution -- sourceMatches (manual/unkown): {SourceMatches}", sourceMatches); _logger.LogTrace("GetFilesByGroupAndResolution -- groupMatches (NO GROUP INFO): {GroupMatches}", groupMatches); - + // get the anidb file info var aniFile = vid.AniDBFile; if (aniFile != null) @@ -1240,7 +1070,9 @@ public List GetFilesByGroupAndResolution(int animeID, string r if (groupMatches && sourceMatches && thisBitDepth == videoBitDepth && string.Equals(resolution, vidResInfo, StringComparison.InvariantCultureIgnoreCase)) { +#pragma warning disable CS0618 _logger.LogTrace("GetFilesByGroupAndResolution -- File Matched: {FileName}", vid.FileName); +#pragma warning restore CS0618 vids.Add(_videoLocalService.GetV1DetailedContract(vid, userID)); } } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationImage.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationImage.cs index 405e3c541..74420d6e5 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationImage.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationImage.cs @@ -1,16 +1,13 @@ using System; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Drawing.Imaging; using System.IO; -using Microsoft.AspNetCore.Http; +using ImageMagick; using Microsoft.AspNetCore.Mvc; using NLog; using Shoko.Models.Enums; using Shoko.Models.Interfaces; -using Shoko.Server.Extensions; using Shoko.Server.Properties; -using Shoko.Server.Repositories; +using Shoko.Server.Utilities; + using Mime = MimeMapping.MimeUtility; namespace Shoko.Server; @@ -19,16 +16,14 @@ namespace Shoko.Server; [Route("/api/Image")] [ApiVersionNeutral] [ApiExplorerSettings(IgnoreApi = true)] -public class ShokoServiceImplementationImage : Controller, IShokoServerImage, IHttpContextAccessor +public class ShokoServiceImplementationImage : Controller, IShokoServerImage { - public HttpContext HttpContext { get; set; } - - private static Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); - [HttpGet("{imageid}/{imageType}/{thumnbnailOnly?}")] - public object GetImage(int imageid, int imageType, bool? thumnbnailOnly = false) + [HttpGet("{imageId}/{imageType}/{thumbnailOnly?}")] + public object GetImage(int imageId, int imageType, bool? thumbnailOnly = false) { - var path = GetImagePath(imageid, imageType, thumnbnailOnly); + var path = GetImagePath(imageId, imageType, thumbnailOnly); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) { return NotFound(); @@ -50,74 +45,41 @@ public object BlankImage() } [NonAction] - internal static Bitmap ReSize(Bitmap im, int width, int height) + internal static Stream ResizeImageToRatio(Stream imageStream, float newRatio) { - var dest = new Bitmap(width, height); - using (var g = Graphics.FromImage(dest)) - { - g.InterpolationMode = width >= im.Width - ? InterpolationMode.HighQualityBilinear - : InterpolationMode.HighQualityBicubic; - g.PixelOffsetMode = PixelOffsetMode.HighQuality; - g.SmoothingMode = SmoothingMode.HighQuality; - g.DrawImage(im, 0, 0, width, height); - } + if (Math.Abs(newRatio) < 0.1F) + return imageStream; - return dest; - } + var image = new MagickImage(imageStream); + float originalWidth = image.Width; + float originalHeight = image.Height; + int newWidth, newHeight; - [NonAction] - public Stream ResizeToRatio(Image im, double newratio) - { - double calcwidth = im.Width; - double calcheight = im.Height; + var calculatedWidth = originalWidth; + var calculatedHeight = originalHeight; - if (Math.Abs(newratio) < 0.001D) - { - var stream = new MemoryStream(); - im.Save(stream, ImageFormat.Jpeg); - stream.Seek(0, SeekOrigin.Begin); - Response.ContentType = "image/jpeg"; - return stream; - } - - double nheight = 0; do { - nheight = calcwidth / newratio; - if (nheight > im.Height + 0.5F) + var newHeightFloat = calculatedWidth / newRatio; + if (newHeightFloat > originalHeight + 0.5F) { - calcwidth = calcwidth * (im.Height / nheight); + calculatedWidth *= originalHeight / newHeightFloat; } else { - calcheight = nheight; + calculatedHeight = newHeightFloat; } - } while (nheight > im.Height + 0.5F); + } while (calculatedHeight > originalHeight + 0.5F); - var newwidth = (int)Math.Round(calcwidth); - var newheight = (int)Math.Round(calcheight); - var x = 0; - var y = 0; - if (newwidth < im.Width) - { - x = (im.Width - newwidth) / 2; - } + newWidth = (int)Math.Round(calculatedWidth); + newHeight = (int)Math.Round(calculatedHeight); + image.Resize(new MagickGeometry(newWidth, newHeight)); - if (newheight < im.Height) - { - y = (im.Height - newheight) / 2; - } + var outStream = new MemoryStream(); + image.Write(outStream, MagickFormat.Png); + outStream.Seek(0, SeekOrigin.Begin); - Image im2 = ReSize(new Bitmap(im), newwidth, newheight); - var g = Graphics.FromImage(im2); - g.DrawImage(im, new Rectangle(0, 0, im2.Width, im2.Height), - new Rectangle(x, y, im2.Width, im2.Height), GraphicsUnit.Pixel); - var ms = new MemoryStream(); - im2.Save(ms, ImageFormat.Jpeg); - ms.Seek(0, SeekOrigin.Begin); - Response.ContentType = "image/jpeg"; - return ms; + return outStream; } [HttpGet("Support/{name}/{ratio}")] @@ -129,77 +91,15 @@ public object GetSupportImage(string name, float? ratio) } name = Path.GetFileNameWithoutExtension(name); - var man = Resources.ResourceManager; - var dta = (byte[])man.GetObject(name); - if (dta == null || dta.Length == 0) - { + if (string.IsNullOrEmpty(name) || Resources.ResourceManager.GetObject(name) is not byte[] dta || dta is { Length: 0 }) return NotFound(); - } - //Little hack var ms = new MemoryStream(dta); ms.Seek(0, SeekOrigin.Begin); - if (!name.Contains("404") || ratio == null || Math.Abs(ratio.Value) < 0.001D) - { - Response.ContentType = "image/png"; - return ms; - } - - var im = Image.FromStream(ms); - float w = im.Width; - float h = im.Height; - float nw; - float nh; - - if (w <= h) - { - nw = h * ratio.Value; - if (nw < w) - { - nw = w; - nh = w / ratio.Value; - } - else - { - nh = h; - } - } - else - { - nh = w / ratio.Value; - if (nh < h) - { - nh = h; - nw = w * ratio.Value; - } - else - { - nw = w; - } - } + if (!name.Contains("404") || ratio is null || Math.Abs(ratio.Value) < 0.001D) + return File(ms, "image/png"); - nw = (float)Math.Round(nw); - nh = (float)Math.Round(nh); - Image im2 = new Bitmap((int)nw, (int)nh, PixelFormat.Format32bppArgb); - using (var g = Graphics.FromImage(im2)) - { - g.InterpolationMode = nw >= im.Width - ? InterpolationMode.HighQualityBilinear - : InterpolationMode.HighQualityBicubic; - g.PixelOffsetMode = PixelOffsetMode.HighQuality; - g.SmoothingMode = SmoothingMode.HighQuality; - g.Clear(Color.Transparent); - var src = new Rectangle(0, 0, im.Width, im.Height); - var dst = new Rectangle((int)((nw - w) / 2), (int)((nh - h) / 2), im.Width, im.Height); - g.DrawImage(im, dst, src, GraphicsUnit.Pixel); - } - - var ms2 = new MemoryStream(); - im2.Save(ms2, ImageFormat.Png); - ms2.Seek(0, SeekOrigin.Begin); - ms.Dispose(); - Response.ContentType = "image/png"; - return ms2; + return File(ResizeImageToRatio(ms, ratio.Value), "image/png"); } [HttpGet("Thumb/{imageId}/{imageType}/{ratio}")] @@ -211,190 +111,26 @@ public object GetThumb(int imageId, int imageType, float ratio) return m; } - if (!(m is Stream image)) + if (m is not Stream image) { return NotFound(); } - using (var im = Image.FromStream(image)) - { - return ResizeToRatio(im, ratio); - } + return ResizeImageToRatio(image, ratio); } - [HttpGet("Path/{imageId}/{imageType}/{thumnbnailOnly?}")] - public string GetImagePath(int imageId, int imageType, bool? thumnbnailOnly) + [HttpGet("Path/{imageId}/{imageType}/{thumbnailOnly?}")] + public string GetImagePath(int imageId, int imageType, bool? thumbnailOnly) { - var it = (ImageEntityType)imageType; - - switch (it) + try { - case ImageEntityType.AniDB_Cover: - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(imageId); - if (anime == null) - { - return null; - } - - if (System.IO.File.Exists(anime.PosterPath)) - { - return anime.PosterPath; - } - else - { - logger.Trace("Could not find AniDB_Cover image: {0}", anime.PosterPath); - return string.Empty; - } - - case ImageEntityType.AniDB_Character: - var chr = RepoFactory.AniDB_Character.GetByID(imageId); - if (chr == null) - { - return null; - } - - if (System.IO.File.Exists(chr.GetPosterPath())) - { - return chr.GetPosterPath(); - } - else - { - logger.Trace("Could not find AniDB_Character image: {0}", chr.GetPosterPath()); - return string.Empty; - } - - case ImageEntityType.AniDB_Creator: - var creator = RepoFactory.AniDB_Seiyuu.GetByID(imageId); - if (creator == null) - { - return string.Empty; - } - - if (System.IO.File.Exists(creator.GetPosterPath())) - { - return creator.GetPosterPath(); - } - else - { - logger.Trace("Could not find AniDB_Creator image: {0}", creator.GetPosterPath()); - return string.Empty; - } - - case ImageEntityType.TvDB_Cover: - var poster = RepoFactory.TvDB_ImagePoster.GetByID(imageId); - if (poster == null) - { - return null; - } - - if (System.IO.File.Exists(poster.GetFullImagePath())) - { - return poster.GetFullImagePath(); - } - else - { - logger.Trace("Could not find TvDB_Cover image: {0}", poster.GetFullImagePath()); - return string.Empty; - } - - case ImageEntityType.TvDB_Banner: - var wideBanner = RepoFactory.TvDB_ImageWideBanner.GetByID(imageId); - if (wideBanner == null) - { - return null; - } - - if (System.IO.File.Exists(wideBanner.GetFullImagePath())) - { - return wideBanner.GetFullImagePath(); - } - else - { - logger.Trace("Could not find TvDB_Banner image: {0}", wideBanner.GetFullImagePath()); - return string.Empty; - } - - case ImageEntityType.TvDB_Episode: - var ep = RepoFactory.TvDB_Episode.GetByID(imageId); - if (ep == null) - { - return null; - } - - if (System.IO.File.Exists(ep.GetFullImagePath())) - { - return ep.GetFullImagePath(); - } - else - { - logger.Trace("Could not find TvDB_Episode image: {0}", ep.GetFullImagePath()); - return string.Empty; - } - - case ImageEntityType.TvDB_FanArt: - var fanart = RepoFactory.TvDB_ImageFanart.GetByID(imageId); - if (fanart == null) - { - return null; - } - - if (System.IO.File.Exists(fanart.GetFullImagePath())) - { - return fanart.GetFullImagePath(); - } - - logger.Trace("Could not find TvDB_FanArt image: {0}", fanart.GetFullImagePath()); - return string.Empty; - - case ImageEntityType.MovieDB_Poster: - var mPoster = RepoFactory.MovieDB_Poster.GetByID(imageId); - if (mPoster == null) - { - return null; - } - - // now find only the original size - mPoster = RepoFactory.MovieDB_Poster.GetByOnlineID(mPoster.URL); - if (mPoster == null) - { - return null; - } - - if (System.IO.File.Exists(mPoster.GetFullImagePath())) - { - return mPoster.GetFullImagePath(); - } - else - { - logger.Trace("Could not find MovieDB_Poster image: {0}", mPoster.GetFullImagePath()); - return string.Empty; - } - - case ImageEntityType.MovieDB_FanArt: - var mFanart = RepoFactory.MovieDB_Fanart.GetByID(imageId); - if (mFanart == null) - { - return null; - } - - mFanart = RepoFactory.MovieDB_Fanart.GetByOnlineID(mFanart.URL); - if (mFanart == null) - { - return null; - } - - if (System.IO.File.Exists(mFanart.GetFullImagePath())) - { - return mFanart.GetFullImagePath(); - } - else - { - logger.Trace("Could not find MovieDB_FanArt image: {0}", mFanart.GetFullImagePath()); - return string.Empty; - } - - default: - return string.Empty; + var it = (CL_ImageEntityType)imageType; + return ImageUtils.GetImageMetadata(it, imageId) is { } metadata && metadata.IsLocalAvailable ? metadata.LocalPath! : string.Empty; + } + catch (Exception ex) + { + _logger.Error(ex, ex.ToString()); + return string.Empty; } } } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs index 9dd72d6c7..a29ee889f 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs @@ -12,7 +12,6 @@ using Shoko.Models.Enums; using Shoko.Models.Metro; using Shoko.Models.Server; -using Shoko.Models.TvDB; using Shoko.Server.Extensions; using Shoko.Server.Filters; using Shoko.Server.Models; @@ -34,14 +33,20 @@ namespace Shoko.Server; public class ShokoServiceImplementationMetro : IShokoServerMetro, IHttpContextAccessor { private readonly TraktTVHelper _traktHelper; + private readonly ShokoServiceImplementation _service; + private readonly ISettingsProvider _settingsProvider; + private readonly JobFactory _jobFactory; + private readonly WatchedStatusService _watchedService; + private readonly AnimeEpisodeService _epService; - public HttpContext HttpContext { get; set; } - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + public HttpContext HttpContext { get; set; } public ShokoServiceImplementationMetro(TraktTVHelper traktHelper, ISettingsProvider settingsProvider, ShokoServiceImplementation service, JobFactory jobFactory, WatchedStatusService watchedService, AnimeEpisodeService epService) @@ -65,26 +70,26 @@ public CL_ServerStatus GetServerStatus() var udpHandler = HttpContext.RequestServices.GetRequiredService(); contract.HashQueueCount = 0; contract.HashQueueMessage = string.Empty; - contract.HashQueueState = string.Empty; // Deprecated since 3.6.0.0 + contract.HashQueueState = string.Empty; contract.HashQueueStateId = 0; - contract.HashQueueStateParams = Array.Empty(); + contract.HashQueueStateParams = []; contract.GeneralQueueCount = 0; contract.GeneralQueueMessage = string.Empty; - contract.GeneralQueueState = string.Empty; // Deprecated since 3.6.0.0 + contract.GeneralQueueState = string.Empty; contract.GeneralQueueStateId = 0; - contract.GeneralQueueStateParams = Array.Empty(); + contract.GeneralQueueStateParams = []; contract.ImagesQueueCount = 0; contract.ImagesQueueMessage = string.Empty; - contract.ImagesQueueState = string.Empty; // Deprecated since 3.6.0.0 + contract.ImagesQueueState = string.Empty; contract.ImagesQueueStateId = 0; - contract.ImagesQueueStateParams = Array.Empty(); + contract.ImagesQueueStateParams = []; contract.IsBanned = httpHandler.IsBanned || udpHandler.IsBanned; contract.BanReason = (httpHandler.IsBanned ? httpHandler.BanTime : udpHandler.BanTime).ToString(); } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return contract; @@ -101,7 +106,7 @@ public CL_ServerSettings GetServerSettings() } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return contract; @@ -110,7 +115,7 @@ public CL_ServerSettings GetServerSettings() [HttpPost("Comment/{traktID}/{commentText}/{isSpoiler}")] public CL_Response PostCommentShow(string traktID, string commentText, bool isSpoiler) { - return _traktHelper.PostCommentShow(traktID, commentText, isSpoiler); + return new CL_Response { Result = false }; } [HttpGet("Community/Links/{animeID}")] @@ -120,7 +125,7 @@ public Metro_CommunityLinks GetCommunityLinks(int animeID) try { var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { return null; } @@ -132,25 +137,16 @@ public Metro_CommunityLinks GetCommunityLinks(int animeID) // MAL var malRef = anime.GetCrossRefMAL(); - if (malRef != null && malRef.Count > 0) + if (malRef is not null && malRef.Count > 0) { contract.MAL_ID = malRef[0].MALID.ToString(); contract.MAL_URL = string.Format(Constants.URLS.MAL_Series, malRef[0].MALID); - //contract.MAL_DiscussURL = string.Format(Constants.URLS.MAL_SeriesDiscussion, malRef[0].MALID, malRef[0].MALTitle); contract.MAL_DiscussURL = string.Format(Constants.URLS.MAL_Series, malRef[0].MALID); } - // TvDB - var tvdbRef = anime.GetCrossRefTvDB(); - if (tvdbRef != null && tvdbRef.Count > 0) - { - contract.TvDB_ID = tvdbRef[0].TvDBID.ToString(); - contract.TvDB_URL = string.Format(Constants.URLS.TvDB_Series, tvdbRef[0].TvDBID); - } - // Trakt var traktRef = anime.GetCrossRefTraktV2(); - if (traktRef != null && traktRef.Count > 0) + if (traktRef is not null && traktRef.Count > 0) { contract.Trakt_ID = traktRef[0].TraktID; contract.Trakt_URL = string.Format(Constants.URLS.Trakt_Series, traktRef[0].TraktID); @@ -158,7 +154,7 @@ public Metro_CommunityLinks GetCommunityLinks(int animeID) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return contract; @@ -173,7 +169,7 @@ public JMMUser AuthenticateUser(string username, string password) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); return null; } } @@ -188,10 +184,10 @@ public List GetAllUsers() } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } - return new List(); + return []; } [HttpGet("Group/{userID}")] @@ -206,21 +202,21 @@ public List GetAllGroups(int userID) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } - return new List(); + return []; } [NonAction] - public List GetEpisodesRecentlyAddedSummary(int maxRecords, int jmmuserID) + public List GetEpisodesRecentlyAddedSummary(int maxRecords, int userID) { var retEps = new List(); try { { - var user = RepoFactory.JMMUser.GetByID(jmmuserID); - if (user == null) + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) { return retEps; } @@ -233,7 +229,7 @@ public List GetEpisodesRecentlyAddedSummary(int maxRecords "ORDER BY MaxDate desc "; */ - var results = RepoFactory.VideoLocal.GetMostRecentlyAdded(maxRecords, jmmuserID) + var results = RepoFactory.VideoLocal.GetMostRecentlyAdded(maxRecords, userID) .SelectMany(a => a.AnimeEpisodes).GroupBy(a => a.AnimeSeriesID) .Select(a => (a.Key, a.Max(b => b.DateTimeUpdated))); @@ -241,7 +237,7 @@ public List GetEpisodesRecentlyAddedSummary(int maxRecords foreach ((var animeSeriesID, var lastUpdated) in results) { var ser = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (ser == null) + if (ser is null) { continue; } @@ -265,8 +261,8 @@ public List GetEpisodesRecentlyAddedSummary(int maxRecords continue; } - var epContract = _epService.GetV1Contract(eps[0], jmmuserID); - if (epContract != null) + var epContract = _epService.GetV1Contract(eps[0], userID); + if (epContract is not null) { retEps.Add(epContract); numEps++; @@ -282,26 +278,26 @@ public List GetEpisodesRecentlyAddedSummary(int maxRecords } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return retEps; } [HttpGet("Anime/New/{maxRecords}/{userID}")] - public List GetAnimeWithNewEpisodes(int maxRecords, int jmmuserID) + public List GetAnimeWithNewEpisodes(int maxRecords, int userID) { var retAnime = new List(); try { { - var user = RepoFactory.JMMUser.GetByID(jmmuserID); - if (user == null) + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) { return retAnime; } - var results = RepoFactory.VideoLocal.GetMostRecentlyAdded(maxRecords, jmmuserID) + var results = RepoFactory.VideoLocal.GetMostRecentlyAdded(maxRecords, userID) .SelectMany(a => a.AnimeEpisodes).GroupBy(a => a.AnimeSeriesID) .Select(a => (a.Key, a.Max(b => b.DateTimeUpdated))); @@ -309,7 +305,7 @@ public List GetAnimeWithNewEpisodes(int maxRecords, int jmm foreach ((var animeSeriesID, var lastUpdated) in results) { var ser = RepoFactory.AnimeSeries.GetByID(animeSeriesID); - if (ser == null) + if (ser is null) { continue; } @@ -319,7 +315,7 @@ public List GetAnimeWithNewEpisodes(int maxRecords, int jmm continue; } - var serUser = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(jmmuserID, ser.AnimeSeriesID); + var serUser = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(userID, ser.AnimeSeriesID); var vids = RepoFactory.VideoLocal.GetMostRecentlyAddedForAnime(1, ser.AniDB_ID); @@ -334,34 +330,34 @@ public List GetAnimeWithNewEpisodes(int maxRecords, int jmm continue; } - var epContract = _epService.GetV1Contract(eps[0], jmmuserID); - if (epContract != null) + var epContract = _epService.GetV1Contract(eps[0], userID); + if (epContract is not null) { var anidb_anime = ser.AniDB_Anime; - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { AnimeID = ser.AniDB_ID, - AnimeName = ser.SeriesName, + AnimeName = ser.PreferredTitle, AnimeSeriesID = ser.AnimeSeriesID, BeginYear = anidb_anime.BeginYear, EndYear = anidb_anime.EndYear }; - //summ.PosterName = anidb_anime.GetDefaultPosterPathNoBlanks(session); - if (serUser != null) + if (serUser is not null) { - summ.UnwatchedEpisodeCount = serUser.UnwatchedEpisodeCount; + summary.UnwatchedEpisodeCount = serUser.UnwatchedEpisodeCount; } else { - summ.UnwatchedEpisodeCount = 0; + summary.UnwatchedEpisodeCount = 0; } - var imgDet = anidb_anime.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = anidb_anime.PreferredOrDefaultPoster; + summary.PosterName = imgDet.LocalPath; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - retAnime.Add(summ); + retAnime.Add(summary); numEps++; // Lets only return the specified amount @@ -375,14 +371,14 @@ public List GetAnimeWithNewEpisodes(int maxRecords, int jmm } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return retAnime; } [NonAction] - public List GetAnimeContinueWatching_old(int maxRecords, int jmmuserID) + public List GetAnimeContinueWatching_old(int maxRecords, int userID) { var retAnime = new List(); try @@ -390,72 +386,71 @@ public List GetAnimeContinueWatching_old(int maxRecords, in { var start = DateTime.Now; - var user = RepoFactory.JMMUser.GetByID(jmmuserID); - if (user == null) + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) { return retAnime; } // get a list of series that is applicable var allSeriesUser = - RepoFactory.AnimeSeries_User.GetMostRecentlyWatched(jmmuserID); + RepoFactory.AnimeSeries_User.GetMostRecentlyWatched(userID); var ts = DateTime.Now - start; - logger.Info(string.Format("GetAnimeContinueWatching:Series: {0}", ts.TotalMilliseconds)); + _logger.Info(string.Format("GetAnimeContinueWatching:Series: {0}", ts.TotalMilliseconds)); foreach (var userRecord in allSeriesUser) { start = DateTime.Now; var series = RepoFactory.AnimeSeries.GetByID(userRecord.AnimeSeriesID); - if (series == null) + if (series is null) { continue; } if (!user.AllowedSeries(series)) { - logger.Info(string.Format("GetAnimeContinueWatching:Skipping Anime - not allowed: {0}", + _logger.Info(string.Format("GetAnimeContinueWatching:Skipping Anime - not allowed: {0}", series.AniDB_ID)); continue; } - var serUser = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(jmmuserID, series.AnimeSeriesID); + var serUser = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(userID, series.AnimeSeriesID); var ep = _service.GetNextUnwatchedEpisode(userRecord.AnimeSeriesID, - jmmuserID); - if (ep != null) + userID); + if (ep is not null) { var anidb_anime = series.AniDB_Anime; - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { AnimeID = series.AniDB_ID, - AnimeName = series.SeriesName, + AnimeName = series.PreferredTitle, AnimeSeriesID = series.AnimeSeriesID, BeginYear = anidb_anime.BeginYear, EndYear = anidb_anime.EndYear }; - //summ.PosterName = anidb_anime.GetDefaultPosterPathNoBlanks(session); - if (serUser != null) + if (serUser is not null) { - summ.UnwatchedEpisodeCount = serUser.UnwatchedEpisodeCount; + summary.UnwatchedEpisodeCount = serUser.UnwatchedEpisodeCount; } else { - summ.UnwatchedEpisodeCount = 0; + summary.UnwatchedEpisodeCount = 0; } - var imgDet = anidb_anime.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = anidb_anime.PreferredOrDefaultPoster; + summary.PosterName = imgDet.LocalPath; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - retAnime.Add(summ); + retAnime.Add(summary); ts = DateTime.Now - start; - logger.Info(string.Format("GetAnimeContinueWatching:Anime: {0} - {1}", summ.AnimeName, - ts.TotalMilliseconds)); + _logger.Info(string.Format("GetAnimeContinueWatching:Anime: {0} - {1}", summary.AnimeName, ts.TotalMilliseconds)); // Lets only return the specified amount if (retAnime.Count == maxRecords) @@ -465,7 +460,7 @@ public List GetAnimeContinueWatching_old(int maxRecords, in } else { - logger.Info(string.Format("GetAnimeContinueWatching:Skipping Anime - no episodes: {0}", + _logger.Info(string.Format("GetAnimeContinueWatching:Skipping Anime - no episodes: {0}", series.AniDB_ID)); } } @@ -473,20 +468,20 @@ public List GetAnimeContinueWatching_old(int maxRecords, in } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return retAnime; } [HttpGet("Anime/ContinueWatch/{maxRecords}/{userID}")] - public List GetAnimeContinueWatching(int maxRecords, int jmmuserID) + public List GetAnimeContinueWatching(int maxRecords, int userID) { var retAnime = new List(); try { - var user = RepoFactory.JMMUser.GetByID(jmmuserID); - if (user == null) + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) { return retAnime; } @@ -494,7 +489,7 @@ public List GetAnimeContinueWatching(int maxRecords, int jm // find the locked Continue Watching Filter FilterPreset gf = null; var lockedGFs = RepoFactory.FilterPreset.GetLockedGroupFilters(); - if (lockedGFs != null) + if (lockedGFs is not null) { // if it already exists we can leave foreach (var gfTemp in lockedGFs.Where(gfTemp => gfTemp.Name == "Continue Watching")) @@ -504,14 +499,14 @@ public List GetAnimeContinueWatching(int maxRecords, int jm } } - if (gf == null) return retAnime; + if (gf is null) return retAnime; var evaluator = HttpContext.RequestServices.GetRequiredService(); var results = evaluator.EvaluateFilter(gf, user.JMMUserID); var groupService = Utils.ServiceContainer.GetRequiredService(); - var comboGroups = results.Select(a => RepoFactory.AnimeGroup.GetByID(a.Key)).Where(a => a != null) - .Select(a => groupService.GetV1Contract(a, jmmuserID)); + var comboGroups = results.Select(a => RepoFactory.AnimeGroup.GetByID(a.Key)).Where(a => a is not null) + .Select(a => groupService.GetV1Contract(a, userID)); foreach (var grp in comboGroups) { @@ -519,37 +514,37 @@ public List GetAnimeContinueWatching(int maxRecords, int jm { if (!user.AllowedSeries(ser)) continue; - var serUser = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(jmmuserID, ser.AnimeSeriesID); + var serUser = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(userID, ser.AnimeSeriesID); - var ep = _service.GetNextUnwatchedEpisode(ser.AnimeSeriesID, jmmuserID); - if (ep != null) + var ep = _service.GetNextUnwatchedEpisode(ser.AnimeSeriesID, userID); + if (ep is not null) { var anidb_anime = ser.AniDB_Anime; - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { AnimeID = ser.AniDB_ID, - AnimeName = ser.SeriesName, + AnimeName = ser.PreferredTitle, AnimeSeriesID = ser.AnimeSeriesID, BeginYear = anidb_anime.BeginYear, - EndYear = anidb_anime.EndYear + EndYear = anidb_anime.EndYear, + PosterName = anidb_anime.PreferredOrDefaultPosterPath, }; - //summ.PosterName = anidb_anime.GetDefaultPosterPathNoBlanks(session); - if (serUser != null) + if (serUser is not null) { - summ.UnwatchedEpisodeCount = serUser.UnwatchedEpisodeCount; + summary.UnwatchedEpisodeCount = serUser.UnwatchedEpisodeCount; } else { - summ.UnwatchedEpisodeCount = 0; + summary.UnwatchedEpisodeCount = 0; } - var imgDet = anidb_anime.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = anidb_anime.PreferredOrDefaultPoster; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - retAnime.Add(summ); + retAnime.Add(summary); // Lets only return the specified amount if (retAnime.Count == maxRecords) @@ -559,7 +554,7 @@ public List GetAnimeContinueWatching(int maxRecords, int jm } else { - logger.Info(string.Format("GetAnimeContinueWatching:Skipping Anime - no episodes: {0}", + _logger.Info(string.Format("GetAnimeContinueWatching:Skipping Anime - no episodes: {0}", ser.AniDB_ID)); } } @@ -567,21 +562,20 @@ public List GetAnimeContinueWatching(int maxRecords, int jm } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return retAnime; } [HttpGet("Anime/Calendar/{userID}/{startDateSecs}/{endDateSecs}/{maxRecords}")] - public List GetAnimeCalendar(int jmmuserID, int startDateSecs, int endDateSecs, - int maxRecords) + public List GetAnimeCalendar(int userID, int startDateSecs, int endDateSecs, int maxRecords) { var retAnime = new List(); try { - var user = RepoFactory.JMMUser.GetByID(jmmuserID); - if (user == null) + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) { return retAnime; } @@ -589,9 +583,8 @@ public List GetAnimeCalendar(int jmmuserID, int startDateSe var startDate = AniDB.GetAniDBDateAsDate(startDateSecs); var endDate = AniDB.GetAniDBDateAsDate(endDateSecs); - var animes = - RepoFactory.AniDB_Anime.GetForDate(startDate.Value, endDate.Value); - foreach (var anidb_anime in animes) + var allAnime = RepoFactory.AniDB_Anime.GetForDate(startDate.Value, endDate.Value); + foreach (var anidb_anime in allAnime) { if (!user.AllowedAnime(anidb_anime)) { @@ -600,30 +593,31 @@ public List GetAnimeCalendar(int jmmuserID, int startDateSe var ser = RepoFactory.AnimeSeries.GetByAnimeID(anidb_anime.AnimeID); - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { - AirDateAsSeconds = anidb_anime.GetAirDateAsSeconds(), AnimeID = anidb_anime.AnimeID + AirDateAsSeconds = anidb_anime.GetAirDateAsSeconds(), + AnimeID = anidb_anime.AnimeID }; - if (ser != null) + if (ser is not null) { - summ.AnimeName = ser.SeriesName; - summ.AnimeSeriesID = ser.AnimeSeriesID; + summary.AnimeName = ser.PreferredTitle; + summary.AnimeSeriesID = ser.AnimeSeriesID; } else { - summ.AnimeName = anidb_anime.MainTitle; - summ.AnimeSeriesID = 0; + summary.AnimeName = anidb_anime.MainTitle; + summary.AnimeSeriesID = 0; } - summ.BeginYear = anidb_anime.BeginYear; - summ.EndYear = anidb_anime.EndYear; - summ.PosterName = anidb_anime.GetDefaultPosterPathNoBlanks(); + summary.BeginYear = anidb_anime.BeginYear; + summary.EndYear = anidb_anime.EndYear; - var imgDet = anidb_anime.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = anidb_anime.PreferredOrDefaultPoster; + summary.PosterName = imgDet.LocalPath; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - retAnime.Add(summ); + retAnime.Add(summary); if (retAnime.Count == maxRecords) { break; @@ -632,27 +626,27 @@ public List GetAnimeCalendar(int jmmuserID, int startDateSe } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return retAnime; } [HttpGet("Anime/Search/{userID}/{queryText}/{maxRecords}")] - public List SearchAnime(int jmmuserID, string queryText, int maxRecords) + public List SearchAnime(int userID, string queryText, int maxRecords) { var retAnime = new List(); try { - var user = RepoFactory.JMMUser.GetByID(jmmuserID); - if (user == null) + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) { return retAnime; } - var animes = RepoFactory.AniDB_Anime.SearchByName(queryText); - foreach (var anidb_anime in animes) + var allAnime = RepoFactory.AniDB_Anime.SearchByName(queryText); + foreach (var anidb_anime in allAnime) { if (!user.AllowedAnime(anidb_anime)) { @@ -661,30 +655,31 @@ public List SearchAnime(int jmmuserID, string queryText, in var ser = RepoFactory.AnimeSeries.GetByAnimeID(anidb_anime.AnimeID); - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { - AirDateAsSeconds = anidb_anime.GetAirDateAsSeconds(), AnimeID = anidb_anime.AnimeID + AirDateAsSeconds = anidb_anime.GetAirDateAsSeconds(), + AnimeID = anidb_anime.AnimeID }; - if (ser != null) + if (ser is not null) { - summ.AnimeName = ser.SeriesName; - summ.AnimeSeriesID = ser.AnimeSeriesID; + summary.AnimeName = ser.PreferredTitle; + summary.AnimeSeriesID = ser.AnimeSeriesID; } else { - summ.AnimeName = anidb_anime.MainTitle; - summ.AnimeSeriesID = 0; + summary.AnimeName = anidb_anime.MainTitle; + summary.AnimeSeriesID = 0; } - summ.BeginYear = anidb_anime.BeginYear; - summ.EndYear = anidb_anime.EndYear; - summ.PosterName = anidb_anime.GetDefaultPosterPathNoBlanks(); + summary.BeginYear = anidb_anime.BeginYear; + summary.EndYear = anidb_anime.EndYear; - var imgDet = anidb_anime.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = anidb_anime.PreferredOrDefaultPoster; + summary.PosterName = imgDet.LocalPath; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - retAnime.Add(summ); + retAnime.Add(summary); if (retAnime.Count == maxRecords) { break; @@ -693,19 +688,19 @@ public List SearchAnime(int jmmuserID, string queryText, in } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return retAnime; } [HttpGet("Anime/Detail/{animeID}/{userID}/{maxEpisodeRecords}")] - public Metro_Anime_Detail GetAnimeDetail(int animeID, int jmmuserID, int maxEpisodeRecords) + public Metro_Anime_Detail GetAnimeDetail(int animeID, int userID, int maxEpisodeRecords) { try { var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { return null; } @@ -713,16 +708,16 @@ public Metro_Anime_Detail GetAnimeDetail(int animeID, int jmmuserID, int maxEpis var ser = RepoFactory.AnimeSeries.GetByAnimeID(animeID); var ret = new Metro_Anime_Detail { AnimeID = anime.AnimeID }; - if (ser != null) + if (ser is not null) { - ret.AnimeName = ser.SeriesName; + ret.AnimeName = ser.PreferredTitle; } else { ret.AnimeName = anime.MainTitle; } - if (ser != null) + if (ser is not null) { ret.AnimeSeriesID = ser.AnimeSeriesID; } @@ -734,21 +729,12 @@ public Metro_Anime_Detail GetAnimeDetail(int animeID, int jmmuserID, int maxEpis ret.BeginYear = anime.BeginYear; ret.EndYear = anime.EndYear; - var imgDet = anime.GetDefaultPosterDetailsNoBlanks(); - ret.PosterImageType = (int)imgDet.ImageType; - ret.PosterImageID = imgDet.ImageID; + var imgDet = anime.PreferredOrDefaultPoster; + ret.PosterImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + ret.PosterImageID = imgDet.ID; - var imgDetFan = anime.GetDefaultFanartDetailsNoBlanks(); - if (imgDetFan != null) - { - ret.FanartImageType = (int)imgDetFan.ImageType; - ret.FanartImageID = imgDetFan.ImageID; - } - else - { - ret.FanartImageType = 0; - ret.FanartImageID = 0; - } + ret.FanartImageType = 0; + ret.FanartImageID = 0; ret.AnimeType = anime.GetAnimeTypeDescription(); ret.Description = anime.Description; @@ -761,13 +747,13 @@ public Metro_Anime_Detail GetAnimeDetail(int animeID, int jmmuserID, int maxEpis ret.OverallRating = anime.GetAniDBRating(); ret.TotalVotes = anime.GetAniDBTotalVotes(); - ret.AllTags = anime.TagsString; + ret.AllTags = string.Join('|', anime.Tags.Select(tag => tag.TagName).Distinct()); - ret.NextEpisodesToWatch = new List(); - if (ser != null) + ret.NextEpisodesToWatch = []; + if (ser is not null) { - var serUserRec = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(jmmuserID, ser.AnimeSeriesID); - if (ser != null) + var serUserRec = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(userID, ser.AnimeSeriesID); + if (ser is not null) { ret.UnwatchedEpisodeCount = serUserRec.UnwatchedEpisodeCount; } @@ -777,77 +763,66 @@ public Metro_Anime_Detail GetAnimeDetail(int animeID, int jmmuserID, int maxEpis } - var epList = new List(); - var dictEpUsers = - new Dictionary(); + var animeEpisodeList = new List(); + var dictEpUsers = new Dictionary(); foreach ( var userRecord in - RepoFactory.AnimeEpisode_User.GetByUserIDAndSeriesID(jmmuserID, ser.AnimeSeriesID)) + RepoFactory.AnimeEpisode_User.GetByUserIDAndSeriesID(userID, ser.AnimeSeriesID)) { dictEpUsers[userRecord.AnimeEpisodeID] = userRecord; } - foreach (var animeep in RepoFactory.AnimeEpisode.GetBySeriesID(ser.AnimeSeriesID)) + foreach (var animeEpisode in RepoFactory.AnimeEpisode.GetBySeriesID(ser.AnimeSeriesID)) { - if (!dictEpUsers.ContainsKey(animeep.AnimeEpisodeID)) + if (!dictEpUsers.ContainsKey(animeEpisode.AnimeEpisodeID)) { - epList.Add(animeep); + animeEpisodeList.Add(animeEpisode); continue; } - var usrRec = dictEpUsers[animeep.AnimeEpisodeID]; + var usrRec = dictEpUsers[animeEpisode.AnimeEpisodeID]; if (usrRec.WatchedCount == 0 || !usrRec.WatchedDate.HasValue) { - epList.Add(animeep); + animeEpisodeList.Add(animeEpisode); } } - var aniEpList = RepoFactory.AniDB_Episode.GetByAnimeID(ser.AniDB_ID); - var dictAniEps = new Dictionary(); - foreach (var aniep in aniEpList) + var anidbEpisodeList = RepoFactory.AniDB_Episode.GetByAnimeID(ser.AniDB_ID); + var animeEpisodeDict = new Dictionary(); + foreach (var anidbEpisode in anidbEpisodeList) { - dictAniEps[aniep.EpisodeID] = aniep; + animeEpisodeDict[anidbEpisode.EpisodeID] = anidbEpisode; } var candidateEps = new List(); - foreach (var ep in epList) + foreach (var ep in animeEpisodeList) { - if (dictAniEps.ContainsKey(ep.AniDB_EpisodeID)) + if (animeEpisodeDict.TryGetValue(ep.AniDB_EpisodeID, out var anidbEpisode)) { - var anidbep = dictAniEps[ep.AniDB_EpisodeID]; - if (anidbep.EpisodeType == (int)EpisodeType.Episode || - anidbep.EpisodeType == (int)EpisodeType.Special) + if (anidbEpisode.EpisodeTypeEnum is EpisodeType.Episode or EpisodeType.Special) { // The episode list have already been filtered to only episodes with a user record // So just add the candidate to the list. - candidateEps.Add(_epService.GetV1Contract(ep, jmmuserID)); + candidateEps.Add(_epService.GetV1Contract(ep, userID)); } } } if (candidateEps.Count > 0) { - var tvSummary = new TvDBSummary(); - tvSummary.Populate(ser.AniDB_ID); // sort by episode type and number to find the next episode - // this will generate a lot of queries when the user doesn have files + // this will generate a lot of queries when the user doesn't have files // for these episodes var cnt = 0; foreach (var canEp in candidateEps.OrderBy(a => a.EpisodeType) .ThenBy(a => a.EpisodeNumber)) { - if (dictAniEps.ContainsKey(canEp.AniDB_EpisodeID)) + if (animeEpisodeDict.TryGetValue(canEp.AniDB_EpisodeID, out var anidbEpisode)) { - var anidbep = dictAniEps[canEp.AniDB_EpisodeID]; - - SVR_AnimeEpisode_User userEpRecord = null; - if (dictEpUsers.ContainsKey(canEp.AnimeEpisodeID)) - { - userEpRecord = dictEpUsers[canEp.AnimeEpisodeID]; - } + dictEpUsers.TryGetValue(canEp.AnimeEpisodeID, out var userEpRecord); // now refresh from the database to get file count var epFresh = RepoFactory.AnimeEpisode.GetByID(canEp.AnimeEpisodeID); @@ -857,9 +832,10 @@ var userRecord in { var contract = new Metro_Anime_Episode { - AnimeEpisodeID = epFresh.AnimeEpisodeID, LocalFileCount = fileCount + AnimeEpisodeID = epFresh.AnimeEpisodeID, + LocalFileCount = fileCount }; - if (userEpRecord == null) + if (userEpRecord is null) { contract.IsWatched = false; } @@ -869,16 +845,12 @@ var userRecord in } // anidb - contract.EpisodeNumber = anidbep.EpisodeNumber; + contract.EpisodeNumber = anidbEpisode.EpisodeNumber; contract.EpisodeName = epFresh.PreferredTitle; - contract.EpisodeType = anidbep.EpisodeType; - contract.LengthSeconds = anidbep.LengthSeconds; - contract.AirDate = anidbep.GetAirDateFormatted(); - - // tvdb - SetTvDBInfo(tvSummary, anidbep, ref contract); - + contract.EpisodeType = anidbEpisode.EpisodeType; + contract.LengthSeconds = anidbEpisode.LengthSeconds; + contract.AirDate = anidbEpisode.GetAirDateFormatted(); ret.NextEpisodesToWatch.Add(contract); cnt++; @@ -897,7 +869,7 @@ var userRecord in } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); return null; } } @@ -908,85 +880,42 @@ public Metro_Anime_Summary GetAnimeSummary(int animeID) try { var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { return null; } var ser = RepoFactory.AnimeSeries.GetByAnimeID(animeID); - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { AnimeID = anime.AnimeID, - AnimeName = anime.MainTitle, + AnimeName = anime.PreferredTitle, AnimeSeriesID = 0, BeginYear = anime.BeginYear, EndYear = anime.EndYear, - PosterName = anime.GetDefaultPosterPathNoBlanks() }; - var imgDet = anime.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = anime.PreferredOrDefaultPoster; + summary.PosterName = imgDet.LocalPath; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - if (ser != null) + if (ser is not null) { - summ.AnimeName = ser.SeriesName; - summ.AnimeSeriesID = ser.AnimeSeriesID; + summary.AnimeName = ser.PreferredTitle; + summary.AnimeSeriesID = ser.AnimeSeriesID; } - return summ; + return summary; } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return null; } - [NonAction] - public static void SetTvDBInfo(SVR_AniDB_Anime anime, SVR_AniDB_Episode ep, ref Metro_Anime_Episode contract) - { - var tvSummary = new TvDBSummary(); - tvSummary.Populate(anime.AnimeID); - - SetTvDBInfo(tvSummary, ep, ref contract); - } - - [NonAction] - public static void SetTvDBInfo(int anidbid, SVR_AniDB_Episode ep, ref Metro_Anime_Episode contract) - { - var tvSummary = new TvDBSummary(); - tvSummary.Populate(anidbid); - - SetTvDBInfo(tvSummary, ep, ref contract); - } - - [NonAction] - public static void SetTvDBInfo(TvDBSummary tvSummary, SVR_AniDB_Episode ep, ref Metro_Anime_Episode contract) - { - var override_link = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBEpisodeID(ep.EpisodeID); - if (override_link.Any(a => a != null)) - { - var tvep = RepoFactory.TvDB_Episode.GetByTvDBID(override_link.FirstOrDefault().TvDBEpisodeID); - contract.EpisodeName = tvep.EpisodeName; - contract.EpisodeOverview = tvep.Overview; - contract.ImageID = tvep.Id; - contract.ImageType = (int)ImageEntityType.TvDB_Episode; - return; - } - - var link = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBEpisodeID(ep.EpisodeID); - if (link.Any(a => a != null)) - { - var tvep = RepoFactory.TvDB_Episode.GetByTvDBID(link.FirstOrDefault().TvDBEpisodeID); - contract.EpisodeName = tvep.EpisodeName; - contract.EpisodeOverview = tvep.Overview; - contract.ImageID = tvep.Id; - contract.ImageType = (int)ImageEntityType.TvDB_Episode; - } - } - [HttpGet("Anime/Character/{animeID}/{maxRecords}")] public List GetCharactersForAnime(int animeID, int maxRecords) { @@ -994,63 +923,37 @@ public List GetCharactersForAnime(int animeID, int maxRec try { - var animeChars = - RepoFactory.AniDB_Anime_Character.GetByAnimeID(animeID); - if (animeChars == null || animeChars.Count == 0) + var animeChars = RepoFactory.AniDB_Anime_Character.GetByAnimeID(animeID) + .OrderByDescending(item => item.CharType.Equals("main character in", StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + if (animeChars.Count == 0) { return chars; } - var cnt = 0; + var index = 0; // first get all the main characters - foreach ( - var animeChar in - animeChars.Where( - item => - item.CharType.Equals("main character in", - StringComparison.InvariantCultureIgnoreCase))) + foreach (var animeChar in animeChars) { - cnt++; - var chr = RepoFactory.AniDB_Character.GetByID(animeChar.CharID); - if (chr != null) + index++; + var character = RepoFactory.AniDB_Character.GetByID(animeChar.CharID); + if (character is not null) { var contract = new Metro_AniDB_Character(); - chars.Add(chr.ToContractMetro(animeChar)); + chars.Add(character.ToContractMetro(animeChar)); } - if (cnt == maxRecords) + if (index == maxRecords) { break; } } - // now get the rest - foreach ( - var animeChar in - animeChars.Where( - item => - !item.CharType.Equals("main character in", - StringComparison.InvariantCultureIgnoreCase)) - ) - { - cnt++; - var chr = RepoFactory.AniDB_Character.GetByID(animeChar.CharID); - if (chr != null) - { - var contract = new Metro_AniDB_Character(); - chars.Add(chr.ToContractMetro(animeChar)); - } - - if (cnt == maxRecords) - { - break; - } - } } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return chars; @@ -1059,30 +962,30 @@ var animeChar in [HttpGet("Anime/Comment/{animeID}/{maxRecords}")] public List GetTraktCommentsForAnime(int animeID, int maxRecords) { - return new List(); + return []; } [HttpGet("Anime/Recommendation/{animeID}/{maxRecords}")] public List GetAniDBRecommendationsForAnime(int animeID, int maxRecords) { - return new List(); + return []; } [HttpGet("Anime/Similar/{animeID}/{maxRecords}/{userID}")] - public List GetSimilarAnimeForAnime(int animeID, int maxRecords, int jmmuserID) + public List GetSimilarAnimeForAnime(int animeID, int maxRecords, int userID) { var links = new List(); var retAnime = new List(); try { var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { return retAnime; } - var juser = RepoFactory.JMMUser.GetByID(jmmuserID); - if (juser == null) + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) { return retAnime; } @@ -1093,24 +996,24 @@ public List GetSimilarAnimeForAnime(int animeID, int maxRec { var animeLink = RepoFactory.AniDB_Anime.GetByAnimeID(link.RelatedAnimeID); - if (animeLink == null) + if (animeLink is null) { // try getting it from anidb now - var command = _jobFactory.CreateJob(c => + var job = _jobFactory.CreateJob(c => { c.DownloadRelations = false; c.AnimeID = link.RelatedAnimeID; c.CreateSeriesEntry = false; }); - animeLink = command.Process().Result; + animeLink = job.Process().Result; } - if (animeLink == null) + if (animeLink is null) { continue; } - if (!juser.AllowedAnime(animeLink)) + if (!user.AllowedAnime(animeLink)) { continue; } @@ -1118,28 +1021,28 @@ public List GetSimilarAnimeForAnime(int animeID, int maxRec // check if this anime has a series var ser = RepoFactory.AnimeSeries.GetByAnimeID(link.RelatedAnimeID); - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { AnimeID = animeLink.AnimeID, AnimeName = animeLink.MainTitle, AnimeSeriesID = 0, BeginYear = animeLink.BeginYear, EndYear = animeLink.EndYear, - //summ.PosterName = animeLink.GetDefaultPosterPathNoBlanks(session); RelationshipType = link.RelationType }; - var imgDet = animeLink.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = animeLink.PreferredOrDefaultPoster; + summary.PosterName = imgDet.LocalPath; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - if (ser != null) + if (ser is not null) { - summ.AnimeName = ser.SeriesName; - summ.AnimeSeriesID = ser.AnimeSeriesID; + summary.AnimeName = ser.PreferredTitle; + summary.AnimeSeriesID = ser.AnimeSeriesID; } - retAnime.Add(summ); + retAnime.Add(summary); } // now get similar anime @@ -1148,10 +1051,10 @@ public List GetSimilarAnimeForAnime(int animeID, int maxRec var animeLink = RepoFactory.AniDB_Anime.GetByAnimeID(link.SimilarAnimeID); - if (animeLink == null) + if (animeLink is null) { // try getting it from anidb now - var command = _jobFactory.CreateJob( + var job = _jobFactory.CreateJob( c => { c.DownloadRelations = false; @@ -1160,15 +1063,15 @@ public List GetSimilarAnimeForAnime(int animeID, int maxRec } ); - animeLink = command.Process().Result; + animeLink = job.Process().Result; } - if (animeLink == null) + if (animeLink is null) { continue; } - if (!juser.AllowedAnime(animeLink)) + if (!user.AllowedAnime(animeLink)) { continue; } @@ -1176,28 +1079,27 @@ public List GetSimilarAnimeForAnime(int animeID, int maxRec // check if this anime has a series var ser = RepoFactory.AnimeSeries.GetByAnimeID(link.SimilarAnimeID); - var summ = new Metro_Anime_Summary + var summary = new Metro_Anime_Summary { AnimeID = animeLink.AnimeID, AnimeName = animeLink.MainTitle, AnimeSeriesID = 0, BeginYear = animeLink.BeginYear, EndYear = animeLink.EndYear, - //summ.PosterName = animeLink.GetDefaultPosterPathNoBlanks(session); - RelationshipType = "Recommendation" }; - var imgDet = animeLink.GetDefaultPosterDetailsNoBlanks(); - summ.ImageType = (int)imgDet.ImageType; - summ.ImageID = imgDet.ImageID; + var imgDet = animeLink.PreferredOrDefaultPoster; + summary.PosterName = imgDet.LocalPath; + summary.ImageType = (int)imgDet.ImageType.ToClient(imgDet.Source); + summary.ImageID = imgDet.ID; - if (ser != null) + if (ser is not null) { - summ.AnimeName = ser.SeriesName; - summ.AnimeSeriesID = ser.AnimeSeriesID; + summary.AnimeName = ser.PreferredTitle; + summary.AnimeSeriesID = ser.AnimeSeriesID; } - retAnime.Add(summ); + retAnime.Add(summary); if (retAnime.Count == maxRecords) { @@ -1207,7 +1109,7 @@ public List GetSimilarAnimeForAnime(int animeID, int maxRec } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return retAnime; @@ -1219,28 +1121,27 @@ public List GetFilesForEpisode(int episodeID, int userID) try { var ep = RepoFactory.AnimeEpisode.GetByID(episodeID); - return ep != null + return ep is not null ? _epService.GetV1VideoDetailedContracts(ep, userID) - : new List(); + : []; } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } - return new List(); + return []; } [HttpGet("Episode/Watch/{animeEpisodeID}/{watchedStatus}/{userID}")] public CL_Response ToggleWatchedStatusOnEpisode(int animeEpisodeID, bool watchedStatus, int userID) { - var response = - new CL_Response { ErrorMessage = string.Empty, Result = null }; + var response = new CL_Response { ErrorMessage = string.Empty, Result = null }; try { var ep = RepoFactory.AnimeEpisode.GetByID(animeEpisodeID); - if (ep == null) + if (ep is null) { response.ErrorMessage = "Could not find anime episode record"; return response; @@ -1262,7 +1163,7 @@ public CL_Response ToggleWatchedStatusOnEpisode(int animeE } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); response.ErrorMessage = ex.Message; return response; } @@ -1273,18 +1174,18 @@ public string UpdateAnimeData(int animeID) { try { - var command = _jobFactory.CreateJob(c => + var job = _jobFactory.CreateJob(c => { c.ForceRefresh = true; c.DownloadRelations = false; c.AnimeID = animeID; c.CreateSeriesEntry = false; }); - command.Process().GetAwaiter().GetResult(); + job.Process().GetAwaiter().GetResult(); } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.Error(ex, ex.ToString()); } return string.Empty; diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationPlex.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationPlex.cs index 86eca15b5..18be2da0e 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationPlex.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationPlex.cs @@ -30,7 +30,7 @@ public PlexContract_Users GetUsers() { var gfs = new PlexContract_Users { - Users = new List() + Users = [] }; foreach (var us in RepoFactory.JMMUser.GetAll()) { @@ -53,7 +53,7 @@ public void UseDirectories(int userId, List directories) var settings = _settingsProvider.GetSettings(); if (directories == null) { - settings.Plex.Libraries = new List(); + settings.Plex.Libraries = []; return; } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs index 9f6c2ce94..fd8e6b03f 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs @@ -27,51 +27,50 @@ public class ShokoServiceImplementationStream : Controller, IShokoServerStream, { public new HttpContext HttpContext { get; set; } - //89% Should be enough to not touch matroska offsets and give us some margin - private double WatchedThreshold = 0.89; + //89% Should be enough to not touch mkv offsets and give us some margin + private readonly double _watchedThreshold = 0.89; + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); public const string SERVER_VERSION = "Shoko Stream Server 1.0"; - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - [HttpGet("{videolocalid}/{userId?}/{autowatch?}/{fakename?}")] + [HttpGet("{videoLocalId}/{userId?}/{autoWatch?}/{fakeName?}")] [ProducesResponseType(typeof(FileStreamResult), 200)] [ProducesResponseType(typeof(FileStreamResult), 206)] [ProducesResponseType(404)] - public object StreamVideo(int videolocalid, int? userId, bool? autowatch, string fakename) + public object StreamVideo(int videoLocalId, int? userId, bool? autoWatch, string fakeName) { - var r = ResolveVideoLocal(videolocalid, userId, autowatch); + var r = ResolveVideoLocal(videoLocalId, userId, autoWatch); if (r.Status != HttpStatusCode.OK && r.Status != HttpStatusCode.PartialContent) return StatusCode((int)r.Status, r.StatusDescription); - if (!string.IsNullOrEmpty(fakename)) return StreamFromIFile(r, autowatch); + if (!string.IsNullOrEmpty(fakeName)) return StreamInfoResult(r, autoWatch); var subs = r.VideoLocal.MediaInfo.TextStreams.Where(a => a.External).ToList(); - if (!subs.Any()) return StatusCode(404); + if (subs.Count == 0) return StatusCode(404); return "" + string.Join(string.Empty, subs.Select(a => "")) + "
"; } - [HttpGet("Filename/{base64filename}/{userId?}/{autowatch?}/{fakename?}")] + [HttpGet("Filename/{base64filename}/{userId?}/{autoWatch?}/{fakeName?}")] [ProducesResponseType(typeof(FileStreamResult), 200)] [ProducesResponseType(typeof(FileStreamResult), 206)] [ProducesResponseType(404)] - public object StreamVideoFromFilename(string base64filename, int? userId, bool? autowatch, string fakename) + public object StreamVideoFromFilename(string base64filename, int? userId, bool? autoWatch, string fakeName) { - var r = ResolveFilename(base64filename, userId, autowatch); + var r = ResolveFilename(base64filename, userId, autoWatch); if (r.Status != HttpStatusCode.OK && r.Status != HttpStatusCode.PartialContent) { return StatusCode((int)r.Status, r.StatusDescription); } - return StreamFromIFile(r, autowatch); + return StreamInfoResult(r, autoWatch); } - private object StreamFromIFile(InfoResult r, bool? autowatch) + [NonAction] + private object StreamInfoResult(InfoResult r, bool? autoWatch) { try { - var rangevalue = Request.Headers["Range"].FirstOrDefault() ?? - Request.Headers["range"].FirstOrDefault(); - - + var rangeValue = Request.Headers.Range.FirstOrDefault(); Stream fr = null; string error = null; try @@ -80,7 +79,7 @@ private object StreamFromIFile(InfoResult r, bool? autowatch) } catch (Exception e) { - Logger.Error(e); + _logger.Error(e); error = e.ToString(); } @@ -90,64 +89,64 @@ private object StreamFromIFile(InfoResult r, bool? autowatch) "Unable to open file '" + r.File?.FullName + "': " + error); } - var totalsize = fr.Length; + var totalSize = fr.Length; long start = 0; - var end = totalsize - 1; + var end = totalSize - 1; - rangevalue = rangevalue?.Replace("bytes=", string.Empty); - var range = !string.IsNullOrEmpty(rangevalue); + rangeValue = rangeValue?.Replace("bytes=", string.Empty); + var range = !string.IsNullOrEmpty(rangeValue); if (range) { // range: bytes=split[0]-split[1] - var split = rangevalue.Split('-'); + var split = rangeValue.Split('-'); if (split.Length == 2) { // bytes=-split[1] - tail of specified length if (string.IsNullOrEmpty(split[0]) && !string.IsNullOrEmpty(split[1])) { var e = long.Parse(split[1]); - start = totalsize - e; - end = totalsize - 1; + start = totalSize - e; + end = totalSize - 1; } // bytes=split[0] - split[0] to end of file else if (!string.IsNullOrEmpty(split[0]) && string.IsNullOrEmpty(split[1])) { start = long.Parse(split[0]); - end = totalsize - 1; + end = totalSize - 1; } // bytes=split[0]-split[1] - specified beginning and end else if (!string.IsNullOrEmpty(split[0]) && !string.IsNullOrEmpty(split[1])) { start = long.Parse(split[0]); end = long.Parse(split[1]); - if (start > totalsize - 1) + if (start > totalSize - 1) { - start = totalsize - 1; + start = totalSize - 1; } - if (end > totalsize - 1) + if (end > totalSize - 1) { - end = totalsize - 1; + end = totalSize - 1; } } } } Response.ContentType = r.Mime; - Response.Headers.Add("Server", SERVER_VERSION); - Response.Headers.Add("Connection", "keep-alive"); - Response.Headers.Add("Accept-Ranges", "bytes"); - Response.Headers.Add("Content-Range", "bytes " + start + "-" + end + "/" + totalsize); + Response.Headers.Append("Server", SERVER_VERSION); + Response.Headers.Append("Connection", "keep-alive"); + Response.Headers.Append("Accept-Ranges", "bytes"); + Response.Headers.Append("Content-Range", "bytes " + start + "-" + end + "/" + totalSize); Response.ContentLength = end - start + 1; Response.StatusCode = (int)(range ? HttpStatusCode.PartialContent : HttpStatusCode.OK); - var outstream = new SubStream(fr, start, end - start + 1); - if (r.User != null && autowatch.HasValue && autowatch.Value && r.VideoLocal != null) + var outStream = new SubStream(fr, start, end - start + 1); + if (r.User != null && autoWatch.HasValue && autoWatch.Value && r.VideoLocal != null) { - outstream.CrossPosition = (long)(totalsize * WatchedThreshold); - outstream.CrossPositionCrossed += + outStream.CrossPosition = (long)(totalSize * _watchedThreshold); + outStream.CrossPositionCrossed += a => { Task.Factory.StartNew(async () => @@ -160,45 +159,45 @@ private object StreamFromIFile(InfoResult r, bool? autowatch) }; } - return outstream; + return outStream; } catch (Exception e) { - Logger.Error("An error occurred while serving a file: " + e); + _logger.Error("An error occurred while serving a file: " + e); return StatusCode(500, e.Message); } } - [HttpHead("{videolocalid}/{userId?}/{autowatch?}/{fakename?}")] - public object InfoVideo(int videolocalid, int? userId, bool? autowatch, string fakename) + [HttpHead("{videoLocalId}/{userId?}/{autoWatch?}/{fakeName?}")] + public object InfoVideo(int videoLocalId, int? userId, bool? autoWatch, string fakeName) { - var r = ResolveVideoLocal(videolocalid, userId, autowatch); + var r = ResolveVideoLocal(videoLocalId, userId, autoWatch); if (r.Status != HttpStatusCode.OK && r.Status != HttpStatusCode.PartialContent) { return StatusCode((int)r.Status, r.StatusDescription); } - Response.Headers.Add("Server", SERVER_VERSION); - Response.Headers.Add("Accept-Ranges", "bytes"); - Response.Headers.Add("Content-Range", "bytes 0-" + (r.File.Length - 1) + "/" + r.File.Length); + Response.Headers.Append("Server", SERVER_VERSION); + Response.Headers.Append("Accept-Ranges", "bytes"); + Response.Headers.Append("Content-Range", "bytes 0-" + (r.File.Length - 1) + "/" + r.File.Length); Response.ContentType = r.Mime; Response.ContentLength = r.File.Length; Response.StatusCode = (int)r.Status; return Ok(); } - [HttpHead("Filename/{base64filename}/{userId?}/{autowatch?}/{fakename?}")] - public object InfoVideoFromFilename(string base64filename, int? userId, bool? autowatch, string fakename) + [HttpHead("Filename/{base64filename}/{userId?}/{autoWatch?}/{fakeName?}")] + public object InfoVideoFromFilename(string base64filename, int? userId, bool? autoWatch, string fakeName) { - var r = ResolveFilename(base64filename, userId, autowatch); + var r = ResolveFilename(base64filename, userId, autoWatch); if (r.Status != HttpStatusCode.OK && r.Status != HttpStatusCode.PartialContent) { return StatusCode((int)r.Status, r.StatusDescription); } - Response.Headers.Add("Server", SERVER_VERSION); - Response.Headers.Add("Accept-Ranges", "bytes"); - Response.Headers.Add("Content-Range", "bytes 0-" + (r.File.Length - 1) + "/" + r.File.Length); + Response.Headers.Append("Server", SERVER_VERSION); + Response.Headers.Append("Accept-Ranges", "bytes"); + Response.Headers.Append("Content-Range", "bytes 0-" + (r.File.Length - 1) + "/" + r.File.Length); Response.ContentType = r.Mime; Response.ContentLength = r.File.Length; Response.StatusCode = (int)r.Status; @@ -215,10 +214,10 @@ private class InfoResult public string Mime { get; set; } } - private InfoResult ResolveVideoLocal(int videolocalid, int? userId, bool? autowatch) + private static InfoResult ResolveVideoLocal(int videoLocalId, int? userId, bool? autoWatch) { var r = new InfoResult(); - var loc = RepoFactory.VideoLocal.GetByID(videolocalid); + var loc = RepoFactory.VideoLocal.GetByID(videoLocalId); if (loc == null) { r.Status = HttpStatusCode.BadRequest; @@ -228,19 +227,16 @@ private InfoResult ResolveVideoLocal(int videolocalid, int? userId, bool? autowa r.VideoLocal = loc; r.File = loc.FirstResolvedPlace?.GetFile(); - return FinishResolve(r, userId, autowatch); + return FinishResolve(r, userId, autoWatch); } public static string Base64DecodeUrl(string base64EncodedData) { - var base64EncodedBytes = - Convert.FromBase64String(base64EncodedData.Replace("-", "+") - .Replace("_", "/") - .Replace(",", "=")); + var base64EncodedBytes = Convert.FromBase64String(base64EncodedData.Replace("-", "+").Replace("_", "/").Replace(",", "=")); return Encoding.UTF8.GetString(base64EncodedBytes); } - private InfoResult FinishResolve(InfoResult r, int? userId, bool? autowatch) + private static InfoResult FinishResolve(InfoResult r, int? userId, bool? autoWatch) { if (r.File == null) { @@ -249,7 +245,7 @@ private InfoResult FinishResolve(InfoResult r, int? userId, bool? autowatch) return r; } - if (userId.HasValue && autowatch.HasValue && userId.Value != 0) + if (userId.HasValue && autoWatch.HasValue && userId.Value != 0) { r.User = RepoFactory.JMMUser.GetByID(userId.Value); if (r.User == null) @@ -265,12 +261,12 @@ private InfoResult FinishResolve(InfoResult r, int? userId, bool? autowatch) return r; } - private InfoResult ResolveFilename(string filenamebase64, int? userId, bool? autowatch) + private static InfoResult ResolveFilename(string base64, int? userId, bool? autoWatch) { var r = new InfoResult(); - var fullname = Base64DecodeUrl(filenamebase64); + var fullName = Base64DecodeUrl(base64); r.VideoLocal = null; - r.File = new FileInfo(fullname); - return FinishResolve(r, userId, autowatch); + r.File = new FileInfo(fullName); + return FinishResolve(r, userId, autoWatch); } } diff --git a/Shoko.Server/API/v2/Models/common/Art.cs b/Shoko.Server/API/v2/Models/common/Art.cs index f709b6505..6b4e9a97c 100644 --- a/Shoko.Server/API/v2/Models/common/Art.cs +++ b/Shoko.Server/API/v2/Models/common/Art.cs @@ -16,9 +16,9 @@ public class ArtCollection public ArtCollection() { - banner = new List(); - fanart = new List(); - thumb = new List(); + banner = []; + fanart = []; + thumb = []; } } diff --git a/Shoko.Server/API/v2/Models/common/Episode.cs b/Shoko.Server/API/v2/Models/common/Episode.cs index e189149a1..acbe75ccc 100644 --- a/Shoko.Server/API/v2/Models/common/Episode.cs +++ b/Shoko.Server/API/v2/Models/common/Episode.cs @@ -1,13 +1,15 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Runtime.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Shoko.Commons.Utils; -using Shoko.Models.Enums; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Extensions; using Shoko.Server.Models; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Services; using Shoko.Server.Utilities; @@ -60,17 +62,17 @@ internal static Episode GenerateFromAnimeEpisode(HttpContext ctx, SVR_AnimeEpiso { var ep = new Episode { id = aep.AnimeEpisodeID, art = new ArtCollection() }; - if (aep.AniDB_Episode != null) + if (aep.AniDB_Episode is { } anidbEpisode) { - ep.eptype = aep.EpisodeTypeEnum.ToString(); - ep.aid = aep.AniDB_Episode.AnimeID; - ep.eid = aep.AniDB_Episode.EpisodeID; + ep.eptype = anidbEpisode.EpisodeTypeEnum.ToString(); + ep.aid = anidbEpisode.AnimeID; + ep.eid = anidbEpisode.EpisodeID; } - var userrating = aep.UserRating; - if (userrating > 0) + var userRating = aep.UserRating; + if (userRating > 0) { - ep.userrating = userrating.ToString(CultureInfo.InvariantCulture); + ep.userrating = userRating.ToString(CultureInfo.InvariantCulture); } if (double.TryParse(ep.rating, out var rating)) @@ -83,8 +85,7 @@ internal static Episode GenerateFromAnimeEpisode(HttpContext ctx, SVR_AnimeEpiso } var epService = Utils.ServiceContainer.GetRequiredService(); - var cae = epService.GetV1Contract(aep, uid); - if (cae != null) + if (epService.GetV1Contract(aep, uid) is { } cae) { ep.name = cae.AniDB_EnglishName; ep.summary = cae.Description; @@ -100,65 +101,57 @@ internal static Episode GenerateFromAnimeEpisode(HttpContext ctx, SVR_AnimeEpiso ep.epnumber = cae.EpisodeNumber; } - var tvep = aep.TvDBEpisode; - - if (tvep != null) + // Grab thumbnails/backdrops from first available source. + if (pic > 0 && aep.GetImages() is { } tmdbImages && tmdbImages.Count > 0) { - if (!string.IsNullOrEmpty(tvep.EpisodeName)) + var thumbnail = tmdbImages + .Where(image => image.ImageType == ImageEntityType.Thumbnail && image.IsLocalAvailable) + .OrderByDescending(image => image.IsPreferred) + .FirstOrDefault(); + var backdrop = tmdbImages.Where(image => image.ImageType == ImageEntityType.Backdrop && image.IsLocalAvailable).GetRandomElement() ?? + aep.AnimeSeries?.GetImages(ImageEntityType.Backdrop).Where(image => image.IsLocalAvailable).GetRandomElement(); + if (thumbnail is not null) { - ep.name = tvep.EpisodeName; + backdrop ??= thumbnail; + ep.art.thumb.Add(new Art + { + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, thumbnail.ImageType, thumbnail.Source, thumbnail.ID), + }); } - - if (pic > 0) + if (backdrop is not null) { - if (Misc.IsImageValid(tvep.GetFullImagePath())) + ep.art.fanart.Add(new Art { - ep.art.thumb.Add(new Art - { - index = 0, - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_Episode, - tvep.Id) - }); - } + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, backdrop.ImageType, backdrop.Source, backdrop.ID), + }); + } + } - var fanarts = aep.AnimeSeries?.AniDB_Anime?.AllFanarts; - if (fanarts is { Count: > 0 }) - { - var cont_image = - fanarts[new Random().Next(fanarts.Count)]; - ep.art.fanart.Add(new Art - { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, cont_image.ImageType, - cont_image.AniDB_Anime_DefaultImageID), - index = 0 - }); - } - else - { - ep.art.fanart.Add(new Art - { - index = 0, - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_Episode, - tvep.Id) - }); - } + if (aep.TmdbEpisodes is { Count: > 0 } tmdbEpisodes) + { + var tmdbEpisode = tmdbEpisodes[0]; + if (!string.IsNullOrEmpty(tmdbEpisode.EnglishTitle)) + { + ep.name = tmdbEpisode.EnglishTitle; } - if (!string.IsNullOrEmpty(tvep.Overview)) + if (!string.IsNullOrEmpty(tmdbEpisode.EnglishOverview)) { - ep.summary = tvep.Overview; + ep.summary = tmdbEpisode.EnglishOverview; } - var zeroPadding = tvep.EpisodeNumber.ToString().Length; - var episodeNumber = tvep.EpisodeNumber.ToString().PadLeft(zeroPadding, '0'); - zeroPadding = tvep.SeasonNumber.ToString().Length; - var seasonNumber = tvep.SeasonNumber.ToString().PadLeft(zeroPadding, '0'); + var zeroPadding = tmdbEpisode.EpisodeNumber.ToString().Length; + var episodeNumber = tmdbEpisode.EpisodeNumber.ToString().PadLeft(zeroPadding, '0'); + zeroPadding = tmdbEpisode.SeasonNumber.ToString().Length; + var seasonNumber = tmdbEpisode.SeasonNumber.ToString().PadLeft(zeroPadding, '0'); ep.season = $"{seasonNumber}x{episodeNumber}"; - var airdate = tvep.AirDate; + var airdate = tmdbEpisode.AiredAt; if (airdate != null) { - ep.air = airdate.Value.ToISO8601Date(); + ep.air = airdate.Value.ToDateTime().ToISO8601Date(); ep.year = airdate.Value.Year.ToString(CultureInfo.InvariantCulture); } } @@ -170,8 +163,7 @@ internal static Episode GenerateFromAnimeEpisode(HttpContext ctx, SVR_AnimeEpiso if (pic > 0 && ep.art.thumb.Count == 0) { - ep.art.thumb.Add( - new Art { index = 0, url = APIV2Helper.ConstructSupportImageLink(ctx, "plex_404.png") }); + ep.art.thumb.Add(new Art { index = 0, url = APIV2Helper.ConstructSupportImageLink(ctx, "plex_404.png") }); ep.art.fanart.Add(new Art { index = 0, url = APIV2Helper.ConstructSupportImageLink(ctx, "plex_404.png") }); } diff --git a/Shoko.Server/API/v2/Models/common/Filter.cs b/Shoko.Server/API/v2/Models/common/Filter.cs index 928cf6892..54d89705e 100644 --- a/Shoko.Server/API/v2/Models/common/Filter.cs +++ b/Shoko.Server/API/v2/Models/common/Filter.cs @@ -1,15 +1,18 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Filters; using Shoko.Server.Models; using Shoko.Server.Repositories; +#nullable enable namespace Shoko.Server.API.v2.Models.common; [DataContract] @@ -34,9 +37,9 @@ public Filter() groups = new List(); } - internal static Filter GenerateFromGroupFilter(HttpContext ctx, FilterPreset gf, int uid, bool nocast, - bool notag, int level, - bool all, bool allpic, int pic, TagFilter.Filter tagfilter, List> evaluatedResults = null) + internal static Filter GenerateFromGroupFilter(HttpContext ctx, FilterPreset gf, int uid, bool noCast, + bool noTag, int level, + bool all, bool allPic, int pic, TagFilter.Filter tagFilter, List>? evaluatedResults = null) { var groups = new List(); var filter = new Filter { name = gf.Name, id = gf.FilterPresetID, size = 0 }; @@ -52,16 +55,12 @@ internal static Filter GenerateFromGroupFilter(HttpContext ctx, FilterPreset gf, // Populate Random Art - List arts = null; - var seriesList = evaluatedResults.SelectMany(a => a).Select(RepoFactory.AnimeSeries.GetByID).ToList(); - var groupsList = evaluatedResults.Select(r => RepoFactory.AnimeGroup.GetByID(r.Key)).ToList(); + List? arts = null; + var seriesList = evaluatedResults.SelectMany(a => a).Select(RepoFactory.AnimeSeries.GetByID).WhereNotNull().ToList(); + var groupsList = evaluatedResults.Select(r => RepoFactory.AnimeGroup.GetByID(r.Key)).WhereNotNull().ToList(); if (pic == 1) { - arts = seriesList.Where(SeriesHasCompleteArt).Where(a => a != null).ToList(); - if (arts.Count == 0) - { - arts = seriesList.Where(SeriesHasMostlyCompleteArt).Where(a => a != null).ToList(); - } + arts = seriesList.Where(SeriesHasArt).ToList(); if (arts.Count == 0) { @@ -73,44 +72,27 @@ internal static Filter GenerateFromGroupFilter(HttpContext ctx, FilterPreset gf, { var rand = new Random(); var anime = arts[rand.Next(arts.Count)]; - - var fanarts = GetFanartFromSeries(anime); - if (fanarts.Any()) + var backdrops = anime.GetImages(ImageEntityType.Backdrop); + if (backdrops.Count > 0) { - var fanart = fanarts[rand.Next(fanarts.Count)]; + var backdrop = backdrops[rand.Next(backdrops.Count)]; filter.art.fanart.Add(new Art { index = 0, - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_FanArt, - fanart.TvDB_ImageFanartID) - }); - } - - var banners = GetBannersFromSeries(anime); - if (banners.Any()) - { - var banner = banners[rand.Next(banners.Count)]; - filter.art.banner.Add(new Art - { - index = 0, - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_Banner, - banner.TvDB_ImageWideBannerID) + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, backdrop.ImageType, backdrop.Source, backdrop.ID), }); } filter.art.thumb.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.AniDB_Cover, - anime.AniDB_ID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, ImageEntityType.Poster, DataSourceEnum.AniDB, anime.AniDB_ID), }); } if (level > 0) { - groups.AddRange(groupsList.Select(ag => - Group.GenerateFromAnimeGroup(ctx, ag, uid, nocast, notag, level - 1, all, filter.id, allpic, pic, tagfilter, - evaluatedResults?.FirstOrDefault(a => a.Key == ag.AnimeGroupID)?.ToList()))); + groups.AddRange(groupsList.Select(ag => Group.GenerateFromAnimeGroup(ctx, ag, uid, noCast, noTag, level - 1, all, filter.id, allPic, pic, tagFilter, evaluatedResults?.FirstOrDefault(a => a.Key == ag.AnimeGroupID)?.ToList()))); } if (groups.Count > 0) @@ -125,54 +107,8 @@ internal static Filter GenerateFromGroupFilter(HttpContext ctx, FilterPreset gf, return filter; } - private static bool SeriesHasCompleteArt(SVR_AnimeSeries series) - { - var anime = series?.AniDB_Anime; - if (anime == null) - { - return false; - } - - var tvdbIDs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(anime.AnimeID).ToList(); - if (!tvdbIDs.Any(a => RepoFactory.TvDB_ImageFanart.GetBySeriesID(a.TvDBID).Any())) - { - return false; - } - - if (!tvdbIDs.Any(a => RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(a.TvDBID).Any())) - { - return false; - } - - return true; - } - - private static bool SeriesHasMostlyCompleteArt(SVR_AnimeSeries series) - { - var anime = series?.AniDB_Anime; - if (anime == null) - { - return false; - } - - var tvdbIDs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(anime.AnimeID).ToList(); - if (!tvdbIDs.Any(a => RepoFactory.TvDB_ImageFanart.GetBySeriesID(a.TvDBID).Any())) - { - return false; - } - - return true; - } - - private static List GetFanartFromSeries(SVR_AnimeSeries ser) - { - var tvdbIDs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(ser.AniDB_ID).ToList(); - return tvdbIDs.SelectMany(a => RepoFactory.TvDB_ImageFanart.GetBySeriesID(a.TvDBID)).ToList(); - } - - private static List GetBannersFromSeries(SVR_AnimeSeries ser) + private static bool SeriesHasArt(SVR_AnimeSeries series) { - var tvdbIDs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(ser.AniDB_ID).ToList(); - return tvdbIDs.SelectMany(a => RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(a.TvDBID)).ToList(); + return series.GetImages(ImageEntityType.Backdrop).Count is > 0; } } diff --git a/Shoko.Server/API/v2/Models/common/Group.cs b/Shoko.Server/API/v2/Models/common/Group.cs index dad2a23a4..9dd38c356 100644 --- a/Shoko.Server/API/v2/Models/common/Group.cs +++ b/Shoko.Server/API/v2/Models/common/Group.cs @@ -5,8 +5,10 @@ using System.Runtime.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Models.PlexAndKodi; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Extensions; using Shoko.Server.Filters; using Shoko.Server.Models; @@ -25,56 +27,58 @@ public class Group : BaseDirectory public Group() { - series = new List(); - art = new ArtCollection(); - tags = new List(); - roles = new List(); + series = []; + art = new(); + tags = []; + roles = []; } - public static Group GenerateFromAnimeGroup(HttpContext ctx, SVR_AnimeGroup ag, int uid, bool nocast, bool notag, - int level, - bool all, int filterid, bool allpic, int pic, TagFilter.Filter tagfilter, List evaluatedSeriesIDs = null) + public static Group GenerateFromAnimeGroup(HttpContext ctx, SVR_AnimeGroup ag, int uid, bool noCast, bool noTag, int level, + bool all, int filterID, bool allPic, int pic, TagFilter.Filter tagFilter, List evaluatedSeriesIDs = null) { var g = new Group { name = ag.GroupName, id = ag.AnimeGroupID, - - //g.videoqualities = ag.VideoQualities; <-- deadly trap added = ag.DateTimeCreated, edited = ag.DateTimeUpdated }; - if (filterid > 0 && evaluatedSeriesIDs == null) + if (filterID > 0 && evaluatedSeriesIDs == null) { - var filter = RepoFactory.FilterPreset.GetByID(filterid); + var filter = RepoFactory.FilterPreset.GetByID(filterID); var evaluator = ctx.RequestServices.GetRequiredService(); evaluatedSeriesIDs = evaluator.EvaluateFilter(filter, ctx.GetUser().JMMUserID).FirstOrDefault(a => a.Key == ag.AnimeGroupID)?.ToList(); } - var animes = evaluatedSeriesIDs != null - ? evaluatedSeriesIDs.Select(id => RepoFactory.AnimeSeries.GetByID(id)).Select(ser => ser.AniDB_Anime).Where(a => a != null).OrderBy(a => a.BeginYear) - .ThenBy(a => a.AirDate ?? DateTime.MaxValue).ToList() + var allAnime = evaluatedSeriesIDs is not null + ? evaluatedSeriesIDs + .Select(RepoFactory.AnimeSeries.GetByID) + .WhereNotNull() + .Select(ser => ser.AniDB_Anime).WhereNotNull() + .OrderBy(a => a.BeginYear) + .ThenBy(a => a.AirDate ?? DateTime.MaxValue) + .ToList() : ag.Anime?.OrderBy(a => a.BeginYear).ThenBy(a => a.AirDate ?? DateTime.MaxValue).ToList(); - if (animes is not { Count: > 0 }) return g; + if (allAnime is not { Count: > 0 }) return g; - var anime = animes.FirstOrDefault(); + var anime = allAnime.FirstOrDefault(); if (anime == null) return g; - PopulateArtFromAniDBAnime(ctx, animes, g, allpic, pic); + PopulateArtFromAniDBAnime(ctx, allAnime, g, allPic, pic); List ael; - if (evaluatedSeriesIDs != null) + if (evaluatedSeriesIDs is not null) { var series = evaluatedSeriesIDs.Select(id => RepoFactory.AnimeSeries.GetByID(id)).ToList(); - ael = series.SelectMany(ser => ser?.AnimeEpisodes).Where(a => a != null).ToList(); + ael = series.SelectMany(ser => ser?.AnimeEpisodes).WhereNotNull().ToList(); g.size = series.Count; } else { var series = ag.AllSeries; - ael = series.SelectMany(a => a?.AnimeEpisodes).Where(a => a != null).ToList(); + ael = series.SelectMany(a => a?.AnimeEpisodes).WhereNotNull().ToList(); g.size = series.Count; } @@ -86,20 +90,22 @@ public static Group GenerateFromAnimeGroup(HttpContext ctx, SVR_AnimeGroup ag, i g.summary = anime.Description ?? string.Empty; g.titles = anime.Titles.Select(s => new AnimeTitle { - Type = s.TitleType.ToString().ToLower(), Language = s.LanguageCode, Title = s.Title + Type = s.TitleType.ToString().ToLower(), + Language = s.LanguageCode, + Title = s.Title }).ToList(); g.year = anime.BeginYear.ToString(); var tags = ag.Tags.Select(a => a.TagName).ToList(); - if (!notag && tags.Count > 0) + if (!noTag && tags.Count > 0) { - g.tags = TagFilter.String.ProcessTags(tagfilter, tags); + g.tags = TagFilter.String.ProcessTags(tagFilter, tags); } - if (!nocast) + if (!noCast) { - var xref_animestaff = RepoFactory.CrossRef_Anime_Staff.GetByAnimeIDAndRoleType(anime.AnimeID, StaffRoleType.Seiyuu); - foreach (var xref in xref_animestaff) + var xrefAnimeStaff = RepoFactory.CrossRef_Anime_Staff.GetByAnimeIDAndRoleType(anime.AnimeID, StaffRoleType.Seiyuu); + foreach (var xref in xrefAnimeStaff) { if (xref.RoleID == null) continue; @@ -112,13 +118,13 @@ public static Group GenerateFromAnimeGroup(HttpContext ctx, SVR_AnimeGroup ag, i var role = new Role { character = character.Name, - character_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.Character, xref.RoleID.Value), + character_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, ImageEntityType.Character, DataSourceEnum.Shoko, xref.RoleID.Value), staff = staff.Name, - staff_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.Staff, xref.StaffID), + staff_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, ImageEntityType.Person, DataSourceEnum.Shoko, xref.StaffID), role = xref.Role, type = ((StaffRoleType)xref.RoleType).ToString() }; - g.roles ??= new List(); + g.roles ??= []; g.roles.Add(role); } @@ -126,12 +132,11 @@ public static Group GenerateFromAnimeGroup(HttpContext ctx, SVR_AnimeGroup ag, i if (level > 0) { - foreach (var ada in animes.Select(a => RepoFactory.AnimeSeries.GetByAnimeID(a.AnimeID))) + // we already sorted allAnime, so no need to sort + foreach (var ada in allAnime.Select(a => RepoFactory.AnimeSeries.GetByAnimeID(a.AnimeID))) { - g.series.Add(Serie.GenerateFromAnimeSeries(ctx, ada, uid, nocast, notag, level - 1, all, allpic, - pic, tagfilter)); + g.series.Add(Serie.GenerateFromAnimeSeries(ctx, ada, uid, noCast, noTag, level - 1, all, allPic, pic, tagFilter)); } - // we already sorted animes, so no need to sort } return g; @@ -143,146 +148,101 @@ private static IEnumerable Randomize(IEnumerable source, int seed = -1) return source.OrderBy(item => rnd.Next()); } - public static void PopulateArtFromAniDBAnime(HttpContext ctx, IEnumerable animes, Group grp, - bool allpics, int pic) + public static void PopulateArtFromAniDBAnime(HttpContext ctx, IEnumerable allAnime, Group group, bool allPictures, int maxPictures) { var rand = new Random(); - - foreach (var anime in Randomize(animes)) + foreach (var anime in Randomize(allAnime)) { - var tvdbIDs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(anime.AnimeID).ToList(); - var fanarts = tvdbIDs - .SelectMany(a => RepoFactory.TvDB_ImageFanart.GetBySeriesID(a.TvDBID)).ToList(); - var banners = tvdbIDs - .SelectMany(a => RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(a.TvDBID)).ToList(); - - var posters = anime.AllPosters; - if (allpics || pic > 1) + var backdrops = anime.GetImages(ImageEntityType.Backdrop); + var banners = anime.GetImages(ImageEntityType.Banner); + var posters = anime.GetImages(ImageEntityType.Poster); + if (allPictures || maxPictures > 1) { - if (allpics) - { - pic = 999; - } - - var pic_index = 0; - if (posters != null) + if (allPictures) + maxPictures = 999; + var pictureIndex = 0; + foreach (var poster in posters) { - foreach (var cont_image in posters) + if (pictureIndex >= maxPictures) + break; + group.art.thumb.Add(new Art { - if (pic_index < pic) - { - grp.art.thumb.Add(new Art - { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, cont_image.ImageType, - cont_image.AniDB_Anime_DefaultImageID), - index = pic_index - }); - pic_index++; - } - else - { - break; - } - } + index = pictureIndex++, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, poster.ImageType, poster.Source, poster.ID), + }); } - - pic_index = 0; - foreach (var cont_image in fanarts) + pictureIndex = 0; + foreach (var backdrop in backdrops) { - if (pic_index < pic) - { - grp.art.fanart.Add(new Art - { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_FanArt, - cont_image.TvDB_ImageFanartID), - index = pic_index - }); - pic_index++; - } - else - { + if (pictureIndex >= maxPictures) break; - } + group.art.fanart.Add(new Art + { + index = pictureIndex++, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, backdrop.ImageType, backdrop.Source, backdrop.ID), + }); } - - pic_index = 0; - foreach (var cont_image in banners) + pictureIndex = 0; + foreach (var banner in banners) { - if (pic_index < pic) - { - grp.art.banner.Add(new Art - { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_Banner, - cont_image.TvDB_ImageWideBannerID), - index = pic_index - }); - pic_index++; - } - else - { + if (pictureIndex >= maxPictures) break; - } + group.art.banner.Add(new Art + { + index = pictureIndex++, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, banner.ImageType, banner.Source, banner.ID), + }); } } - else if (pic > 0) + else if (maxPictures > 0) { - var poster = anime.GetDefaultPosterDetailsNoBlanks(); - grp.art.thumb.Add(new Art + var poster = anime.PreferredOrDefaultPoster; + group.art.thumb.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)poster.ImageType, poster.ImageID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, poster.ImageType, poster.Source, poster.ID), }); - - if (fanarts.Count > 0) + if (backdrops.Count > 0) { - var default_fanart = anime.DefaultFanart; - - if (default_fanart != null) + if (anime.PreferredBackdrop is { } preferredBackdrop) { - grp.art.fanart.Add(new Art + group.art.fanart.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, default_fanart.ImageType, - default_fanart.AniDB_Anime_DefaultImageID), + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, preferredBackdrop.ImageType, preferredBackdrop.ImageSource.ToDataSourceEnum(), preferredBackdrop.ImageID), index = 0 }); } else { - var tvdbart = fanarts[rand.Next(fanarts.Count)]; - grp.art.fanart.Add(new Art + var backdrop = backdrops[rand.Next(backdrops.Count)]; + group.art.fanart.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_FanArt, - tvdbart.TvDB_ImageFanartID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, backdrop.ImageType, backdrop.Source, backdrop.ID), }); } } - if (banners.Count > 0) { - var default_fanart = anime.DefaultWideBanner; - - if (default_fanart != null) + var preferredBanner = anime.PreferredBanner; + if (preferredBanner is not null) { - grp.art.banner.Add(new Art + group.art.banner.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, default_fanart.ImageType, - default_fanart.AniDB_Anime_DefaultImageID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, preferredBanner.ImageType, preferredBanner.ImageSource.ToDataSourceEnum(), preferredBanner.ImageID), }); } else { - var tvdbart = banners[rand.Next(banners.Count)]; - grp.art.banner.Add(new Art + var banner = banners[rand.Next(banners.Count)]; + group.art.banner.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_Banner, - tvdbart.TvDB_ImageWideBannerID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, banner.ImageType, banner.Source, banner.ID), }); } } - break; } } @@ -319,7 +279,7 @@ private static void GenerateSizes(Group grp, List ael, int uid continue; } - var local = ep.VideoLocals?.Any() ?? false; + var local = ep.VideoLocals.Count != 0; switch (ep.EpisodeTypeEnum) { case EpisodeType.Episode: @@ -330,7 +290,7 @@ private static void GenerateSizes(Group grp, List ael, int uid local_eps++; } - if (ep.GetUserRecord(uid)?.WatchedDate != null) + if (ep.GetUserRecord(uid)?.WatchedDate is not null) { watched_eps++; } @@ -345,7 +305,7 @@ private static void GenerateSizes(Group grp, List ael, int uid local_credits++; } - if (ep.GetUserRecord(uid)?.WatchedDate != null) + if (ep.GetUserRecord(uid)?.WatchedDate is not null) { watched_credits++; } @@ -360,7 +320,7 @@ private static void GenerateSizes(Group grp, List ael, int uid local_specials++; } - if (ep.GetUserRecord(uid)?.WatchedDate != null) + if (ep.GetUserRecord(uid)?.WatchedDate is not null) { watched_specials++; } @@ -375,7 +335,7 @@ private static void GenerateSizes(Group grp, List ael, int uid local_trailers++; } - if (ep.GetUserRecord(uid)?.WatchedDate != null) + if (ep.GetUserRecord(uid)?.WatchedDate is not null) { watched_trailers++; } @@ -390,7 +350,7 @@ private static void GenerateSizes(Group grp, List ael, int uid local_parodies++; } - if (ep.GetUserRecord(uid)?.WatchedDate != null) + if (ep.GetUserRecord(uid)?.WatchedDate is not null) { watched_parodies++; } @@ -405,7 +365,7 @@ private static void GenerateSizes(Group grp, List ael, int uid local_others++; } - if (ep.GetUserRecord(uid)?.WatchedDate != null) + if (ep.GetUserRecord(uid)?.WatchedDate is not null) { watched_others++; } diff --git a/Shoko.Server/API/v2/Models/common/Serie.cs b/Shoko.Server/API/v2/Models/common/Serie.cs index d65c933b9..7e200ff00 100644 --- a/Shoko.Server/API/v2/Models/common/Serie.cs +++ b/Shoko.Server/API/v2/Models/common/Serie.cs @@ -10,12 +10,15 @@ using Shoko.Models.Enums; using Shoko.Models.PlexAndKodi; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Extensions; using Shoko.Server.Models; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.AniDB; +#pragma warning disable CA2012 +#pragma warning disable IDE1006 namespace Shoko.Server.API.v2.Models.common; [DataContract] @@ -41,42 +44,32 @@ public class Serie : BaseDirectory, IComparable public Serie() { art = new ArtCollection(); - roles = new List(); - tags = new List(); + roles = []; + tags = []; } - public static Serie GenerateFromVideoLocal(HttpContext ctx, SVR_VideoLocal vl, int uid, bool nocast, bool notag, - int level, bool all, bool allpics, int pic, TagFilter.Filter tagfilter) + public static Serie GenerateFromVideoLocal(HttpContext ctx, SVR_VideoLocal vl, int uid, bool noCast, bool noTag, + int level, bool all, bool allPictures, int pic, TagFilter.Filter tagFilter) { - var sr = new Serie(); - - if (vl == null) - { - return sr; - } + if (vl is null) + return new(); var ser = vl.AnimeEpisodes.FirstOrDefault()?.AnimeSeries; - if (ser == null) - { - return sr; - } - - sr = GenerateFromAnimeSeries(ctx, ser, uid, nocast, notag, level, all, allpics, pic, tagfilter); + if (ser is null) + return new(); - return sr; + return GenerateFromAnimeSeries(ctx, ser, uid, noCast, noTag, level, all, allPictures, pic, tagFilter); } - public static Serie GenerateFromBookmark(HttpContext ctx, BookmarkedAnime bookmark, int uid, bool nocast, - bool notag, int level, bool all, bool allpics, int pic, TagFilter.Filter tagfilter) + public static Serie GenerateFromBookmark(HttpContext ctx, BookmarkedAnime bookmark, int uid, bool noCast, + bool noTag, int level, bool all, bool allPictures, int pic, TagFilter.Filter tagFilter) { var series = RepoFactory.AnimeSeries.GetByAnimeID(bookmark.AnimeID); - if (series != null) - { - return GenerateFromAnimeSeries(ctx, series, uid, nocast, notag, level, all, allpics, pic, tagfilter); - } + if (series is not null) + return GenerateFromAnimeSeries(ctx, series, uid, noCast, noTag, level, all, allPictures, pic, tagFilter); var aniDB_Anime = RepoFactory.AniDB_Anime.GetByAnimeID(bookmark.AnimeID); - if (aniDB_Anime == null) + if (aniDB_Anime is null) { var scheduler = ctx.RequestServices.GetRequiredService().GetScheduler().Result; scheduler.StartJob( @@ -87,15 +80,14 @@ public static Serie GenerateFromBookmark(HttpContext ctx, BookmarkedAnime bookma } ).GetAwaiter().GetResult(); - var empty_serie = new Serie { id = -1, name = "GetAnimeInfoHTTP", aid = bookmark.AnimeID }; - return empty_serie; + return new Serie { id = -1, name = "GetAnimeInfoHTTP", aid = bookmark.AnimeID }; } - return GenerateFromAniDB_Anime(ctx, aniDB_Anime, nocast, notag, allpics, pic, tagfilter); + return GenerateFromAniDBAnime(ctx, aniDB_Anime, noCast, noTag, allPictures, pic, tagFilter); } - public static Serie GenerateFromAniDB_Anime(HttpContext ctx, SVR_AniDB_Anime anime, bool nocast, bool notag, - bool allpics, int pic, TagFilter.Filter tagfilter) + public static Serie GenerateFromAniDBAnime(HttpContext ctx, SVR_AniDB_Anime anime, bool noCast, bool noTag, + bool allPictures, int pic, TagFilter.Filter tagFilter) { var sr = new Serie { @@ -110,7 +102,7 @@ public static Serie GenerateFromAniDB_Anime(HttpContext ctx, SVR_AniDB_Anime ani ismovie = anime.AnimeType == (int)AnimeType.Movie ? 1 : 0 }; - if (anime.AirDate != null) + if (anime.AirDate.HasValue) { sr.year = anime.AirDate.Value.Year.ToString(); var airdate = anime.AirDate.Value; @@ -122,7 +114,7 @@ public static Serie GenerateFromAniDB_Anime(HttpContext ctx, SVR_AniDB_Anime ani var vote = RepoFactory.AniDB_Vote.GetByEntityAndType(anime.AnimeID, AniDBVoteType.Anime) ?? RepoFactory.AniDB_Vote.GetByEntityAndType(anime.AnimeID, AniDBVoteType.AnimeTemp); - if (vote != null) + if (vote is not null) { sr.userrating = Math.Round(vote.VoteValue / 100D, 1).ToString(CultureInfo.InvariantCulture); } @@ -130,108 +122,97 @@ public static Serie GenerateFromAniDB_Anime(HttpContext ctx, SVR_AniDB_Anime ani sr.titles = anime.Titles.Select(title => new AnimeTitle { - Language = title.LanguageCode, Title = title.Title, Type = title.TitleType.ToString().ToLower() + Language = title.LanguageCode, + Title = title.Title, + Type = title.TitleType.ToString().ToLower(), }).ToList(); - PopulateArtFromAniDBAnime(ctx, anime, sr, allpics, pic); + PopulateArtFromAniDBAnime(ctx, anime, sr, allPictures, pic); - if (!nocast) + if (!noCast) { - var xref_animestaff = RepoFactory.CrossRef_Anime_Staff.GetByAnimeIDAndRoleType(anime.AnimeID, + var xrefAnimeStaff = RepoFactory.CrossRef_Anime_Staff.GetByAnimeIDAndRoleType(anime.AnimeID, StaffRoleType.Seiyuu); - foreach (var xref in xref_animestaff) + foreach (var xref in xrefAnimeStaff) { - if (xref.RoleID == null) - { + if (!xref.RoleID.HasValue) continue; - } var character = RepoFactory.AnimeCharacter.GetByID(xref.RoleID.Value); - if (character == null) - { + if (character is null) continue; - } var staff = RepoFactory.AnimeStaff.GetByID(xref.StaffID); - if (staff == null) - { + if (staff is null) continue; - } var role = new Role { character = character.Name, - character_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.Character, - xref.RoleID.Value), + character_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, ImageEntityType.Character, DataSourceEnum.Shoko, xref.RoleID.Value), staff = staff.Name, - staff_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.Staff, - xref.StaffID), + staff_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, ImageEntityType.Person, DataSourceEnum.Shoko, xref.StaffID), role = xref.Role, type = ((StaffRoleType)xref.RoleType).ToString() }; - if (sr.roles == null) - { - sr.roles = new List(); - } - + sr.roles ??= []; sr.roles.Add(role); } } - if (!notag) + if (!noTag) { var tags = anime.GetAllTags(); - if (tags != null) + if (tags is not null) { - sr.tags = TagFilter.String.ProcessTags(tagfilter, tags.ToList()); + sr.tags = TagFilter.String.ProcessTags(tagFilter, tags.ToList()); } } return sr; } - public static Serie GenerateFromAnimeSeries(HttpContext ctx, SVR_AnimeSeries ser, int uid, bool nocast, bool notag, - int level, bool all, bool allpics, int pic, TagFilter.Filter tagfilter) + public static Serie GenerateFromAnimeSeries(HttpContext ctx, SVR_AnimeSeries ser, int uid, bool noCast, bool noTag, + int level, bool all, bool allPictures, int maxPictures, TagFilter.Filter tagFilter) { - var sr = GenerateFromAniDB_Anime(ctx, ser.AniDB_Anime, nocast, notag, allpics, pic, tagfilter); + var sr = GenerateFromAniDBAnime(ctx, ser.AniDB_Anime, noCast, noTag, allPictures, maxPictures, tagFilter); var ael = ser.AnimeEpisodes; sr.id = ser.AnimeSeriesID; - sr.name = ser.SeriesName; + sr.name = ser.PreferredTitle; GenerateSizes(sr, ael, uid); var season = ael.FirstOrDefault(a => - a.AniDB_Episode != null && a.AniDB_Episode.EpisodeType == (int)EpisodeType.Episode && + a.AniDB_Episode.EpisodeType == (int)EpisodeType.Episode && a.AniDB_Episode.EpisodeNumber == 1) - ?.TvDBEpisode?.SeasonNumber; - if (season != null) + ?.TmdbEpisodes.FirstOrDefault()?.SeasonNumber; + if (season is not null) { sr.season = season.Value.ToString(); } - var tvdbseriesID = ael.Select(a => a.TvDBEpisode) - .Where(a => a != null) - .GroupBy(a => a.SeriesID) + var tmdbShow = ael.SelectMany(a => a.TmdbEpisodes) + .DistinctBy(a => a.TmdbEpisodeID) + .GroupBy(a => a.TmdbShowID) .MaxBy(a => a.Count()) - ?.Key; - - if (tvdbseriesID != null) + ?.First().TmdbShow; + if (tmdbShow is not null) { - var tvdbseries = RepoFactory.TvDB_Series.GetByTvDBID(tvdbseriesID.Value); - if (tvdbseries != null) + sr.titles.Add(new() { - var title = new AnimeTitle { Language = "EN", Title = tvdbseries.SeriesName, Type = "TvDB" }; - sr.titles.Add(title); - } + Language = "EN", + Title = tmdbShow.EnglishTitle, + Type = "TMDB", + }); } - if (!notag) + if (!noTag) { var tags = ser.AniDB_Anime.GetAllTags(); - if (tags != null) + if (tags is not null) { - sr.tags = TagFilter.String.ProcessTags(tagfilter, tags.ToList()); + sr.tags = TagFilter.String.ProcessTags(tagFilter, tags.ToList()); } } @@ -239,7 +220,7 @@ public static Serie GenerateFromAnimeSeries(HttpContext ctx, SVR_AnimeSeries ser { if (ael.Count > 0) { - sr.eps = new List(); + sr.eps = []; foreach (var ae in ael) { if (!all && (ae?.VideoLocals?.Count ?? 0) == 0) @@ -247,8 +228,8 @@ public static Serie GenerateFromAnimeSeries(HttpContext ctx, SVR_AnimeSeries ser continue; } - var new_ep = Episode.GenerateFromAnimeEpisode(ctx, ae, uid, level - 1, pic); - if (new_ep == null) + var new_ep = Episode.GenerateFromAnimeEpisode(ctx, ae, uid, level - 1, maxPictures); + if (new_ep is null) { continue; } @@ -299,13 +280,13 @@ private static void GenerateSizes(Serie sr, IEnumerable ael, i // single loop. Will help on long shows foreach (var ep in ael) { - if (ep?.AniDB_Episode == null) + if (ep?.AniDB_Episode is null) { continue; } - var local = ep.VideoLocals.Any(); - var watched = ep.GetUserRecord(uid)?.WatchedDate != null; + var local = ep.VideoLocals.Count != 0; + var watched = ep.GetUserRecord(uid)?.WatchedDate is not null; switch (ep.EpisodeTypeEnum) { case EpisodeType.Episode: @@ -437,139 +418,97 @@ private static void GenerateSizes(Serie sr, IEnumerable ael, i }; } - public static void PopulateArtFromAniDBAnime(HttpContext ctx, SVR_AniDB_Anime anime, Serie sr, bool allpics, - int pic) + public static void PopulateArtFromAniDBAnime(HttpContext ctx, SVR_AniDB_Anime anime, Serie sr, bool allPictures, + int maxPictures) { var rand = (Random)ctx.Items["Random"]; - var tvdbIDs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(anime.AnimeID).ToList(); - var fanarts = tvdbIDs - .SelectMany(a => RepoFactory.TvDB_ImageFanart.GetBySeriesID(a.TvDBID)).ToList(); - var banners = tvdbIDs - .SelectMany(a => RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(a.TvDBID)).ToList(); - - if (allpics || pic > 1) + var backdrops = anime.GetImages(ImageEntityType.Backdrop); + var banners = anime.GetImages(ImageEntityType.Banner); + var posters = anime.GetImages(ImageEntityType.Poster); + if (allPictures || maxPictures > 1) { - if (allpics) + if (allPictures) + maxPictures = 999; + var pictureIndex = 0; + foreach (var poster in posters) { - pic = 999; - } - - var pic_index = 0; - var posters = anime.AllPosters; - if (posters != null) - { - foreach (var cont_image in posters) + if (pictureIndex >= maxPictures) + break; + sr.art.thumb.Add(new Art { - if (pic_index < pic) - { - sr.art.thumb.Add(new Art - { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, cont_image.ImageType, - cont_image.AniDB_Anime_DefaultImageID), - index = pic_index - }); - pic_index++; - } - else - { - break; - } - } + index = pictureIndex++, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, poster.ImageType, poster.Source, poster.ID), + }); } - - pic_index = 0; - foreach (var cont_image in fanarts) + pictureIndex = 0; + foreach (var backdrop in backdrops) { - if (pic_index < pic) - { - sr.art.fanart.Add(new Art - { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_FanArt, - cont_image.TvDB_ImageFanartID), - index = pic_index - }); - pic_index++; - } - else - { + if (pictureIndex >= maxPictures) break; - } + sr.art.fanart.Add(new Art + { + index = pictureIndex++, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, backdrop.ImageType, backdrop.Source, backdrop.ID), + }); } - - pic_index = 0; - foreach (var cont_image in banners) + pictureIndex = 0; + foreach (var banner in banners) { - if (pic_index < pic) - { - sr.art.banner.Add(new Art - { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_Banner, - cont_image.TvDB_ImageWideBannerID), - index = pic_index - }); - pic_index++; - } - else - { + if (pictureIndex >= maxPictures) break; - } + sr.art.banner.Add(new Art + { + index = pictureIndex++, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, banner.ImageType, banner.Source, banner.ID), + }); } } - else if (pic > 0) + else if (maxPictures > 0) { - var poster = anime.GetDefaultPosterDetailsNoBlanks(); + var poster = anime.PreferredOrDefaultPoster; sr.art.thumb.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)poster.ImageType, poster.ImageID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, poster.ImageType, poster.Source, poster.ID), }); - - if (fanarts.Count > 0) + if (backdrops.Count > 0) { - var default_fanart = anime.DefaultFanart; - - if (default_fanart != null) + if (anime.PreferredBackdrop is { } preferredBackdrop) { sr.art.fanart.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, default_fanart.ImageType, - default_fanart.AniDB_Anime_DefaultImageID), + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, preferredBackdrop.ImageType, preferredBackdrop.ImageSource.ToDataSourceEnum(), preferredBackdrop.ImageID), index = 0 }); } else { - var tvdbart = fanarts[rand.Next(fanarts.Count)]; + var backdrop = backdrops[rand.Next(backdrops.Count)]; sr.art.fanart.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_FanArt, - tvdbart.TvDB_ImageFanartID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, backdrop.ImageType, backdrop.Source, backdrop.ID), }); } } - if (banners.Count > 0) { - var default_fanart = anime.DefaultWideBanner; - - if (default_fanart != null) + var preferredBanner = anime.PreferredBanner; + if (preferredBanner is not null) { sr.art.banner.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, default_fanart.ImageType, - default_fanart.AniDB_Anime_DefaultImageID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, preferredBanner.ImageType, preferredBanner.ImageSource.ToDataSourceEnum(), preferredBanner.ImageID), }); } else { - var tvdbart = banners[rand.Next(banners.Count)]; + var banner = banners[rand.Next(banners.Count)]; sr.art.banner.Add(new Art { - url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.TvDB_Banner, - tvdbart.TvDB_ImageWideBannerID), - index = 0 + index = 0, + url = APIHelper.ConstructImageLinkFromTypeAndId(ctx, banner.ImageType, banner.Source, banner.ID), }); } } @@ -578,8 +517,7 @@ public static void PopulateArtFromAniDBAnime(HttpContext ctx, SVR_AniDB_Anime an public int CompareTo(object obj) { - var a = obj as Serie; - if (a == null) + if (obj is not Serie a) { return 1; } @@ -618,7 +556,6 @@ public int CompareTo(object obj) } } - // I don't trust TvDB well enough to sort by them. Bakamonogatari... // Does it have a Season? Sort by it if (int.TryParse(a.season, out s1) && int.TryParse(season, out s)) { diff --git a/Shoko.Server/API/v2/Modules/Common.cs b/Shoko.Server/API/v2/Modules/Common.cs index 016f78c2c..ff2c9cce9 100644 --- a/Shoko.Server/API/v2/Modules/Common.cs +++ b/Shoko.Server/API/v2/Modules/Common.cs @@ -1,22 +1,21 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Dynamic; using System.IO; using System.Linq; -using System.Net; using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; using NLog; using Quartz; using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.v2.Models.common; using Shoko.Server.API.v2.Models.core; using Shoko.Server.Extensions; @@ -33,6 +32,7 @@ using Shoko.Server.Utilities; using APIFilters = Shoko.Server.API.v2.Models.common.Filters; +#pragma warning disable IDE1006 namespace Shoko.Server.API.v2.Modules; //As responds for this API we throw object that will be converted to json/xml @@ -216,9 +216,9 @@ public async Task RemoveMissingFiles() ///
/// APIStatus [HttpGet("stats_update")] - public ActionResult UpdateStats() + public async Task UpdateStats() { - _actionService.UpdateAllStats(); + await _actionService.UpdateAllStats(); return Ok(); } @@ -471,15 +471,9 @@ await scheduler.StartJobNow( [HttpGet("myid/get")] public object MyID() { - JMMUser user = HttpContext.GetUser(); + var user = HttpContext.GetUser(); dynamic x = new ExpandoObject(); - if (user != null) - { - x.userid = user.JMMUserID; - return x; - } - - x.userid = 0; + x.userid = user.JMMUserID; return x; } @@ -490,38 +484,7 @@ public object MyID() [HttpGet("news/get")] public List GetNews(int max) { - var client = new WebClient(); - client.Headers.Add("User-Agent", "jmmserver"); - client.Headers.Add("Accept", "application/json"); - var response = client.DownloadString(new Uri("https://shokoanime.com/feed.json")); - var newsFeed = JsonConvert.DeserializeObject(response); - var news = new List(); - var limit = 0; - foreach (var post in newsFeed.items) - { - limit++; - var postAuthor = "shoko team"; - if ((string)post.author != "") - { - postAuthor = (string)post.author; - } - - var wn = new WebNews - { - author = postAuthor, - date = post.date_published, - link = post.url, - title = HttpUtility.HtmlDecode((string)post.title), - description = post.content_text - }; - news.Add(wn); - if (limit >= max) - { - break; - } - } - - return news; + return []; } /// @@ -725,8 +688,7 @@ public async Task PauseGeneralQueue() [HttpGet("queue/images/pause")] public async Task PauseImagesQueue() { - var scheduler = await _schedulerFactory.GetScheduler(); - await scheduler.Standby(); + await _queueHandler.Pause(); return Ok(); } @@ -768,16 +730,9 @@ public async Task StartImagesQueue() /// /// APIStatus [HttpGet("queue/hasher/clear")] - public async Task ClearHasherQueue() + public ActionResult ClearHasherQueue() { - try - { - return Ok(); - } - catch - { - return InternalError(); - } + return Ok(); } /// @@ -787,14 +742,7 @@ public async Task ClearHasherQueue() [HttpGet("queue/general/clear")] public ActionResult ClearGeneralQueue() { - try - { - return Ok(); - } - catch - { - return InternalError(); - } + return Ok(); } /// @@ -804,14 +752,7 @@ public ActionResult ClearGeneralQueue() [HttpGet("queue/images/clear")] public ActionResult ClearImagesQueue() { - try - { - return Ok(); - } - catch - { - return InternalError(); - } + return Ok(); } #endregion @@ -841,12 +782,12 @@ public ActionResult> GetFilesWithMismatchedInfo(int level) { JMMUser user = HttpContext.GetUser(); - var allvids = RepoFactory.VideoLocal.GetAll().Where(vid => !vid.IsEmpty() && vid.MediaInfo != null) + var allVideos = RepoFactory.VideoLocal.GetAll().Where(vid => !vid.IsEmpty() && vid.MediaInfo != null) .ToDictionary(a => a, a => a.AniDBFile); - return allvids.Keys.Select(vid => new { vid, anidb = allvids[vid] }) + return allVideos.Keys.Select(vid => new { vid, anidb = allVideos[vid] }) .Where(tuple => tuple.anidb != null) .Where(tuple => !tuple.anidb.IsDeprecated) - .Where(tuple => tuple.vid.MediaInfo?.MenuStreams.Any() != tuple.anidb.IsChaptered) + .Where(tuple => tuple.vid.MediaInfo?.MenuStreams.Count != 0 != tuple.anidb.IsChaptered) .Select(tuple => GetFileById(tuple.vid.VideoLocalID, level, user.JMMUserID).Value).ToList(); } @@ -861,20 +802,23 @@ public async Task AVDumpMismatchedFiles() if (string.IsNullOrWhiteSpace(settings.AniDb.AVDumpKey)) return BadRequest("Missing AVDump API key"); - var allvids = RepoFactory.VideoLocal.GetAll().Where(vid => !vid.IsEmpty() && vid.MediaInfo != null) + var allVideos = RepoFactory.VideoLocal.GetAll().Where(vid => !vid.IsEmpty() && vid.MediaInfo != null) .ToDictionary(a => a, a => a.AniDBFile); var logger = LogManager.GetCurrentClassLogger(); - var list = allvids.Keys.Select(vid => new + var list = allVideos.Keys + .Select(vid => new { - vid, anidb = allvids[vid] + vid, + anidb = allVideos[vid], }) .Where(tuple => tuple.anidb != null) .Where(tuple => !tuple.anidb.IsDeprecated) - .Where(tuple => tuple.vid.MediaInfo?.MenuStreams.Any() != tuple.anidb.IsChaptered) + .Where(tuple => tuple.vid.MediaInfo?.MenuStreams.Count != 0 != tuple.anidb.IsChaptered) .Select(_tuple => new { - Path = _tuple.vid.FirstResolvedPlace?.FullServerPath, Video = _tuple.vid + Path = _tuple.vid.FirstResolvedPlace?.FullServerPath, + Video = _tuple.vid }) .Where(obj => !string.IsNullOrEmpty(obj.Path)).ToDictionary(a => a.Video.VideoLocalID, a => a.Path); @@ -896,9 +840,9 @@ public ActionResult> GetDeprecatedFiles(int level) { JMMUser user = HttpContext.GetUser(); - var allvids = RepoFactory.VideoLocal.GetAll() + var allVideos = RepoFactory.VideoLocal.GetAll() .Where(a => !a.IsEmpty() && a.AniDBFile != null && a.AniDBFile.IsDeprecated).ToList(); - return allvids.Select(vid => GetFileById(vid.VideoLocalID, level, user.JMMUserID).Value).ToList(); + return allVideos.Select(vid => GetFileById(vid.VideoLocalID, level, user.JMMUserID).Value).ToList(); } /// @@ -1115,8 +1059,9 @@ internal ActionResult GetFileById(int file_id, int level, int uid) /// Internal function returning files /// /// number of return items - /// offset to start from - /// List + /// + /// + /// List%lt;RawFile%gt; internal object GetAllFiles(int limit, int level, int uid) { var list = new List(); @@ -1199,7 +1144,7 @@ internal async Task MarkFile(bool status, int id, int uid) /// /// Handle /api/ep /// - /// List or Episode + /// List<Episode> or Episode [HttpGet("ep")] public object GetEpisode([FromQuery] API_Call_Parameters para) { @@ -1279,7 +1224,7 @@ public ActionResult> GetEpisodeFromHash(string hash, int pic = 1) /// /// Handle /api/ep/recent /// - /// List + /// List<Episode> [HttpGet("ep/recent")] public List GetRecentEpisodes([FromQuery] API_Call_Parameters para) { @@ -1320,7 +1265,7 @@ public List GetRecentEpisodes([FromQuery] API_Call_Parameters para) /// /// Handle /api/ep/missing /// - /// List + /// List<Serie> [HttpGet("ep/missing")] public List GetMissingEpisodes(bool all, int pic, TagFilter.Filter tagfilter) { @@ -1395,7 +1340,7 @@ public async Task MarkEpisodeAsUnwatched(int id) /// /// APIStatus [HttpGet("ep/vote")] - public ActionResult VoteOnEpisode(int id, int score) + public async Task VoteOnEpisode(int id, int score) { JMMUser user = HttpContext.GetUser(); @@ -1403,7 +1348,7 @@ public ActionResult VoteOnEpisode(int id, int score) { if (score != 0) { - return EpisodeVote(id, score, user.JMMUserID); + return await EpisodeVote(id, score, user.JMMUserID); } return BadRequest("missing 'score'"); @@ -1458,7 +1403,7 @@ public ActionResult EpisodeScrobble(int id, int progress, int status, bool ismov /// /// Handle /api/ep/last_watched /// - /// List<> + /// List<> [HttpGet("ep/last_watched")] public List ListWatchedEpisodes(string query, int pic, int level, int limit, int offset) { @@ -1535,7 +1480,7 @@ internal async Task MarkEpisode(bool status, int id, int uid) /// /// Return All known Episodes for current user /// - /// List + /// List<Episode> internal object GetAllEpisodes(int uid, int limit, int offset, int level, bool all, int pic) { var eps = new List(); @@ -1573,6 +1518,8 @@ internal object GetAllEpisodes(int uid, int limit, int offset, int level, bool a /// /// episode id /// user id + /// + /// /// Episode or APIStatus internal object GetEpisodeById(int id, int uid, int level, int pic) { @@ -1614,37 +1561,43 @@ internal object GetEpisodeById(int id, int uid, int level, int pic) /// rating score as 1-10 or 100-1000 /// /// APIStatus - internal ActionResult EpisodeVote(int id, int score, int uid) + internal async Task EpisodeVote(int id, int score, int uid) { - if (id > 0) + if (id <= 0) { - if (score > 0 && score < 1000) - { - var thisVote = RepoFactory.AniDB_Vote.GetByEntityAndType(id, AniDBVoteType.Episode); + return BadRequest("'id' value is wrong"); + } - if (thisVote == null) - { - thisVote = new AniDB_Vote { VoteType = (int)AniDBVoteType.Episode, EntityID = id }; - } + if (score <= 0 || score > 1000) + { + return BadRequest("'score' value is wrong"); + } - if (score <= 10) - { - score = score * 100; - } + var episode = RepoFactory.AnimeEpisode.GetByID(id); + if (episode == null) + { + return BadRequest($"Episode with id {id} was not found"); + } - thisVote.VoteValue = score; - RepoFactory.AniDB_Vote.Save(thisVote); + var thisVote = RepoFactory.AniDB_Vote.GetByEntityAndType(id, AniDBVoteType.Episode) ?? + new AniDB_Vote { EntityID = id, VoteType = (int)AniDBVoteType.Episode }; - //CommandRequest_VoteAnime cmdVote = new CommandRequest_VoteAnime(animeID, voteType, voteValue); - //cmdVote.Save(); + if (score <= 10) + { + score *= 100; + } - return Ok(); - } + thisVote.VoteValue = score; + RepoFactory.AniDB_Vote.Save(thisVote); - return BadRequest("'score' value is wrong"); - } + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJobNow(c => + { + c.EpisodeID = episode.AniDB_EpisodeID; + c.VoteValue = Convert.ToDouble(thisVote); + }); - return BadRequest("'id' value is wrong"); + return Ok(); } #endregion @@ -1656,7 +1609,7 @@ internal ActionResult EpisodeVote(int id, int score, int uid) /// /// Handle /api/serie /// - /// List or Serie + /// List<Serie> or Serie [HttpGet("serie")] public object GetSerie([FromQuery] API_Call_Parameters para) { @@ -1683,7 +1636,7 @@ public ActionResult CountSerie() /// /// Handle /api/serie/today /// - /// List or Serie + /// List<Serie> or Serie [HttpGet("serie/today")] public ActionResult SeriesToday([FromQuery] API_Call_Parameters para) { @@ -1729,7 +1682,7 @@ public ActionResult SeriesToday([FromQuery] API_Call_Parameters para) /// /// Handle /api/serie/bookmark /// - /// List + /// List<Serie> [HttpGet("serie/bookmark")] public ActionResult SeriesBookmark([FromQuery] API_Call_Parameters para) { @@ -1864,7 +1817,7 @@ public ActionResult SeriesSoon([FromQuery] API_Call_Parameters para) anime_count++; return true; - }).OrderBy(a => a.AirDate).Select(ser => Serie.GenerateFromAniDB_Anime(HttpContext, ser, para.nocast == 1, + }).OrderBy(a => a.AirDate).Select(ser => Serie.GenerateFromAniDBAnime(HttpContext, ser, para.nocast == 1, para.notag == 1, para.allpics == 1, para.pic, para.tagfilter)).ToList(); return new Group @@ -1881,7 +1834,7 @@ public ActionResult SeriesSoon([FromQuery] API_Call_Parameters para) /// /// Handle /api/serie/byfolder /// - /// List or APIStatus + /// List<Serie> or APIStatus [HttpGet("serie/byfolder")] public ActionResult> GetSeriesByFolderId([FromQuery] API_Call_Parameters para) { @@ -1899,7 +1852,7 @@ public ActionResult> GetSeriesByFolderId([FromQuery] API_Call /// /// Handle /api/serie/infobyfolder /// - /// List or APIStatus + /// List<ObjectList> or APIStatus [HttpGet("serie/infobyfolder")] public object GetSeriesInfoByFolderId(int id) { @@ -1916,7 +1869,7 @@ public object GetSeriesInfoByFolderId(int id) /// /// Handle /api/serie/recent /// - /// List + /// List<Serie> [HttpGet("serie/recent")] public ActionResult> GetSeriesRecent([FromQuery] API_Call_Parameters para) { @@ -1998,7 +1951,7 @@ public async Task VoteOnSerie(int id, int score) /// /// Handle /api/serie/search /// - /// List or APIStatus + /// List<Serie> or APIStatus [HttpGet("serie/search")] public ActionResult> SearchForSerie([FromQuery] API_Call_Parameters para) { @@ -2024,7 +1977,7 @@ public ActionResult> SearchForSerie([FromQuery] API_Call_Para /// /// Handle /api/serie/tag /// - /// List or APIStatus + /// List<Serie> or APIStatus [HttpGet("serie/tag")] public ActionResult> SearchForTag([FromQuery] API_Call_Parameters para) { @@ -2121,7 +2074,10 @@ public ActionResult GetSeriesFromAniDBID([FromQuery] API_Call_Parameters /// deep level /// /// - /// List + /// + /// + /// + /// List<Serie> internal List GetSeriesByFolder(int id, int uid, bool nocast, bool notag, int level, bool all, int limit, bool allpic, int pic, TagFilter.Filter tagfilter) { @@ -2211,9 +2167,9 @@ internal object GetSeriesInfoByFolder(int id) var path = (Path.GetDirectoryName(place.FilePath) ?? string.Empty) + "/"; foreach (var series in seriesList) { - if (output.ContainsKey(series.AnimeSeriesID)) + if (output.TryGetValue(series.AnimeSeriesID, out var value)) { - var ser = output[series.AnimeSeriesID]; + var ser = value; ser.filesize += vl.FileSize; ser.size++; @@ -2231,7 +2187,7 @@ internal object GetSeriesInfoByFolder(int id) { id = series.AnimeSeriesID, filesize = vl.FileSize, - name = series.SeriesName, + name = series.PreferredTitle, size = 1, paths = new List { path } }; @@ -2257,10 +2213,11 @@ internal object GetSeriesInfoByFolder(int id) /// import folder id /// user id /// - /// List + /// + /// List<ObjectList> internal object GetSeriesInfoByFolder(int id, int uid, int limit, TagFilter.Filter tagfilter) { - var tmp_list = new Dictionary(); + var tempDict = new Dictionary(); var allseries = new List(); var vlpall = RepoFactory.VideoLocalPlace.GetByImportFolder(id) .Select(a => a.VideoLocal) @@ -2279,21 +2236,21 @@ internal object GetSeriesInfoByFolder(int id, int uid, int limit, TagFilter.Filt var objl = new ObjectList(ser.name, ObjectList.ListType.SERIE, ser.filesize); if (ser.name != null) { - if (!tmp_list.ContainsKey(ser.name)) + if (!tempDict.TryGetValue(ser.name, out var value)) { - tmp_list.Add(ser.name, ser.filesize); + tempDict.Add(ser.name, ser.filesize); allseries.Add(objl); } else { - if (tmp_list[ser.name] != ser.filesize) + if (value != ser.filesize) { - while (tmp_list.ContainsKey(objl.name)) + while (tempDict.ContainsKey(objl.name)) { - objl.name = objl.name + "*"; + objl.name += "*"; } - tmp_list.Add(objl.name, ser.filesize); + tempDict.Add(objl.name, ser.filesize); allseries.Add(objl); } } @@ -2317,6 +2274,9 @@ internal object GetSeriesInfoByFolder(int id, int uid, int limit, TagFilter.Filt /// disable tag /// deep level /// + /// + /// + /// /// internal ActionResult GetSerieFromEpisode(int id, int uid, bool nocast, bool notag, int level, bool all, bool allpic, int pic, TagFilter.Filter tagfilter) @@ -2331,23 +2291,24 @@ internal ActionResult GetSerieFromEpisode(int id, int uid, bool nocast, b return NotFound("serie not found"); } - // + /// /// Return Serie for given aid (AniDB ID) /// /// AniDB ID - /// user id /// disable cast /// disable tag - /// deep level - /// + /// + /// + /// + /// /// - internal ActionResult GetSerieFromAniDBID(int id, bool nocast, bool notag, bool all, bool allpic, int pic, + internal ActionResult GetSerieFromAniDBID(int id, bool nocast, bool notag, bool _, bool allpic, int pic, TagFilter.Filter tagfilter) { var adba = RepoFactory.AniDB_Anime.GetByAnimeID(id); if (adba != null) { - return Serie.GenerateFromAniDB_Anime(HttpContext, adba, nocast, notag, allpic, pic, tagfilter); + return Serie.GenerateFromAniDBAnime(HttpContext, adba, nocast, notag, allpic, pic, tagfilter); } return NotFound("serie not found"); @@ -2359,7 +2320,13 @@ internal ActionResult GetSerieFromAniDBID(int id, bool nocast, bool notag /// disable cast /// number of return items /// offset to start from - /// List + /// + /// + /// + /// + /// + /// + /// List<Serie> internal List GetAllSeries(bool nocast, int limit, int offset, bool notag, int level, bool all, bool allpic, int pic, TagFilter.Filter tagfilter) { @@ -2392,6 +2359,12 @@ internal List GetAllSeries(bool nocast, int limit, int offset, bool notag /// /// serie id /// disable cast + /// + /// + /// + /// + /// + /// /// internal object GetSerieById(int series_id, bool nocast, bool notag, int level, bool all, bool allpic, int pic, TagFilter.Filter tagfilter) @@ -2471,7 +2444,10 @@ internal async Task MarkSerieWatchStatus(int id, bool watched, int /// deep level /// /// Disable searching for invalid path characters - /// List + /// + /// + /// + /// List<Serie> internal ActionResult> Search(string query, int limit, int limit_tag, int offset, int tagSearch, int uid, bool nocast, bool notag, int level, bool all, bool fuzzy, bool allpic, int pic, TagFilter.Filter tagfilter) @@ -2504,24 +2480,19 @@ internal ActionResult> Search(string query, int limit, int li return series_list; } - private SeriesSearch.SearchFlags GetFlags(int tagSearch, bool fuzzy) - { - switch (tagSearch) + private static SeriesSearch.SearchFlags GetFlags(int tagSearch, bool fuzzy) + => tagSearch switch { - case 0: - return fuzzy - ? SeriesSearch.SearchFlags.Titles | SeriesSearch.SearchFlags.Fuzzy - : SeriesSearch.SearchFlags.Titles; - case 1: - return fuzzy - ? SeriesSearch.SearchFlags.Tags | SeriesSearch.SearchFlags.Fuzzy - : SeriesSearch.SearchFlags.Tags; - default: - return fuzzy - ? SeriesSearch.SearchFlags.Titles | SeriesSearch.SearchFlags.Tags | SeriesSearch.SearchFlags.Fuzzy - : SeriesSearch.SearchFlags.Titles | SeriesSearch.SearchFlags.Tags; - } - } + 0 => fuzzy + ? SeriesSearch.SearchFlags.Titles | SeriesSearch.SearchFlags.Fuzzy + : SeriesSearch.SearchFlags.Titles, + 1 => fuzzy + ? SeriesSearch.SearchFlags.Tags | SeriesSearch.SearchFlags.Fuzzy + : SeriesSearch.SearchFlags.Tags, + _ => fuzzy + ? SeriesSearch.SearchFlags.Titles | SeriesSearch.SearchFlags.Tags | SeriesSearch.SearchFlags.Fuzzy + : SeriesSearch.SearchFlags.Titles | SeriesSearch.SearchFlags.Tags, + }; private static void CheckTitlesStartsWith(SVR_AnimeSeries a, string query, ref ConcurrentDictionary series, int limit) @@ -2601,9 +2572,9 @@ internal object StartsWith(string query, int limit, int uid, bool nocast, /// /// serie id /// rating score as 1-10 or 100-1000 - /// + /// /// APIStatus - internal async Task SerieVote(int id, int score, int uid) + internal async Task SerieVote(int id, int score, int _) { if (id <= 0) { @@ -2624,14 +2595,9 @@ internal async Task SerieVote(int id, int score, int uid) var voteType = ser.AniDB_Anime.GetFinishedAiring() ? (int)AniDBVoteType.Anime : (int)AniDBVoteType.AnimeTemp; var thisVote = - RepoFactory.AniDB_Vote.GetByEntityAndType(id, AniDBVoteType.AnimeTemp) ?? - RepoFactory.AniDB_Vote.GetByEntityAndType(id, AniDBVoteType.Anime); - - if (thisVote == null) - { - thisVote = new AniDB_Vote { EntityID = ser.AniDB_ID }; - } - + (RepoFactory.AniDB_Vote.GetByEntityAndType(id, AniDBVoteType.AnimeTemp) ?? + RepoFactory.AniDB_Vote.GetByEntityAndType(id, AniDBVoteType.Anime)) ?? + new AniDB_Vote { EntityID = ser.AniDB_ID }; if (score <= 10) { score *= 100; @@ -2648,7 +2614,7 @@ await scheduler.StartJobNow( { c.AnimeID = ser.AniDB_ID; c.VoteType = (AniDBVoteType)voteType; - c.VoteValue = Convert.ToDecimal(score / 100); + c.VoteValue = score / 100D; } ); return Ok(); @@ -2725,16 +2691,23 @@ public object GetFilters([FromQuery] API_Call_Parameters para) /// disable cast /// disable tag /// deep level - /// List + /// + /// + /// + /// + /// List<Filter> internal object GetAllFilters(int uid, bool nocast, bool notag, int level, bool all, bool allpic, int pic, TagFilter.Filter tagfilter) { var filters = new APIFilters { - id = 0, name = "Filters", viewed = 0, url = APIV2Helper.ConstructFilterUrl(HttpContext) + id = 0, + name = "Filters", + viewed = 0, + url = APIV2Helper.ConstructFilterUrl(HttpContext), }; - var _filters = new List(); + var filterList = new List(); var evaluator = HttpContext.RequestServices.GetRequiredService(); var user = HttpContext.GetUser(); var hideCategories = user.GetHideCategories(); @@ -2756,28 +2729,29 @@ internal object GetAllFilters(int uid, bool nocast, bool notag, int level, bool filter = APIFilters.GenerateFromGroupFilter(HttpContext, gf, uid, nocast, notag, level, all, allpic, pic, tagfilter, result); } - _filters.Add(filter); + filterList.Add(filter); } // Include 'Unsort' var vids = RepoFactory.VideoLocal.GetVideosWithoutEpisodeUnsorted(); - if (vids.Any()) + if (vids.Count != 0) { var filter = new Filter { url = APIV2Helper.ConstructUnsortUrl(HttpContext), name = "Unsort" }; filter.art.fanart.Add(new Art { - url = APIV2Helper.ConstructSupportImageLink(HttpContext, "plex_unsort.png"), index = 0 + url = APIV2Helper.ConstructSupportImageLink(HttpContext, "plex_unsort.png"), + index = 0, }); filter.art.thumb.Add( new Art { url = APIV2Helper.ConstructSupportImageLink(HttpContext, "plex_unsort.png"), index = 0 }); filter.size = vids.Count; filter.viewed = 0; - _filters.Add(filter); + filterList.Add(filter); } - filters.filters = _filters.OrderBy(a => a.name).ToList(); - filters.size = _filters.Count(); + filters.filters = filterList.OrderBy(a => a.name).ToList(); + filters.size = filterList.Count; return filters; } @@ -2791,6 +2765,9 @@ internal object GetAllFilters(int uid, bool nocast, bool notag, int level, bool /// disable tag /// deep level /// include missing episodes + /// + /// + /// /// Filter or Filters internal object GetFilter(int id, int uid, bool nocast, bool notag, int level, bool all, bool allpic, int pic, TagFilter.Filter tagfilter) @@ -2892,7 +2869,10 @@ public object SearchGroup([FromQuery] API_Call_Parameters para) /// /// /// - /// List + /// + /// + /// + /// List<Group> internal object GetAllGroups(int uid, bool nocast, bool notag, int level, bool all, bool allpics, int pic, TagFilter.Filter tagfilter) { @@ -2919,6 +2899,9 @@ internal object GetAllGroups(int uid, bool nocast, bool notag, int level, bool a /// deep level /// add all known episodes /// + /// + /// + /// /// Group or APIStatus internal object GetGroup(int id, int uid, bool nocast, bool notag, int level, bool all, int filterid, bool allpics, int pic, TagFilter.Filter tagfilter) @@ -3150,12 +3133,10 @@ public object GetCastFromSeries(int id) var role = new Role { character = character.Name, - character_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.Character, - xref.RoleID.Value), + character_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, ImageEntityType.Character, DataSourceEnum.Shoko, xref.RoleID.Value), character_description = cdescription, staff = staff.Name, - staff_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, (int)ImageEntityType.Staff, - xref.StaffID), + staff_image = APIHelper.ConstructImageLinkFromTypeAndId(ctx, ImageEntityType.Person, DataSourceEnum.Shoko, xref.StaffID), staff_description = sdescription, role = xref.Role, type = ((StaffRoleType)xref.RoleType).ToString() @@ -3222,7 +3203,7 @@ private static int CompareXRef_Anime_StaffByImportance( return result; } - return string.Compare(staff1.Key.SeriesName, staff2.Key.SeriesName, + return string.Compare(staff1.Key.PreferredTitle, staff2.Key.PreferredTitle, StringComparison.InvariantCultureIgnoreCase); } @@ -3232,8 +3213,8 @@ public ActionResult SearchByStaff([FromQuery] API_Call_Parameters para) var results = new List(); var user = HttpContext.GetUser(); - var search_filter = new Filter { name = "Search By Staff", groups = new List() }; - var search_group = new Group { name = para.query, series = new List() }; + var search_filter = new Filter { name = "Search By Staff", groups = [] }; + var search_group = new Group { name = para.query, series = [] }; var seriesDict = _seriesService.SearchSeriesByStaff(para.query, para.fuzzy == 1).ToList(); @@ -3243,33 +3224,29 @@ public ActionResult SearchByStaff([FromQuery] API_Call_Parameters para) para.tagfilter))); search_group.series = results; - search_group.size = search_group.series.Count(); + search_group.size = search_group.series.Count; search_filter.groups.Add(search_group); - search_filter.size = search_filter.groups.Count(); + search_filter.size = search_filter.groups.Count; return search_filter; } #endregion [HttpGet("links/serie")] - public Dictionary GetLinks(int id) + public static Dictionary GetLinks(int id) { var links = new Dictionary(); var serie = RepoFactory.AnimeSeries.GetByID(id); var trakt = serie?.TraktShow; - if (trakt != null) links.Add("trakt", trakt.Where(a => !string.IsNullOrEmpty(a.URL)).Select(x => x.URL).ToArray()); - var tvdb = serie?.TvDBSeries; - if (tvdb != null) - { - links.Add("tvdb", tvdb.Select(x => x.SeriesID).ToArray()); - } + if (trakt != null) + links.Add("trakt", trakt.Where(a => !string.IsNullOrEmpty(a.URL)).Select(x => x.URL).ToArray()); - var tmdb = serie?.CrossRefMovieDB; - if (tmdb != null) - { - links.Add("tmdb", tmdb.CrossRefID); //not sure this will work. - } + if (serie?.TmdbShows is { Count: > 0 } tmdbShows) + links.Add("tvdb", tmdbShows.Select(x => x.TvdbShowID).WhereNotNull().ToArray()); + + if (serie?.TmdbMovieCrossReferences is { Count: > 0 } tmdbMovieXrefs) + links.Add("tmdb", tmdbMovieXrefs[0].TmdbMovieID); return links; } @@ -3281,7 +3258,9 @@ public Dictionary GetLinks(int id) [ApiController] public class Common_v2_1 : BaseController { +#pragma warning disable ASP0018 [HttpGet("v{version:apiVersion}/ep/getbyfilename")] +#pragma warning restore ASP0018 [HttpGet("ep/getbyfilename")] //to allow via the header explicitly. public ActionResult> GetEpisodeFromName_v2([FromQuery] string filename, [FromQuery] int pic = 1, [FromQuery] int level = 0) @@ -3295,13 +3274,13 @@ public ActionResult> GetEpisodeFromName_v2([FromQuery] stri var items = RepoFactory.VideoLocalPlace.GetAll() .Where(v => filename.Equals(v.FilePath.Split(Path.DirectorySeparatorChar).LastOrDefault(), StringComparison.InvariantCultureIgnoreCase)) - .Where(a => a.VideoLocal != null) + .Where(a => a.VideoLocal is not null) .Select(a => a.VideoLocal.AnimeEpisodes) - .Where(a => a != null && a.Any()) + .Where(a => a is not null && a.Count is not 0) .Select(a => a.First()) .Select(aep => Episode.GenerateFromAnimeEpisode(HttpContext, aep, user.JMMUserID, level, pic)).ToList(); - if (items.Any()) + if (items.Count is not 0) { return Ok(items); } diff --git a/Shoko.Server/API/v2/Modules/Core.cs b/Shoko.Server/API/v2/Modules/Core.cs index 6ccedc8e7..350430345 100644 --- a/Shoko.Server/API/v2/Modules/Core.cs +++ b/Shoko.Server/API/v2/Modules/Core.cs @@ -400,38 +400,17 @@ public ActionResult TraktNotImplemented() /// /// [HttpGet("tvdb/update")] - public async Task ScanTvDB() + public ActionResult ScanTvDB() { - await _actionService.RunImport_ScanTvDB(); return APIStatus.OK(); } [HttpGet("tvdb/regenlinks")] public ActionResult RegenerateAllEpisodeLinks() { - try - { - RepoFactory.CrossRef_AniDB_TvDB_Episode.DeleteAllUnverifiedLinks(); - RepoFactory.AnimeSeries.GetAll().ToList().AsParallel().ForAll(animeseries => - TvDBLinkingHelper.GenerateTvDBEpisodeMatches(animeseries.AniDB_ID, true)); - } - catch (Exception e) - { - logger.Error(e); - return APIStatus.InternalError(e.Message); - } - return APIStatus.OK(); } - public class EpisodeMatchComparison - { - public string Anime { get; set; } - public int AnimeID { get; set; } - public IEnumerable<(AniEpSummary AniDB, TvDBEpSummary TvDB)> Current { get; set; } - public IEnumerable<(AniEpSummary AniDB, TvDBEpSummary TvDB)> Calculated { get; set; } - } - public class AniEpSummary { public int AniDBEpisodeType { get; set; } @@ -476,119 +455,10 @@ public override int GetHashCode() } } - public class TvDBEpSummary - { - public int TvDBSeason { get; set; } - public int TvDBEpisodeNumber { get; set; } - public string TvDBEpisodeName { get; set; } - - protected bool Equals(TvDBEpSummary other) - { - return TvDBSeason == other.TvDBSeason && TvDBEpisodeNumber == other.TvDBEpisodeNumber && - string.Equals(TvDBEpisodeName, other.TvDBEpisodeName); - } - - public override bool Equals(object obj) - { - if (ReferenceEquals(null, obj)) - { - return false; - } - - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj.GetType() != GetType()) - { - return false; - } - - return Equals((TvDBEpSummary)obj); - } - - public override int GetHashCode() - { - unchecked - { - var hashCode = TvDBSeason; - hashCode = (hashCode * 397) ^ TvDBEpisodeNumber; - hashCode = (hashCode * 397) ^ (TvDBEpisodeName != null ? TvDBEpisodeName.GetHashCode() : 0); - return hashCode; - } - } - } - [HttpGet("tvdb/checklinks")] - public ActionResult> CheckAllEpisodeLinksAgainstCurrent() + public ActionResult> CheckAllEpisodeLinksAgainstCurrent() { - try - { - // This is for testing changes in the algorithm. It will be slow. - var list = RepoFactory.AnimeSeries.GetAll().Select(a => a.AniDB_Anime) - .Where(a => !string.IsNullOrEmpty(a?.MainTitle)).OrderBy(a => a.MainTitle).ToList(); - var result = new List(); - foreach (var animeseries in list) - { - var tvxrefs = - RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(animeseries.AnimeID); - var tvdbID = tvxrefs.FirstOrDefault()?.TvDBID ?? 0; - var matches = TvDBLinkingHelper.GetTvDBEpisodeMatches(animeseries.AnimeID, tvdbID).Select(a => ( - AniDB: new AniEpSummary - { - AniDBEpisodeType = a.AniDB.EpisodeType, - AniDBEpisodeNumber = a.AniDB.EpisodeNumber, - AniDBEpisodeName = a.AniDB.DefaultTitle - }, - TvDB: a.TvDB == null - ? null - : new TvDBEpSummary - { - TvDBSeason = a.TvDB.SeasonNumber, - TvDBEpisodeNumber = a.TvDB.EpisodeNumber, - TvDBEpisodeName = a.TvDB.EpisodeName - })).OrderBy(a => a.AniDB.AniDBEpisodeType).ThenBy(a => a.AniDB.AniDBEpisodeNumber).ToList(); - var currentMatches = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAnimeID(animeseries.AnimeID) - .Select(a => - { - var AniDB = RepoFactory.AniDB_Episode.GetByEpisodeID(a.AniDBEpisodeID); - var TvDB = RepoFactory.TvDB_Episode.GetByTvDBID(a.TvDBEpisodeID); - return ( - AniDB: new AniEpSummary - { - AniDBEpisodeType = AniDB.EpisodeType, - AniDBEpisodeNumber = AniDB.EpisodeNumber, - AniDBEpisodeName = AniDB.DefaultTitle - }, - TvDB: TvDB == null - ? null - : new TvDBEpSummary - { - TvDBSeason = TvDB.SeasonNumber, - TvDBEpisodeNumber = TvDB.EpisodeNumber, - TvDBEpisodeName = TvDB.EpisodeName - }); - }).OrderBy(a => a.AniDB.AniDBEpisodeType).ThenBy(a => a.AniDB.AniDBEpisodeNumber).ToList(); - if (!currentMatches.SequenceEqual(matches)) - { - result.Add(new EpisodeMatchComparison - { - Anime = animeseries.MainTitle, - AnimeID = animeseries.AnimeID, - Current = currentMatches, - Calculated = matches - }); - } - } - - return result; - } - catch (Exception e) - { - logger.Error(e); - return APIStatus.InternalError(e.Message); - } + return new List(); } #endregion @@ -600,9 +470,9 @@ public ActionResult> CheckAllEpisodeLinksAgainstCur /// /// [HttpGet("moviedb/update")] - public async Task ScanMovieDB() + public async Task ScanTMDB() { - await _actionService.RunImport_ScanMovieDB(); + await _actionService.RunImport_ScanTMDB(); return APIStatus.OK(); } @@ -868,9 +738,8 @@ public ActionResult> GetLog(int lines = 10, int posit #region 11. Image Actions [HttpGet("images/update")] - public async Task UpdateImages() + public ActionResult UpdateImages() { - await _actionService.RunImport_UpdateTvDB(true); Utils.ShokoServer.DownloadAllImages(); return APIStatus.OK(); diff --git a/Shoko.Server/API/v2/Modules/Image.cs b/Shoko.Server/API/v2/Modules/Image.cs index bf023daef..170411cfd 100644 --- a/Shoko.Server/API/v2/Modules/Image.cs +++ b/Shoko.Server/API/v2/Modules/Image.cs @@ -1,27 +1,20 @@ using System; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Drawing.Imaging; using System.Globalization; using System.IO; -using System.Linq; using System.Threading.Tasks; +using ImageMagick; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Quartz; -using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Server.API.Annotations; using Shoko.Server.API.v2.Models.core; -using Shoko.Server.Extensions; -using Shoko.Server.ImageDownload; using Shoko.Server.Properties; -using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Shoko; using Shoko.Server.Settings; -using Mime = MimeMapping.MimeUtility; +using Shoko.Server.Utilities; +#nullable enable namespace Shoko.Server.API.v2.Modules; [ApiController] @@ -30,7 +23,6 @@ namespace Shoko.Server.API.v2.Modules; [ApiVersion("2.0")] public class Image : BaseController { - private readonly ILogger _logger; private readonly ISchedulerFactory _schedulerFactory; [HttpGet("validateall")] @@ -50,15 +42,14 @@ public async Task ValidateAll() [HttpGet("{type}/{id}")] public FileResult GetImage(int type, int id) { - var path = GetImagePath(type, id); - - if (string.IsNullOrEmpty(path)) + var metadata = ImageUtils.GetImageMetadata((CL_ImageEntityType)type, id); + if (metadata is null || metadata.GetStream() is not { } stream) { Response.StatusCode = 404; return File(MissingImage(), "image/png"); } - return File(System.IO.File.OpenRead(path), Mime.GetMimeMapping(path)); + return File(stream, metadata.ContentType); } /// @@ -71,26 +62,18 @@ public FileResult GetImage(int type, int id) [HttpGet("thumb/{type}/{id}/{ratio?}")] public FileResult GetThumb(int type, int id, string ratio = "0") { - string contentType; ratio = ratio.Replace(',', '.'); - if (!float.TryParse(ratio, NumberStyles.AllowDecimalPoint, CultureInfo.CreateSpecificCulture("en-EN"), - out var newratio)) - { - newratio = 0.6667f; - } - - var path = GetImagePath(type, id); + if (!float.TryParse(ratio, NumberStyles.AllowDecimalPoint, CultureInfo.CreateSpecificCulture("en-EN"), out var newRatio)) + newRatio = 0.6667f; - if (string.IsNullOrEmpty(path)) + var metadata = ImageUtils.GetImageMetadata((CL_ImageEntityType)type, id); + if (metadata is null || metadata.GetStream() is not { } stream) { Response.StatusCode = 404; return File(MissingImage(), "image/png"); } - var fs = System.IO.File.OpenRead(path); - contentType = Mime.GetMimeMapping(path); - var im = System.Drawing.Image.FromStream(fs); - return File(ResizeImageToRatio(im, newratio), contentType); + return File(ResizeImageToRatio(stream, newRatio), metadata.ContentType); } /// @@ -104,21 +87,14 @@ public FileResult GetThumb(int type, int id, string ratio = "0") public ActionResult GetSupportImage(string name) { if (string.IsNullOrEmpty(name)) - { return APIStatus.NotFound(); - } name = Path.GetFileNameWithoutExtension(name); - var man = Resources.ResourceManager; - var dta = (byte[])man.GetObject(name); - if (dta == null || dta.Length == 0) - { + if (string.IsNullOrEmpty(name) || Resources.ResourceManager.GetObject(name) is not byte[] dta || dta is { Length: 0 }) return APIStatus.NotFound(); - } var ms = new MemoryStream(dta); ms.Seek(0, SeekOrigin.Begin); - return File(ms, "image/png"); } @@ -128,288 +104,16 @@ public ActionResult GetSupportImage(string name) public ActionResult GetSupportImage(string name, string ratio) { if (string.IsNullOrEmpty(name)) - { return APIStatus.NotFound(); - } - - ratio = ratio.Replace(',', '.'); - float.TryParse(ratio, NumberStyles.AllowDecimalPoint, - CultureInfo.CreateSpecificCulture("en-EN"), out var newratio); name = Path.GetFileNameWithoutExtension(name); - var man = Resources.ResourceManager; - var dta = (byte[])man.GetObject(name); - if (dta == null || dta.Length == 0) - { + if (string.IsNullOrEmpty(name) || Resources.ResourceManager.GetObject(name) is not byte[] dta || dta is { Length: 0 }) return APIStatus.NotFound(); - } var ms = new MemoryStream(dta); ms.Seek(0, SeekOrigin.Begin); - var im = System.Drawing.Image.FromStream(ms); - - return File(ResizeImageToRatio(im, newratio), "image/png"); - } - - /// - /// Internal function that return valid image file path on server that exist - /// - /// image id - /// image type - /// string - internal string GetImagePath(int type, int id) - { - var imageType = (ImageEntityType)type; - string path; - - switch (imageType) - { - // 1 - case ImageEntityType.AniDB_Cover: - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(id); - if (anime == null) - { - return null; - } - - path = anime.PosterPath; - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find AniDB_Cover image: {Poster}", anime.PosterPath); - } - - break; - - // 2 - case ImageEntityType.AniDB_Character: - var chr = RepoFactory.AniDB_Character.GetByCharID(id); - if (chr == null) - { - return null; - } - - path = chr.GetPosterPath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find AniDB_Character image: {Poster}", chr.GetPosterPath()); - } - - break; - - // 3 - case ImageEntityType.AniDB_Creator: - var creator = RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(id); - if (creator == null) - { - return null; - } - - path = creator.GetPosterPath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find AniDB_Creator image: {Poster}", creator.GetPosterPath()); - } - - break; - - // 4 - case ImageEntityType.TvDB_Banner: - var wideBanner = RepoFactory.TvDB_ImageWideBanner.GetByID(id); - if (wideBanner == null) - { - return null; - } - - path = wideBanner.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find TvDB_Banner image: {Poster}", wideBanner.GetFullImagePath()); - } - - break; - - // 5 - case ImageEntityType.TvDB_Cover: - var poster = RepoFactory.TvDB_ImagePoster.GetByID(id); - if (poster == null) - { - return null; - } - - path = poster.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find TvDB_Cover image: {Poster}", poster.GetFullImagePath()); - } - - break; - - // 6 - case ImageEntityType.TvDB_Episode: - var ep = RepoFactory.TvDB_Episode.GetByID(id); - if (ep == null) - { - return null; - } - - path = ep.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find TvDB_Episode image: {Poster}", ep.GetFullImagePath()); - } - - break; - - // 7 - case ImageEntityType.TvDB_FanArt: - var fanart = RepoFactory.TvDB_ImageFanart.GetByID(id); - if (fanart == null) - { - return null; - } - - path = fanart.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - - path = string.Empty; - _logger.LogTrace("Could not find TvDB_FanArt image: {Poster}", fanart.GetFullImagePath()); - break; - - // 8 - case ImageEntityType.MovieDB_FanArt: - var mFanart = RepoFactory.MovieDB_Fanart.GetByID(id); - if (mFanart == null) - { - return null; - } - - mFanart = RepoFactory.MovieDB_Fanart.GetByOnlineID(mFanart.URL); - if (mFanart == null) - { - return null; - } - - path = mFanart.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find MovieDB_FanArt image: {Poster}", mFanart.GetFullImagePath()); - } - - break; - - // 9 - case ImageEntityType.MovieDB_Poster: - var mPoster = RepoFactory.MovieDB_Poster.GetByID(id); - if (mPoster == null) - { - return null; - } - - mPoster = RepoFactory.MovieDB_Poster.GetByOnlineID(mPoster.URL); - if (mPoster == null) - { - return null; - } - - path = mPoster.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find MovieDB_Poster image: {Poster}", mPoster.GetFullImagePath()); - } - - break; - - case ImageEntityType.Character: - var character = RepoFactory.AnimeCharacter.GetByID(id); - if (character == null) - { - return null; - } - - path = ImageUtils.GetBaseAniDBCharacterImagesPath() + Path.DirectorySeparatorChar + character.ImagePath; - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find Character image: {Poster}", - ImageUtils.GetBaseAniDBCharacterImagesPath() + Path.DirectorySeparatorChar + character.ImagePath); - } - - break; - - case ImageEntityType.Staff: - var staff = RepoFactory.AnimeStaff.GetByID(id); - if (staff == null) - { - return null; - } - - path = ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar + staff.ImagePath; - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find Staff image: {Poster}", - ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar + staff.ImagePath); - } - - break; - - default: - path = string.Empty; - break; - } - - return path; + float.TryParse(ratio.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.CreateSpecificCulture("en-EN"), out var newRatio); + return File(ResizeImageToRatio(ms, newRatio), "image/png"); } /// @@ -420,287 +124,25 @@ internal string GetImagePath(int type, int id) [HttpGet("{type}/random")] public FileResult GetRandomImage(int type) { - var path = GetRandomImagePath(type); - - if (string.IsNullOrEmpty(path)) + // Try 5 times to find a **valid** random image. + var tries = 0; + var imageType = (CL_ImageEntityType)type; + while (tries++ < 5) { - Response.StatusCode = 404; - return File(MissingImage(), "image/png"); + var metadata = ImageUtils.GetRandomImageID(imageType); + if (metadata is not null && metadata.GetStream(allowRemote: false) is { } stream) + return File(stream, metadata.ContentType); } - return File(System.IO.File.OpenRead(path), Mime.GetMimeMapping(path)); - } - - private string GetRandomImagePath(int type) - { - var imageType = (ImageEntityType)type; - string path; - - switch (imageType) - { - // 1 - case ImageEntityType.AniDB_Cover: - var anime = RepoFactory.AniDB_Anime.GetAll() - .Where(a => a?.PosterPath != null && !a.GetAllTags().Contains("18 restricted")) - .GetRandomElement(); - if (anime == null) - { - return null; - } - - path = anime.PosterPath; - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find AniDB_Cover image: {Poster}", anime.PosterPath); - } - - break; - - // 2 - case ImageEntityType.AniDB_Character: - var chr = RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => a.Characters).Select(a => a.GetCharacter()).Where(a => a != null) - .GetRandomElement(); - if (chr == null) - { - return null; - } - - path = chr.GetPosterPath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find AniDB_Character image: {Poster}", chr.GetPosterPath()); - } - - break; - - // 3 -- this will likely be slow - case ImageEntityType.AniDB_Creator: - var creator = RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => a.Characters) - .SelectMany(a => RepoFactory.AniDB_Character_Seiyuu.GetByCharID(a.CharID)) - .Select(a => RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(a.SeiyuuID)).Where(a => a != null) - .GetRandomElement(); - if (creator == null) - { - return null; - } - - path = creator.GetPosterPath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find AniDB_Creator image: {Poster}", creator.GetPosterPath()); - } - - break; - - // 4 - case ImageEntityType.TvDB_Banner: - // TvDB doesn't allow H content, so we get to skip the check! - var wideBanner = RepoFactory.TvDB_ImageWideBanner.GetAll().GetRandomElement(); - if (wideBanner == null) - { - return null; - } - - path = wideBanner.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find TvDB_Banner image: {Poster}", wideBanner.GetFullImagePath()); - } - - break; - - // 5 - case ImageEntityType.TvDB_Cover: - // TvDB doesn't allow H content, so we get to skip the check! - var poster = RepoFactory.TvDB_ImagePoster.GetAll().GetRandomElement(); - if (poster == null) - { - return null; - } - - path = poster.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find TvDB_Cover image: {Poster}", poster.GetFullImagePath()); - } - - break; - - // 6 - case ImageEntityType.TvDB_Episode: - // TvDB doesn't allow H content, so we get to skip the check! - var ep = RepoFactory.TvDB_Episode.GetAll().GetRandomElement(); - if (ep == null) - { - return null; - } - - path = ep.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find TvDB_Episode image: {Poster}", ep.GetFullImagePath()); - } - - break; - - // 7 - case ImageEntityType.TvDB_FanArt: - // TvDB doesn't allow H content, so we get to skip the check! - var fanart = RepoFactory.TvDB_ImageFanart.GetAll().GetRandomElement(); - if (fanart == null) - { - return null; - } - - path = fanart.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - - path = string.Empty; - _logger.LogTrace("Could not find TvDB_FanArt image: {Poster}", fanart.GetFullImagePath()); - break; - - // 8 - case ImageEntityType.MovieDB_FanArt: - var mFanart = RepoFactory.MovieDB_Fanart.GetAll().GetRandomElement(); - if (mFanart == null) - { - return null; - } - - path = mFanart.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find MovieDB_FanArt image: {Poster}", mFanart.GetFullImagePath()); - } - - break; - - // 9 - case ImageEntityType.MovieDB_Poster: - var mPoster = RepoFactory.MovieDB_Poster.GetAll().GetRandomElement(); - if (mPoster == null) - { - return null; - } - - path = mPoster.GetFullImagePath(); - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find MovieDB_Poster image: {Poster}", mPoster.GetFullImagePath()); - } - - break; - - case ImageEntityType.Character: - var character = RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => RepoFactory.CrossRef_Anime_Staff.GetByAnimeID(a.AnimeID)) - .Where(a => a.RoleType == (int)StaffRoleType.Seiyuu && a.RoleID.HasValue) - .Select(a => RepoFactory.AnimeCharacter.GetByID(a.RoleID.Value)).GetRandomElement(); - if (character == null) - { - return null; - } - - path = ImageUtils.GetBaseAniDBCharacterImagesPath() + Path.DirectorySeparatorChar + character.ImagePath; - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find Character image: {Poster}", - ImageUtils.GetBaseAniDBCharacterImagesPath() + Path.DirectorySeparatorChar + - character.ImagePath); - } - - break; - - case ImageEntityType.Staff: - var staff = RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => RepoFactory.CrossRef_Anime_Staff.GetByAnimeID(a.AnimeID)) - .Select(a => RepoFactory.AnimeStaff.GetByID(a.StaffID)).GetRandomElement(); - if (staff == null) - { - return null; - } - - path = ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar + staff.ImagePath; - if (System.IO.File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - _logger.LogTrace("Could not find Staff image: {Poster}", - ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar + staff.ImagePath); - } - - break; - - default: - path = string.Empty; - break; - } - - return path; + Response.StatusCode = 404; + return File(MissingImage(), "image/png"); } /// /// Internal function that return image for missing image /// /// Stream - internal Stream MissingImage() + internal static Stream MissingImage() { var dta = Resources.blank; var ms = new MemoryStream(dta); @@ -708,76 +150,45 @@ internal Stream MissingImage() return ms; } - internal static System.Drawing.Image ResizeImage(System.Drawing.Image im, int width, int height) + internal static Stream ResizeImageToRatio(Stream imageStream, float newRatio) { - var dest = new Bitmap(width, height); - using (var g = Graphics.FromImage(dest)) - { - g.InterpolationMode = width >= im.Width - ? InterpolationMode.HighQualityBilinear - : InterpolationMode.HighQualityBicubic; - g.PixelOffsetMode = PixelOffsetMode.HighQuality; - g.SmoothingMode = SmoothingMode.HighQuality; - g.DrawImage(im, 0, 0, width, height); - } + if (Math.Abs(newRatio) < 0.1F) + return imageStream; - return dest; - } + var image = new MagickImage(imageStream); + float originalWidth = image.Width; + float originalHeight = image.Height; + int newWidth, newHeight; - internal Stream ResizeImageToRatio(System.Drawing.Image im, float newratio) - { - float calcwidth = im.Width; - float calcheight = im.Height; - - if (Math.Abs(newratio) < 0.1F) - { - var stream = new MemoryStream(); - im.Save(stream, ImageFormat.Png); - stream.Seek(0, SeekOrigin.Begin); - return stream; - } + var calculatedWidth = originalWidth; + var calculatedHeight = originalHeight; - float nheight; do { - nheight = calcwidth / newratio; - if (nheight > im.Height + 0.5F) + var newHeightFloat = calculatedWidth / newRatio; + if (newHeightFloat > originalHeight + 0.5F) { - calcwidth = calcwidth * (im.Height / nheight); + calculatedWidth *= originalHeight / newHeightFloat; } else { - calcheight = nheight; + calculatedHeight = newHeightFloat; } - } while (nheight > im.Height + 0.5F); + } while (calculatedHeight > originalHeight + 0.5F); - var newwidth = (int)Math.Round(calcwidth); - var newheight = (int)Math.Round(calcheight); - var x = 0; - var y = 0; - if (newwidth < im.Width) - { - x = (im.Width - newwidth) / 2; - } + newWidth = (int)Math.Round(calculatedWidth); + newHeight = (int)Math.Round(calculatedHeight); + image.Resize(new MagickGeometry(newWidth, newHeight)); - if (newheight < im.Height) - { - y = (im.Height - newheight) / 2; - } + var outStream = new MemoryStream(); + image.Write(outStream, MagickFormat.Png); + outStream.Seek(0, SeekOrigin.Begin); - var im2 = ResizeImage(im, newwidth, newheight); - var g = Graphics.FromImage(im2); - g.DrawImage(im, new Rectangle(0, 0, im2.Width, im2.Height), new Rectangle(x, y, im2.Width, im2.Height), - GraphicsUnit.Pixel); - var ms = new MemoryStream(); - im2.Save(ms, ImageFormat.Png); - ms.Seek(0, SeekOrigin.Begin); - return ms; + return outStream; } - public Image(ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, ILogger logger) : base(settingsProvider) + public Image(ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory) : base(settingsProvider) { _schedulerFactory = schedulerFactory; - _logger = logger; } } diff --git a/Shoko.Server/API/v2/Modules/PlexWebhook.cs b/Shoko.Server/API/v2/Modules/PlexWebhook.cs index 6568f36d6..a0c3e8053 100644 --- a/Shoko.Server/API/v2/Modules/PlexWebhook.cs +++ b/Shoko.Server/API/v2/Modules/PlexWebhook.cs @@ -100,7 +100,7 @@ private void TraktScrobble(PlexEvent evt, ScrobblePlayingStatus type) if (episode == null) return; var vl = RepoFactory.VideoLocal.GetByAniDBEpisodeID(episode.AniDB_EpisodeID).FirstOrDefault(); - if (vl == null || vl.Duration == 0) return; + if (vl == null || vl.Duration == 0) return; var per = 100 * (metadata.ViewOffset / @@ -201,17 +201,15 @@ await watchedService.SetWatchedStatus(episode, true, true, FromUnixTime(metadata //if only one possible match if (animeEps.Count == 1) return (animeEps.First(), anime); - //if TvDB matched. + // Check for Tmdb matches SVR_AnimeEpisode result; - if ((result = animeEps.FirstOrDefault(a => a.TvDBEpisode?.SeasonNumber == series)) != null) + if ((result = animeEps.FirstOrDefault(a => a.TmdbEpisodes.Any(e => e.SeasonNumber == series))) != null) { return (result, anime); } - //catch all - _logger.LogInformation( - $"Unable to work out the metadata for {metadata.Guid}, this might be a clash of multipl episodes linked, but no tvdb link."); + _logger.LogInformation($"Unable to work out the metadata for {metadata.Guid}, this might be a clash of multiple episodes linked, but no tmdb link."); return (null, anime); } @@ -295,12 +293,20 @@ public ActionResult SetLibraries([FromBody] List ids) { return CallPlexHelper(h => { + if (ids.Count == 0) + { + SettingsProvider.GetSettings().Plex.Libraries = new (); + SettingsProvider.SaveSettings(); + return APIStatus.OK(); + } + var dirs = h.GetDirectories(); var selected = dirs.Where(d => ids.Contains(d.Key)).ToList(); if (selected.Count == 0) return APIStatus.BadRequest("No directories found please ensure server token is set and try again"); SettingsProvider.GetSettings().Plex.Libraries = selected.Select(s => s.Key).ToList(); + SettingsProvider.SaveSettings(); return APIStatus.OK(); }); } diff --git a/Shoko.Server/API/v2/Modules/Webui.cs b/Shoko.Server/API/v2/Modules/Webui.cs index 68889a2e7..cfcda44d8 100644 --- a/Shoko.Server/API/v2/Modules/Webui.cs +++ b/Shoko.Server/API/v2/Modules/Webui.cs @@ -143,7 +143,7 @@ public object SetWebUIConfig(WebUI_Settings webuiSettings) /// /// List all available themes to use inside webui /// - /// List with 'name' of css files + /// List<OSFile> with 'name' of css files private object GetWebUIThemes() { var files = new List(); diff --git a/Shoko.Server/API/v3/Controllers/ActionController.cs b/Shoko.Server/API/v3/Controllers/ActionController.cs index 3d72c0f7a..137702115 100644 --- a/Shoko.Server/API/v3/Controllers/ActionController.cs +++ b/Shoko.Server/API/v3/Controllers/ActionController.cs @@ -8,10 +8,11 @@ using Microsoft.Extensions.Logging; using Quartz; using Shoko.Server.API.Annotations; -using Shoko.Server.API.v3.Helpers; using Shoko.Server.API.v3.Models.Shoko; using Shoko.Server.Providers.AniDB; -using Shoko.Server.Providers.MovieDB; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.Info; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Providers.TraktTV; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; @@ -37,21 +38,21 @@ public class ActionController : BaseController private readonly ActionService _actionService; private readonly AnimeGroupService _groupService; private readonly TraktTVHelper _traktHelper; - private readonly MovieDBHelper _movieDBHelper; + private readonly TmdbMetadataService _tmdbService; private readonly ISchedulerFactory _schedulerFactory; - private readonly JobFactory _jobFactory; - private readonly SeriesFactory _seriesFactory; + private readonly IRequestFactory _requestFactory; + private readonly AnimeSeriesService _seriesService; - public ActionController(ILogger logger, TraktTVHelper traktHelper, MovieDBHelper movieDBHelper, ISchedulerFactory schedulerFactory, - ISettingsProvider settingsProvider, JobFactory jobFactory, ActionService actionService, SeriesFactory seriesFactory, AnimeGroupCreator groupCreator, AnimeGroupService groupService) : base(settingsProvider) + public ActionController(ILogger logger, TraktTVHelper traktHelper, TmdbMetadataService tmdbService, ISchedulerFactory schedulerFactory, + IRequestFactory requestFactory, ISettingsProvider settingsProvider, ActionService actionService, AnimeSeriesService seriesService, AnimeGroupCreator groupCreator, AnimeGroupService groupService) : base(settingsProvider) { _logger = logger; _traktHelper = traktHelper; - _movieDBHelper = movieDBHelper; + _tmdbService = tmdbService; _schedulerFactory = schedulerFactory; - _jobFactory = jobFactory; + _requestFactory = requestFactory; _actionService = actionService; - _seriesFactory = seriesFactory; + _seriesService = seriesService; _groupCreator = groupCreator; _groupService = groupService; } @@ -59,7 +60,7 @@ public ActionController(ILogger logger, TraktTVHelper traktHel #region Common Actions /// - /// Run Import. This checks for new files, hashes them etc, scans Drop Folders, checks and scans for community site links (tvdb, trakt, moviedb, etc), and downloads missing images. + /// Run Import. This checks for new files, hashes them etc, scans Drop Folders, checks and scans for community site links (tmdb, trakt, etc), and downloads missing images. /// /// [HttpGet("RunImport")] @@ -135,36 +136,70 @@ public async Task RemoveMissingFiles(bool removeFromMyList = true) } /// - /// Update All TvDB Series Info + /// Updates and Downloads Missing Images /// /// - [HttpGet("UpdateAllTvDBInfo")] - public async Task UpdateAllTvDBInfo() + [HttpGet("UpdateAllImages")] + public ActionResult UpdateAllImages() { - await _actionService.RunImport_UpdateTvDB(false); + Utils.ShokoServer.DownloadAllImages(); return Ok(); } + /// - /// Updates and Downloads Missing Images + /// Updates All TMDB Movie Info. /// /// - [HttpGet("UpdateAllImages")] - public ActionResult UpdateAllImages() + [Obsolete("Use 'UpdateAllTMDBMovieInfo' instead.")] + [HttpGet("UpdateAllMovieDBInfo")] + public ActionResult UpdateAllMovieDBInfo() { - Utils.ShokoServer.DownloadAllImages(); + Task.Factory.StartNew(() => _tmdbService.UpdateAllMovies(true, true)); return Ok(); } /// - /// Updates All MovieDB Info + /// Updates all TMDB Movies in the local database. /// /// - [HttpGet("UpdateAllMovieDBInfo")] - public ActionResult UpdateAllMovieDBInfo() + [HttpGet("UpdateAllTmdbMovies")] + public ActionResult UpdateAllTmdbMovies() + { + Task.Factory.StartNew(() => _tmdbService.UpdateAllMovies(true, true)); + return Ok(); + } + + /// + /// Purge all unused TMDB Movies that are not linked to any AniDB anime. + /// + /// + [HttpGet("PurgeAllUnusedTmdbMovies")] + public ActionResult PurgeAllUnusedTmdbMovies() + { + Task.Factory.StartNew(() => _tmdbService.PurgeAllUnusedMovies()); + return Ok(); + } + + /// + /// Update all TMDB Shows in the local database. + /// + /// + [HttpGet("UpdateAllTmdbShows")] + public ActionResult UpdateAllTmdbShows() { - // fire and forget - Task.Factory.StartNew(async () => await _movieDBHelper.UpdateAllMovieInfo(true)); + Task.Factory.StartNew(() => _tmdbService.UpdateAllShows(true, true)); + return Ok(); + } + + /// + /// Purge all unused TMDB Shows that are not linked to any AniDB anime. + /// + /// + [HttpGet("PurgeAllUnusedTmdbShows")] + public ActionResult PurgeAllUnusedTmdbShows() + { + Task.Factory.StartNew(() => _tmdbService.PurgeAllUnusedShows()); return Ok(); } @@ -187,7 +222,7 @@ public ActionResult UpdateTraktInfo() } /// - /// Validates invalid images and redownloads them + /// Validates invalid images and re-downloads them /// /// [HttpGet("ValidateAllImages")] @@ -202,6 +237,21 @@ public async Task ValidateAllImages() #region Admin Actions + /// + /// Purges all TVDB data, including images and episode/series links. + /// This is a one-time action and will be blocked if ran again. + ///
+ /// This action is only accessible to admins. + ///
+ /// + [Authorize("admin")] + [HttpGet("PurgeAllOfTvDB")] + [HttpPost("PurgeAllOfTvDB")] + public ActionResult PurgeAllTvdbData() + { + return Ok(); + } + /// /// Gets files whose data does not match AniDB /// @@ -217,7 +267,7 @@ public async Task AVDumpMismatchedFiles() var mismatchedFiles = RepoFactory.VideoLocal.GetAll() .Where(file => !file.IsEmpty() && file.MediaInfo != null) .Select(file => (Video: file, AniDB: file.AniDBFile)) - .Where(tuple => tuple.AniDB is { IsDeprecated: false } && tuple.Video.MediaInfo?.MenuStreams.Any() != tuple.AniDB.IsChaptered) + .Where(tuple => tuple.AniDB is { IsDeprecated: false } && tuple.Video.MediaInfo?.MenuStreams.Count != 0 != tuple.AniDB.IsChaptered) .Select(tuple => (Path: tuple.Video.FirstResolvedPlace?.FullServerPath, tuple.Video)) .Where(tuple => !string.IsNullOrEmpty(tuple.Path)) .ToDictionary(tuple => tuple.Video.VideoLocalID, tuple => tuple.Path); @@ -239,7 +289,7 @@ public async Task AVDumpMismatchedFiles() /// [Authorize("admin")] [HttpGet("DownloadMissingAniDBAnimeData")] - public async Task UpdateMissingAniDBXML() + public async Task UpdateMissingAnidbXml() { // Check existing anime. var index = 0; @@ -260,18 +310,64 @@ public async Task UpdateMissingAniDBXML() if (rawXml != null) continue; - await _seriesFactory.QueueAniDBRefresh(_schedulerFactory, _jobFactory, animeID, true, false, false); + await _seriesService.QueueAniDBRefresh(animeID, true, false, false); queuedAnimeSet.Add(animeID); } + // Attempt to fix cross-references with incomplete data. + index = 0; + var videos = RepoFactory.VideoLocal.GetVideosWithMissingCrossReferenceData(); + var unknownEpisodeDict = videos + .SelectMany(file => file.EpisodeCrossRefs) + .Where(xref => xref.AnimeID is 0) + .GroupBy(xref => xref.EpisodeID) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.ToList()); + _logger.LogInformation("Attempting to fix {MissingAnimeCount} cross-references with unknown anime…", unknownEpisodeDict.Count); + foreach (var (episodeId, xrefs) in unknownEpisodeDict) + { + if (++index % 10 == 1) + _logger.LogInformation("Attempting to fix {MissingAnimeCount} cross-references with unknown anime — {CurrentCount}/{MissingAnimeCount}", unknownEpisodeDict.Count, index + 1, unknownEpisodeDict.Count); + + var episode = RepoFactory.AniDB_Episode.GetByEpisodeID(episodeId); + if (episode is not null) + { + foreach (var xref in xrefs) + { + xref.AnimeID = episode.AnimeID; + } + RepoFactory.CrossRef_File_Episode.Save(xrefs); + continue; + } + int? epAnimeID = null; + var epRequest = _requestFactory.Create(r => r.EpisodeID = episodeId); + try + { + var epResponse = epRequest.Send(); + epAnimeID = epResponse.Response?.AnimeID; + } + catch (Exception e) + { + _logger.LogError(e, "Could not get Episode Info for {EpisodeID}", episode.EpisodeID); + } + + if (epAnimeID is not null) + { + foreach (var xref in xrefs) + { + xref.AnimeID = epAnimeID.Value; + } + RepoFactory.CrossRef_File_Episode.Save(xrefs); + } + } + // Queue missing anime needed by existing files. index = 0; var localEpisodeSet = RepoFactory.AniDB_Episode.GetAll() .Select(episode => episode.EpisodeID) .ToHashSet(); - var missingAnimeSet = RepoFactory.VideoLocal.GetVideosWithMissingCrossReferenceData() + var missingAnimeSet = videos .SelectMany(file => file.EpisodeCrossRefs) - .Where(xref => !queuedAnimeSet.Contains(xref.AnimeID) && (!localAnimeSet.Contains(xref.AnimeID) || !localEpisodeSet.Contains(xref.EpisodeID))) + .Where(xref => xref.AnimeID > 0 && !queuedAnimeSet.Contains(xref.AnimeID) && (!localAnimeSet.Contains(xref.AnimeID) || !localEpisodeSet.Contains(xref.EpisodeID))) .Select(xref => xref.AnimeID) .ToHashSet(); _logger.LogInformation("Queueing {MissingAnimeCount} anime that needs an update…", missingAnimeSet.Count); @@ -280,7 +376,7 @@ public async Task UpdateMissingAniDBXML() if (++index % 10 == 1) _logger.LogInformation("Queueing {MissingAnimeCount} anime that needs an update — {CurrentCount}/{MissingAnimeCount}", missingAnimeSet.Count, index + 1, missingAnimeSet.Count); - await _seriesFactory.QueueAniDBRefresh(_schedulerFactory, _jobFactory, animeID, false, true, true); + await _seriesService.QueueAniDBRefresh(animeID, false, true, true); queuedAnimeSet.Add(animeID); } @@ -289,26 +385,16 @@ public async Task UpdateMissingAniDBXML() } /// - /// Regenerate All Episode Matchings for TvDB. Generally, don't do this unless there was an error that was fixed. - /// In those cases, you'd be told to. + /// Downloads all missing or partially missing AniDB creators over the UDP + /// API. Will do nothing if downloading creator data is set to + /// . /// /// [Authorize("admin")] - [HttpGet("RegenerateAllTvDBEpisodeMatchings")] - public ActionResult RegenerateAllEpisodeLinks() + [HttpGet("DownloadMissingAniDBCreators")] + public ActionResult ScheduleMissingAniDBCreators() { - try - { - RepoFactory.CrossRef_AniDB_TvDB_Episode.DeleteAllUnverifiedLinks(); - RepoFactory.AnimeSeries.GetAll().ToList().AsParallel().ForAll(animeseries => - TvDBLinkingHelper.GenerateTvDBEpisodeMatches(animeseries.AniDB_ID, true)); - } - catch (Exception e) - { - _logger.LogError(e, e.Message); - return InternalError(e.Message); - } - + Task.Run(_actionService.ScheduleMissingAnidbCreators); return Ok(); } @@ -357,9 +443,9 @@ public ActionResult UpdateAllMediaInfo() /// [Authorize("admin")] [HttpGet("UpdateSeriesStats")] - public ActionResult UpdateSeriesStats() + public async Task UpdateSeriesStats() { - _actionService.UpdateAllStats(); + await _actionService.UpdateAllStats(); return Ok(); } @@ -432,7 +518,7 @@ public async Task PlexSyncAll() await Utils.ShokoServer.SyncPlex(); return Ok(); } - + /// /// Forcibly runs AddToMyList commands for all manual links /// @@ -451,5 +537,29 @@ public async Task AddAllManualLinksToMyList() return Ok($"Saved {files.Count} AddToMyList Commands"); } + /// + /// Fetch unread notifications and messages from AniDB + /// + /// + [Authorize("admin")] + [HttpGet("GetAniDBNotifications")] + public async Task GetAniDBNotifications() + { + await _actionService.CheckForUnreadNotifications(true); + return Ok(); + } + + /// + /// Process file moved messages from AniDB. This will force an update on the affected files. + /// + /// + [Authorize("admin")] + [HttpGet("RefreshAniDBMovedFiles")] + public async Task RefreshAniDBMovedFiles() + { + await _actionService.RefreshAniDBMovedFiles(true); + return Ok(); + } + #endregion } diff --git a/Shoko.Server/API/v3/Controllers/AniDBController.cs b/Shoko.Server/API/v3/Controllers/AniDBController.cs index f5fa0778a..6ab2cfd72 100644 --- a/Shoko.Server/API/v3/Controllers/AniDBController.cs +++ b/Shoko.Server/API/v3/Controllers/AniDBController.cs @@ -1,14 +1,19 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Shoko.Server.API.Annotations; using Shoko.Server.API.v3.Helpers; +using Shoko.Server.API.v3.Models.AniDB; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.API.v3.Models.Shoko; +using Shoko.Server.Extensions; using Shoko.Server.Repositories; using Shoko.Server.Settings; +#pragma warning disable CA1822 +#nullable enable namespace Shoko.Server.API.v3.Controllers; [ApiController] @@ -17,6 +22,10 @@ namespace Shoko.Server.API.v3.Controllers; [Authorize] public class AniDBController : BaseController { + public AniDBController(ISettingsProvider settingsProvider) : base(settingsProvider) + { + } + /// /// Get the known anidb release groups stored in shoko. /// @@ -41,7 +50,61 @@ public ActionResult> GetReleaseGroups( }; } - public AniDBController(ISettingsProvider settingsProvider) : base(settingsProvider) + /// + /// Get an anidb release group by id. + /// + /// The release group id. + /// + [HttpGet("ReleaseGroup/{id}")] + public ActionResult GetReleaseGroup(int id) + { + var group = RepoFactory.AniDB_ReleaseGroup.GetByGroupID(id); + if (group == null) + return NotFound(); + return new ReleaseGroup(group); + } + + /// + /// Get all anidb creators. + /// + /// The page size. Set to 0 to disable pagination. + /// The page index. + /// + [HttpGet("Creator")] + public ActionResult> GetCreators([FromQuery, Range(0, 1000)] int pageSize = 20, + [FromQuery, Range(1, int.MaxValue)] int page = 1) + { + return RepoFactory.AniDB_Creator.GetAll() + .ToListResult(c => new Creator(c), page, pageSize); + } + + /// + /// Get an anidb creator by id. + /// + /// The creator id. + /// + [HttpGet("Creator/{id}")] + public ActionResult GetCreator(int id) { + var creator = RepoFactory.AniDB_Creator.GetByCreatorID(id); + if (creator == null) + return NotFound(); + + return new Creator(creator); + } + + /// + /// Get an anidb creator by name. + /// + /// The creator name. + /// + [HttpGet("Creator/Name/{name}")] + public ActionResult GetCreator(string name) + { + var creator = RepoFactory.AniDB_Creator.GetByName(name); + if (creator == null) + return NotFound(); + + return new Creator(creator); } } diff --git a/Shoko.Server/API/v3/Controllers/DashboardController.cs b/Shoko.Server/API/v3/Controllers/DashboardController.cs index ad5bd9d44..eef9cf722 100644 --- a/Shoko.Server/API/v3/Controllers/DashboardController.cs +++ b/Shoko.Server/API/v3/Controllers/DashboardController.cs @@ -27,7 +27,6 @@ namespace Shoko.Server.API.v3.Controllers; [Authorize] public class DashboardController : BaseController { - private readonly SeriesFactory _seriesFactory; private readonly QueueHandler _queueHandler; private readonly AnimeSeriesService _seriesService; private readonly AnimeSeries_UserRepository _seriesUser; @@ -95,9 +94,9 @@ public Dashboard.CollectionStats GetStats() var hoursWatched = Math.Round( (decimal)watchedEpisodes.Sum(a => a.VideoLocals.FirstOrDefault()?.DurationTimeSpan.TotalHours ?? new TimeSpan(0, 0, a.AniDB_Episode?.LengthSeconds ?? 0).TotalHours), 1, MidpointRounding.AwayFromZero); + // We cache the video local here since it may be gone later if the files are actively being removed. var places = files - // We cache the video local here since it may be gone later if the files are actively being removed. - .SelectMany(a => a.Places.Select(b => new { VideoLocalID = a.VideoLocalID, VideoLocal = a, Place = b })) + .SelectMany(a => a.Places.Select(b => new { a.VideoLocalID, VideoLocal = a, Place = b })) .ToList(); var duplicates = places .Where(a => !a.VideoLocal.IsVariation) @@ -112,7 +111,7 @@ public Dashboard.CollectionStats GetStats() var multipleEpisodes = episodes.Count(a => a.VideoLocals.Count(b => !b.IsVariation) > 1); var unrecognizedFiles = RepoFactory.VideoLocal.GetVideosWithoutEpisodeUnsorted().Count; var duplicateFiles = places.GroupBy(a => a.VideoLocalID).Count(a => a.Count() > 1); - var seriesWithMissingLinks = allSeries.Count(MissingBothTvDBAndMovieDBLink); + var seriesWithMissingLinks = allSeries.Count(MissingTMDBLink); return new() { FileCount = files.Count, @@ -132,17 +131,14 @@ public Dashboard.CollectionStats GetStats() }; } - private static bool MissingBothTvDBAndMovieDBLink(SVR_AnimeSeries ser) + private static bool MissingTMDBLink(SVR_AnimeSeries ser) { - if (ser.AniDB_Anime.Restricted > 0) - { + if (ser.IsTMDBAutoMatchingDisabled) return false; - } - // this is fast now - var movieLinkMissing = RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(ser.AniDB_ID, CrossRefType.MovieDB) == null; - var tvlinkMissing = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(ser.AniDB_ID).Count == 0; - return movieLinkMissing && tvlinkMissing; + var tmdbMovieLinkMissing = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(ser.AniDB_ID).Count == 0; + var tmdbShowLinkMissing = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(ser.AniDB_ID).Count == 0; + return tmdbMovieLinkMissing && tmdbShowLinkMissing; } /// @@ -152,7 +148,7 @@ private static bool MissingBothTvDBAndMovieDBLink(SVR_AnimeSeries ser) /// The to use. (Defaults to | | ) /// [HttpGet("TopTags/{number}")] - [Obsolete] + [Obsolete("Provide pageSize in query instead.")] public List GetTopTagsObsolete(int number = 10, [FromQuery] TagFilter.Filter filter = TagFilter.Filter.AnidbInternal | TagFilter.Filter.Misc | TagFilter.Filter.Source) @@ -166,9 +162,11 @@ public List GetTopTagsObsolete(int number = 10, /// The to use. (Defaults to | | ) /// [HttpGet("TopTags")] - public List GetTopTags([FromQuery] [Range(0, 100)] int pageSize = 10, [FromQuery] [Range(1, int.MaxValue)] int page = 1, - [FromQuery] TagFilter.Filter filter = - TagFilter.Filter.AnidbInternal | TagFilter.Filter.Misc | TagFilter.Filter.Source) + public List GetTopTags( + [FromQuery, Range(0, 100)] int pageSize = 10, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] TagFilter.Filter filter = TagFilter.Filter.AnidbInternal | TagFilter.Filter.Misc | TagFilter.Filter.Source + ) { var tags = RepoFactory.AniDB_Anime_Tag.GetAllForLocalSeries() .GroupBy(xref => xref.TagID) @@ -234,8 +232,11 @@ public Dashboard.SeriesSummary GetSeriesSummary() /// Include episodes from restricted (H) series. /// [HttpGet("RecentlyAddedEpisodes")] - public List GetRecentlyAddedEpisodes([FromQuery] [Range(0, 100)] int pageSize = 30, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeRestricted = false) + public ListResult GetRecentlyAddedEpisodes( + [FromQuery, Range(0, 1000)] int pageSize = 30, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] bool includeRestricted = false + ) { var user = HttpContext.GetUser(); var episodeList = RepoFactory.VideoLocal.GetAll() @@ -249,25 +250,13 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeRestr .ToDictionary(series => series.AnimeSeriesID); var animeDict = seriesDict.Values .ToDictionary(series => series.AnimeSeriesID, series => series.AniDB_Anime); - - if (pageSize <= 0) - { - return episodeList - .Where(tuple => animeDict.TryGetValue(tuple.episode.AnimeSeriesID, out var anime) && - (includeRestricted || anime.Restricted == 0)) - .Select(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, - seriesDict[tuple.episode.AnimeSeriesID], animeDict[tuple.episode.AnimeSeriesID], tuple.file)) - .ToList(); - } - return episodeList - .Where(tuple => animeDict.TryGetValue(tuple.episode.AnimeSeriesID, out var anime) && - (includeRestricted || anime.Restricted == 0)) - .Skip(pageSize * (page - 1)) - .Take(pageSize) - .Select(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, - seriesDict[tuple.episode.AnimeSeriesID], animeDict[tuple.episode.AnimeSeriesID], tuple.file)) - .ToList(); + .Where(tuple => animeDict.TryGetValue(tuple.episode.AnimeSeriesID, out var anime) && (includeRestricted || !anime.IsRestricted)) + .ToListResult( + tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, seriesDict[tuple.episode.AnimeSeriesID], animeDict[tuple.episode.AnimeSeriesID], tuple.file), + page, + pageSize + ); } /// @@ -278,31 +267,22 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeRestr /// Include restricted (H) series. /// [HttpGet("RecentlyAddedSeries")] - public List GetRecentlyAddedSeries([FromQuery] [Range(0, 100)] int pageSize = 20, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeRestricted = false) + public ListResult GetRecentlyAddedSeries( + [FromQuery, Range(0, 1000)] int pageSize = 20, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] bool includeRestricted = false + ) { var user = HttpContext.GetUser(); - var seriesList = RepoFactory.VideoLocal.GetAll() + return RepoFactory.VideoLocal.GetAll() .Where(f => f.DateTimeImported.HasValue) .OrderByDescending(f => f.DateTimeImported) .SelectMany(file => file.AnimeEpisodes.Select(episode => episode.AnimeSeriesID)) .Distinct() - .Select(seriesID => RepoFactory.AnimeSeries.GetByID(seriesID)) + .Select(RepoFactory.AnimeSeries.GetByID) .Where(series => series != null && user.AllowedSeries(series) && - (includeRestricted || series.AniDB_Anime.Restricted != 1)); - - if (pageSize <= 0) - { - return seriesList - .Select(a => _seriesFactory.GetSeries(a)) - .ToList(); - } - - return seriesList - .Skip(pageSize * (page - 1)) - .Take(pageSize) - .Select(a => _seriesFactory.GetSeries(a)) - .ToList(); + (includeRestricted || !series.AniDB_Anime.IsRestricted)) + .ToListResult(a => new Series(a, User.JMMUserID), page, pageSize); } /// @@ -311,34 +291,28 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeRestr /// Limits the number of results per page. Set to 0 to disable the limit. /// Page number. /// Include specials in the search. + /// Include other type episodes in the search. /// Include episodes from restricted (H) series. /// [HttpGet("ContinueWatchingEpisodes")] - public List GetContinueWatchingEpisodes([FromQuery] [Range(0, 100)] int pageSize = 20, - [FromQuery] [Range(0, int.MaxValue)] int page = 0, [FromQuery] bool includeSpecials = true, - [FromQuery] bool includeRestricted = false) + public ListResult GetContinueWatchingEpisodes( + [FromQuery, Range(0, 100)] int pageSize = 20, + [FromQuery, Range(0, int.MaxValue)] int page = 0, + [FromQuery] bool includeSpecials = true, + [FromQuery] bool includeOthers = false, + [FromQuery] bool includeRestricted = false + ) { var user = HttpContext.GetUser(); - var episodeList = RepoFactory.AnimeSeries_User.GetByUserID(user.JMMUserID) + return RepoFactory.AnimeSeries_User.GetByUserID(user.JMMUserID) .Where(record => record.LastEpisodeUpdate.HasValue) .OrderByDescending(record => record.LastEpisodeUpdate) .Select(record => RepoFactory.AnimeSeries.GetByID(record.AnimeSeriesID)) - .Where(series => user.AllowedSeries(series) && - (includeRestricted || series.AniDB_Anime.Restricted != 1)) - .Select(series => (series, episode: _seriesService.GetActiveEpisode(series, user.JMMUserID, includeSpecials))) - .Where(tuple => tuple.episode != null); - if (pageSize <= 0) - { - return episodeList - .Select(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, tuple.series)) - .ToList(); - } - - return episodeList - .Skip(pageSize * (page - 1)) - .Take(pageSize) - .Select(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, tuple.series)) - .ToList(); + .Where(series => series is not null && user.AllowedSeries(series) && + (includeRestricted || !series.AniDB_Anime.IsRestricted)) + .Select(series => (series, episode: _seriesService.GetActiveEpisode(series, user.JMMUserID, includeSpecials, includeOthers))) + .Where(tuple => tuple.episode != null) + .ToListResult(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, tuple.series), page, pageSize); } /// @@ -348,49 +322,47 @@ [FromQuery] [Range(0, int.MaxValue)] int page = 0, [FromQuery] bool includeSpeci /// Page number. /// Only show unwatched episodes. /// Include specials in the search. + /// Include other type episodes in the search. /// Include episodes from restricted (H) series. /// Include missing episodes in the list. - /// Include hidden episodes in the list. /// Include already watched episodes in the /// search if we determine the user is "re-watching" the series. /// [HttpGet("NextUpEpisodes")] - public List GetNextUpEpisodes([FromQuery] [Range(0, 100)] int pageSize = 20, - [FromQuery] [Range(0, int.MaxValue)] int page = 0, [FromQuery] bool onlyUnwatched = true, - [FromQuery] bool includeSpecials = true, [FromQuery] bool includeRestricted = false, - [FromQuery] bool includeMissing = false, [FromQuery] bool includeHidden = false, - [FromQuery] bool includeRewatching = false) + public ListResult GetNextUpEpisodes( + [FromQuery, Range(0, 100)] int pageSize = 20, + [FromQuery, Range(0, int.MaxValue)] int page = 0, + [FromQuery] bool onlyUnwatched = true, + [FromQuery] bool includeSpecials = true, + [FromQuery] bool includeOthers = false, + [FromQuery] bool includeRestricted = false, + [FromQuery] bool includeMissing = false, + [FromQuery] bool includeRewatching = false + ) { var user = HttpContext.GetUser(); - var episodeList = RepoFactory.AnimeSeries_User.GetByUserID(user.JMMUserID) + return RepoFactory.AnimeSeries_User.GetByUserID(user.JMMUserID) .Where(record => - record.LastEpisodeUpdate.HasValue && (onlyUnwatched ? record.UnwatchedEpisodeCount > 0 : true)) + record.LastEpisodeUpdate.HasValue && (!onlyUnwatched || record.UnwatchedEpisodeCount > 0)) .OrderByDescending(record => record.LastEpisodeUpdate) .Select(record => RepoFactory.AnimeSeries.GetByID(record.AnimeSeriesID)) .Where(series => user.AllowedSeries(series) && - (includeRestricted || series.AniDB_Anime.Restricted != 1)) - .Select(series => (series, episode: _seriesService.GetNextEpisode(series, user.JMMUserID, new() + (includeRestricted || !series.AniDB_Anime.IsRestricted)) + .Select(series => (series, episode: _seriesService.GetNextUpEpisode( + series, + user.JMMUserID, + new() { DisableFirstEpisode = true, IncludeCurrentlyWatching = !onlyUnwatched, - IncludeHidden = includeHidden, IncludeMissing = includeMissing, IncludeRewatching = includeRewatching, IncludeSpecials = includeSpecials, - }))) - .Where(tuple => tuple.episode != null); - if (pageSize <= 0) - { - return episodeList - .Select(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, tuple.series)) - .ToList(); - } - - return episodeList - .Skip(pageSize * (page - 1)) - .Take(pageSize) - .Select(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, tuple.series)) - .ToList(); + IncludeOthers = includeOthers, + } + ))) + .Where(tuple => tuple.episode is not null) + .ToListResult(tuple => GetEpisodeDetailsForSeriesAndEpisode(user, tuple.episode, tuple.series), page, pageSize); } [NonAction] @@ -401,7 +373,7 @@ public Dashboard.EpisodeDetails GetEpisodeDetailsForSeriesAndEpisode(SVR_JMMUser var animeEpisode = episode.AniDB_Episode; anime ??= series.AniDB_Anime; - if (file != null) + if (file is not null) { userRecord = _vlUsers.GetByUserIDAndVideoLocalID(user.JMMUserID, file.VideoLocalID); } @@ -437,13 +409,13 @@ public Dashboard.EpisodeDetails GetEpisodeDetailsForSeriesAndEpisode(SVR_JMMUser .ToDictionary(anime => anime.AnimeID); var seriesDict = animeDict.Values .Select(anime => RepoFactory.AnimeSeries.GetByAnimeID(anime.AnimeID)) - .Where(series => series != null) + .WhereNotNull() .Distinct() .ToDictionary(anime => anime.AniDB_ID); return episodeList .Where(episode => animeDict.TryGetValue(episode.AnimeID, out var anime) && user.AllowedAnime(anime) && - (includeRestricted || anime.Restricted == 0) && + (includeRestricted || !anime.IsRestricted) && (showAll || seriesDict.ContainsKey(episode.AnimeID))) .OrderBy(episode => episode.GetAirDateAsDate()) .Select(episode => @@ -461,9 +433,8 @@ public Dashboard.EpisodeDetails GetEpisodeDetailsForSeriesAndEpisode(SVR_JMMUser .ToList(); } - public DashboardController(ISettingsProvider settingsProvider, SeriesFactory seriesFactory, QueueHandler queueHandler, AnimeSeriesService seriesService, AnimeSeries_UserRepository seriesUser, VideoLocal_UserRepository vlUsers) : base(settingsProvider) + public DashboardController(ISettingsProvider settingsProvider, QueueHandler queueHandler, AnimeSeriesService seriesService, AnimeSeries_UserRepository seriesUser, VideoLocal_UserRepository vlUsers) : base(settingsProvider) { - _seriesFactory = seriesFactory; _queueHandler = queueHandler; _seriesService = seriesService; _seriesUser = seriesUser; diff --git a/Shoko.Server/API/v3/Controllers/DebugController.cs b/Shoko.Server/API/v3/Controllers/DebugController.cs index fb35930ed..81c01bcdd 100644 --- a/Shoko.Server/API/v3/Controllers/DebugController.cs +++ b/Shoko.Server/API/v3/Controllers/DebugController.cs @@ -15,6 +15,7 @@ using Shoko.Server.Providers.AniDB.Interfaces; using Shoko.Server.Providers.AniDB.UDP.Exceptions; using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.AniDB; using Shoko.Server.Scheduling.Jobs.Test; using Shoko.Server.Settings; @@ -85,6 +86,19 @@ await scheduler.StartJobNow(t => return Ok(); } + /// + /// Fetch a specific AniDB message by the provided ID. + /// + /// Message ID + /// + [HttpGet("FetchAniDBMessage/{id}")] + public async Task FetchAniDBMessage(int id) + { + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJobNow(r => r.MessageID = id); + return Ok(); + } + /// /// Call the AniDB UDP API using the /// @@ -108,7 +122,7 @@ public async Task CallAniDB([FromBody] AnidbUdpRequest request } var fullResponse = request.Unsafe ? - await _udpHandler.SendDirectly(request.Command, resetPingTimer: request.IsPing) : + await _udpHandler.SendDirectly(request.Command, isPing: request.IsPing, isLogout: request.IsLogout) : await _udpHandler.Send(request.Command); var decodedParts = fullResponse.Split('\n'); var decodedResponse = string.Join('\n', @@ -220,6 +234,18 @@ public bool IsPing } } + /// + /// Indicates that this request is a ping request. + /// + [JsonIgnore] + public bool IsLogout + { + get + { + return string.Equals(Action, "LOGOUT", StringComparison.InvariantCultureIgnoreCase); + } + } + /// /// Indicates the request needs authentication. /// @@ -228,7 +254,7 @@ public bool NeedAuth { get { - return !IsPing && (!Payload.ContainsKey("s")); + return !IsPing && !Payload.ContainsKey("s"); } } diff --git a/Shoko.Server/API/v3/Controllers/EpisodeController.cs b/Shoko.Server/API/v3/Controllers/EpisodeController.cs index 51aacdc06..8e22e1a06 100644 --- a/Shoko.Server/API/v3/Controllers/EpisodeController.cs +++ b/Shoko.Server/API/v3/Controllers/EpisodeController.cs @@ -1,11 +1,15 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Shoko.Commons.Extensions; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Plugin.Abstractions.Extensions; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; @@ -13,17 +17,16 @@ using Shoko.Server.API.v3.Models.Common; using Shoko.Server.API.v3.Models.Shoko; using Shoko.Server.Models; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Repositories; +using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; using EpisodeType = Shoko.Server.API.v3.Models.Shoko.EpisodeType; using AniDBEpisodeType = Shoko.Models.Enums.EpisodeType; -using System.Collections.Concurrent; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Shoko.Plugin.Abstractions.Enums; -using Shoko.Server.Services; +using TmdbEpisode = Shoko.Server.API.v3.Models.TMDB.Episode; +using TmdbMovie = Shoko.Server.API.v3.Models.TMDB.Movie; namespace Shoko.Server.API.v3.Controllers; @@ -33,11 +36,6 @@ namespace Shoko.Server.API.v3.Controllers; [Authorize] public class EpisodeController : BaseController { - private readonly AnimeSeriesService _seriesService; - private readonly AnimeGroupService _groupService; - private readonly WatchedStatusService _watchedService; - - internal const string EpisodeWithZeroID = "episodeID must be greater than 0"; internal const string EpisodeNotFoundWithEpisodeID = "No Episode entry for the given episodeID"; @@ -51,6 +49,36 @@ public class EpisodeController : BaseController internal const string EpisodeNoSeriesForEpisodeID = "Unable to find a Series entry for given episodeID"; + private readonly AnimeSeriesService _seriesService; + + private readonly AnimeGroupService _groupService; + + private readonly AnimeEpisodeService _episodeService; + + private readonly WatchedStatusService _watchedService; + + private readonly TmdbLinkingService _tmdbLinkingService; + + private readonly TmdbMetadataService _tmdbMetadataService; + + public EpisodeController( + ISettingsProvider settingsProvider, + AnimeSeriesService seriesService, + AnimeGroupService groupService, + AnimeEpisodeService episodeService, + WatchedStatusService watchedService, + TmdbLinkingService tmdbLinkingService, + TmdbMetadataService tmdbMetadataService + ) : base(settingsProvider) + { + _seriesService = seriesService; + _groupService = groupService; + _episodeService = episodeService; + _watchedService = watchedService; + _tmdbLinkingService = tmdbLinkingService; + _tmdbMetadataService = tmdbMetadataService; + } + /// /// Get all s for the given filter. /// @@ -60,6 +88,7 @@ public class EpisodeController : BaseController /// The page size. Set to 0 to disable pagination. /// The page index. /// Include missing episodes in the list. + /// Include unaired episodes in the list. /// Include hidden episodes in the list. /// Include data from selected s. /// Include watched episodes in the list. @@ -76,6 +105,7 @@ public ActionResult> GetAllEpisodes( [FromQuery, Range(0, 1000)] int pageSize = 20, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] IncludeOnlyFilter includeMissing = IncludeOnlyFilter.False, + [FromQuery] IncludeOnlyFilter includeUnaired = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeHidden = IncludeOnlyFilter.False, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, [FromQuery] IncludeOnlyFilter includeWatched = IncludeOnlyFilter.True, @@ -108,7 +138,7 @@ public ActionResult> GetAllEpisodes( if (anidb == null || shoko == null) return false; - // Filter by hidden state, if spesified + // Filter by hidden state, if specified if (includeHidden != IncludeOnlyFilter.True) { // If we should hide hidden episodes and the episode is hidden, then hide it. @@ -132,8 +162,17 @@ public ActionResult> GetAllEpisodes( // If we should hide missing episodes and the episode has no files, then hide it. // Or if we should only show missing episodes and the episode has files, the hide it. var shouldHideMissing = includeMissing == IncludeOnlyFilter.False; - var noFiles = shoko.VideoLocals.Count == 0; - if (shouldHideMissing == noFiles) + var isMissing = shoko.VideoLocals.Count == 0 && anidb.HasAired; + if (shouldHideMissing == isMissing) + return false; + } + if (includeUnaired != IncludeOnlyFilter.True) + { + // If we should hide unaired episodes and the episode has no files, then hide it. + // Or if we should only show unaired episodes and the episode has files, the hide it. + var shouldHideUnaired = includeUnaired == IncludeOnlyFilter.False; + var isUnaired = shoko.VideoLocals.Count == 0 && !anidb.HasAired; + if (shouldHideUnaired == isUnaired) return false; } @@ -153,7 +192,7 @@ public ActionResult> GetAllEpisodes( if (hasSearch) { var languages = SettingsProvider.GetSettings() - .LanguagePreference + .Language.EpisodeTitleLanguageOrder .Select(lang => lang.GetTitleLanguage()) .Concat(new TitleLanguage[] { TitleLanguage.English, TitleLanguage.Romaji }) .ToHashSet(); @@ -228,66 +267,7 @@ public ActionResult> GetAllEpisodes( .ToListResult(episode => new Episode.AniDB(episode), page, pageSize); } - /// - /// Get all s. Admins only. - /// - /// - /// It's admins only since i don't want to add the logic to - /// - /// The page size. Set to 0 to disable pagination. - /// The page index. - /// - [HttpGet("TvDB")] - public ActionResult> GetAllTvDBEpisodes( - [FromQuery, Range(0, 1000)] int pageSize = 20, - [FromQuery, Range(1, int.MaxValue)] int page = 1) - { - var user = User; - var isAdmin = user.IsAdmin == 1; - var allowedShowDict = new ConcurrentDictionary(); - return RepoFactory.TvDB_Episode.GetAll() - .Where(episode => - { - // Only show episodes the user is allowed to view. - if (!allowedShowDict.TryGetValue(episode.SeriesID, out var isAllowed)) - { - // If this is an episode not tied to a missing show, then - // just hide it. - var show = RepoFactory.TvDB_Series.GetByTvDBID(episode.SeriesID); - if (show == null) - { - isAllowed = false; - goto addValue; - } - - // If there are no cross-references, then hide it if the - // user is not an admin. - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(episode.SeriesID) - .FirstOrDefault(); - if (xref == null) - { - isAllowed = isAdmin; - goto addValue; - } - - // Or if the cross-reference is broken then also hide it if - // the user is not an admin, otherwise check if the user can - // view the series. - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(xref.AniDBID); - isAllowed = anime == null ? isAdmin : user.AllowedAnime(anime); - -addValue: allowedShowDict.TryAdd(episode.SeriesID, isAllowed); - } - if (!isAllowed) - return false; - - return true; - }) - .OrderBy(episode => episode.SeriesID) - .ThenBy(episode => episode.SeasonNumber) - .ThenBy(episode => episode.EpisodeNumber) - .ToListResult(episode => new Episode.TvDB(episode), page, pageSize); - } + #region Shoko /// /// Get the entry for the given . @@ -301,16 +281,13 @@ public ActionResult> GetAllEpisodes( /// [HttpGet("{episodeID}")] public ActionResult GetEpisodeByEpisodeID( - [FromRoute] int episodeID, + [FromRoute, Range(1, int.MaxValue)] int episodeID, [FromQuery] bool includeFiles = false, [FromQuery] bool includeMediaInfo = false, [FromQuery] bool includeAbsolutePaths = false, [FromQuery] bool includeXRefs = false, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) { - if (episodeID == 0) - return BadRequest(EpisodeWithZeroID); - var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) return NotFound(EpisodeNotFoundWithEpisodeID); @@ -333,11 +310,8 @@ public ActionResult GetEpisodeByEpisodeID( /// [Authorize("admin")] [HttpPost("{episodeID}/OverrideTitle")] - public ActionResult OverrideEpisodeTitle([FromRoute] int episodeID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] EpisodeTitleOverride body) + public ActionResult OverrideEpisodeTitle([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Episode.Input.EpisodeTitleOverrideBody body) { - if (episodeID == 0) - return BadRequest(EpisodeWithZeroID); - var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) @@ -371,11 +345,8 @@ public ActionResult OverrideEpisodeTitle([FromRoute] int episodeID, [FromBody(Em /// [Authorize("admin")] [HttpPost("{episodeID}/SetHidden")] - public ActionResult PostEpisodeSetHidden([FromRoute] int episodeID, [FromQuery] bool value = true, [FromQuery] bool updateStats = true) + public ActionResult PostEpisodeSetHidden([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromQuery] bool value = true, [FromQuery] bool updateStats = true) { - if (episodeID == 0) - return BadRequest(EpisodeWithZeroID); - var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) return NotFound(EpisodeNotFoundWithEpisodeID); @@ -405,17 +376,18 @@ public ActionResult PostEpisodeSetHidden([FromRoute] int episodeID, [FromQuery] return Ok(); } + #endregion + + #region AniDB + /// /// Get the entry for the given . /// /// Shoko ID /// [HttpGet("{episodeID}/AniDB")] - public ActionResult GetEpisodeAnidbByEpisodeID([FromRoute] int episodeID) + public ActionResult GetEpisodeAnidbByEpisodeID([FromRoute, Range(1, int.MaxValue)] int episodeID) { - if (episodeID == 0) - return BadRequest(EpisodeWithZeroID); - var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) return NotFound(EpisodeNotFoundWithEpisodeID); @@ -479,11 +451,8 @@ public ActionResult GetEpisode( /// /// [HttpPost("{episodeID}/Vote")] - public ActionResult PostEpisodeVote([FromRoute] int episodeID, [FromBody] Vote vote) + public async Task PostEpisodeVote([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromBody] Vote vote) { - if (episodeID == 0) - return BadRequest(EpisodeWithZeroID); - var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) return NotFound(EpisodeNotFoundWithEpisodeID); @@ -498,22 +467,154 @@ public ActionResult PostEpisodeVote([FromRoute] int episodeID, [FromBody] Vote v if (vote.Value > vote.MaxValue) return ValidationProblem($"Value must be less than or equal to the set max value ({vote.MaxValue}).", nameof(vote.Value)); - Episode.AddEpisodeVote(HttpContext, episode, User.JMMUserID, vote); + await _episodeService.AddEpisodeVote(episode, vote.GetRating()); return NoContent(); } + #endregion + + #region TMDB + /// - /// Get the TvDB details for episode with Shoko ID + /// Get all TMDB Movies linked directly to the Shoko Episode by ID. /// - /// Shoko ID + /// Shoko Episode ID. + /// Extra details to include. + /// Language to fetch some details in. + /// All TMDB Movies linked directly to the Shoko Episode. + [HttpGet("{episodeID}/TMDB/Movie")] + public ActionResult> GetTmdbMoviesByEpisodeID( + [FromRoute, Range(1, int.MaxValue)] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet language = null + ) + { + var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + return episode.TmdbMovieCrossReferences + .Select(xref => xref.TmdbMovie) + .WhereNotNull() + .Select(tmdbMovie => new TmdbMovie(tmdbMovie, include?.CombineFlags(), language)) + .ToList(); + } + + + /// + /// Add a new TMDB Movie cross-reference to the Shoko Episode by ID. + /// + /// Shoko Episode ID. + /// Body containing the information about the new cross-reference to be made. + /// Void. + [Authorize("admin")] + [HttpPost("{episodeID}/TMDB/Movie")] + public async Task AddLinkToTMDBMoviesByEpisodeID( + [FromRoute, Range(1, int.MaxValue)] int episodeID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.LinkCommonBody body + ) + { + var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + await _tmdbLinkingService.AddMovieLinkForEpisode(episode.AniDB_EpisodeID, body.ID, additiveLink: !body.Replace); + + var needRefresh = RepoFactory.TMDB_Movie.GetByTmdbMovieID(body.ID) is null || body.Refresh; + if (needRefresh) + await _tmdbMetadataService.ScheduleUpdateOfMovie(body.ID, forceRefresh: body.Refresh, downloadImages: true); + + return NoContent(); + } + + /// + /// Remove one or all TMDB Movie links from the episode. + /// + /// Shoko Episode ID. + /// Optional. Body containing information about the link to be removed. /// - [HttpGet("{episodeID}/TvDB")] - public ActionResult> GetEpisodeTvDBDetails([FromRoute] int episodeID) + [Authorize("admin")] + [HttpDelete("{episodeID}/TMDB/Movie")] + public async Task RemoveLinkToTMDBMoviesByEpisodeID( + [FromRoute, Range(1, int.MaxValue)] int episodeID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Series.Input.UnlinkMovieBody body + ) { - if (episodeID == 0) - return BadRequest(EpisodeWithZeroID); + var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + if (body != null && body.ID > 0) + await _tmdbLinkingService.RemoveMovieLinkForEpisode(episode.AniDB_EpisodeID, body.ID, body.Purge); + else + await _tmdbLinkingService.RemoveAllMovieLinksForEpisode(episode.AniDB_EpisodeID, body?.Purge ?? false); + + return NoContent(); + } + + /// + /// Get all TMDB Movie cross-references for the Shoko Episode by ID. + /// + /// Shoko Episode ID. + /// All TMDB Movie cross-references for the Shoko Episode. + [HttpGet("{seriesID}/TMDB/Movie/CrossReferences")] + public ActionResult> GetTMDBMovieCrossReferenceByEpisodeID( + [FromRoute, Range(1, int.MaxValue)] int seriesID + ) + { + var episode = RepoFactory.AnimeEpisode.GetByID(seriesID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + return episode.TmdbMovieCrossReferences + .Select(xref => new TmdbMovie.CrossReference(xref)) + .OrderBy(xref => xref.TmdbMovieID) + .ToList(); + } + + /// + /// Get all TMDB Episodes linked to the Shoko Episode by ID. + /// + /// Shoko Episode ID. + /// Extra details to include. + /// Language to fetch some details for. + /// All TMDB Episodes linked to the Shoko Episode. + [HttpGet("{episodeID}/TMDB/Episode")] + public ActionResult> GetTmdbEpisodesByEpisodeID( + [FromRoute, Range(1, int.MaxValue)] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet language = null + ) + { var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) return NotFound(EpisodeNotFoundWithEpisodeID); @@ -525,11 +626,214 @@ public ActionResult PostEpisodeVote([FromRoute] int episodeID, [FromBody] Vote v if (!User.AllowedSeries(series)) return Forbid(EpisodeForbiddenForUser); - return episode.TvDBEpisodes - .Select(a => new Episode.TvDB(a)) + return episode.TmdbEpisodeCrossReferences + .Select(xref => xref.TmdbEpisode) + .WhereNotNull() + .GroupBy(tmdbEpisode => tmdbEpisode.TmdbShowID) + .Select(groupBy => (TmdbShow: groupBy.First().TmdbShow!, TmdbEpisodes: groupBy.ToList())) + .Where(tuple => tuple.TmdbShow is not null) + .SelectMany(tuple0 => + string.IsNullOrEmpty(tuple0.TmdbShow.PreferredAlternateOrderingID) + ? tuple0.TmdbEpisodes.Select(tmdbEpisode => new TmdbEpisode(tuple0.TmdbShow, tmdbEpisode, include?.CombineFlags(), language)) + : tuple0.TmdbEpisodes + .Select(tmdbEpisode => (TmdbEpisode: tmdbEpisode, TmdbAlternateOrdering: tmdbEpisode.GetTmdbAlternateOrderingEpisodeById(tuple0.TmdbShow.PreferredAlternateOrderingID))) + .Where(tuple1 => tuple1.TmdbAlternateOrdering is not null) + .Select(tuple1 => new TmdbEpisode(tuple0.TmdbShow, tuple1.TmdbEpisode, tuple1.TmdbAlternateOrdering, include?.CombineFlags(), language) + )) .ToList(); } + /// + /// Get all TMDB Episode cross-references for the Shoko Episode by ID. + /// + /// Shoko Episode ID. + /// All TMDB Episode cross-references for the Shoko Episode. + [HttpGet("{seriesID}/TMDB/Episode/CrossReferences")] + public ActionResult> GetTMDBEpisodeCrossReferenceByEpisodeID( + [FromRoute, Range(1, int.MaxValue)] int seriesID + ) + { + var episode = RepoFactory.AnimeEpisode.GetByID(seriesID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + return episode.TmdbEpisodeCrossReferences + .Select(xref => new TmdbEpisode.CrossReference(xref)) + .OrderBy(xref => xref.TmdbEpisodeID) + .ToList(); + } + + #endregion + + #region Images + + #region All images + + private static readonly HashSet _allowedImageTypes = [Image.ImageType.Poster, Image.ImageType.Banner, Image.ImageType.Backdrop, Image.ImageType.Logo, Image.ImageType.Thumbnail]; + + private const string InvalidIDForSource = "Invalid image id for selected source."; + + private const string InvalidImageIsDisabled = "Image is disabled."; + + /// + /// Get all images for episode with ID, optionally with Disabled images, as well. + /// + /// Shoko ID + /// + /// + [HttpGet("{episodeID}/Images")] + public ActionResult GetSeriesImages([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromQuery] bool includeDisabled) + { + var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + return episode.GetImages().ToDto(includeDisabled: includeDisabled, includeThumbnails: true); + } + + #endregion + + #region Default image + + /// + /// Get the default for the given for the . + /// + /// Series ID + /// Poster, Banner, Fanart + /// + [HttpGet("{episodeID}/Images/{imageType}")] + public ActionResult GetSeriesDefaultImageForType([FromRoute, Range(1, int.MaxValue)] int episodeID, + [FromRoute] Image.ImageType imageType) + { + if (!_allowedImageTypes.Contains(imageType)) + return NotFound(); + + var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + var imageEntityType = imageType.ToServer(); + var preferredImage = episode.GetPreferredImageForType(imageEntityType); + if (preferredImage != null) + return new Image(preferredImage); + + var images = episode.GetImages().ToDto(); + return imageEntityType switch + { + ImageEntityType.Poster => images.Posters.FirstOrDefault(), + ImageEntityType.Banner => images.Banners.FirstOrDefault(), + ImageEntityType.Backdrop => images.Backdrops.FirstOrDefault(), + ImageEntityType.Logo => images.Logos.FirstOrDefault(), + _ => null + }; + } + + + /// + /// Set the default for the given for the . + /// + /// Series ID + /// Poster, Banner, Fanart + /// The body containing the source and id used to set. + /// + [HttpPut("{episodeID}/Images/{imageType}")] + public ActionResult SetSeriesDefaultImageForType([FromRoute, Range(1, int.MaxValue)] int episodeID, + [FromRoute] Image.ImageType imageType, [FromBody] Image.Input.DefaultImageBody body) + { + if (!_allowedImageTypes.Contains(imageType)) + return NotFound(); + + var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + // Check if the id is valid for the given type and source. + var dataSource = body.Source.ToServer(); + var imageEntityType = imageType.ToServer(); + var image = ImageUtils.GetImageMetadata(dataSource, imageEntityType, body.ID); + if (image is null) + return ValidationProblem(InvalidIDForSource); + if (!image.IsEnabled) + return ValidationProblem(InvalidImageIsDisabled); + + // Create or update the entry. + var defaultImage = RepoFactory.AniDB_Episode_PreferredImage.GetByAnidbEpisodeIDAndType(episode.AniDB_EpisodeID, imageEntityType) ?? + new() { AnidbAnimeID = episode.AniDB_EpisodeID, ImageType = imageEntityType }; + defaultImage.ImageID = body.ID; + defaultImage.ImageSource = dataSource; + RepoFactory.AniDB_Episode_PreferredImage.Save(defaultImage); + + return new Image(body.ID, imageEntityType, dataSource, true); + } + + /// + /// Unset the default for the given for the . + /// + /// + /// Poster, Banner, Fanart + /// + [HttpDelete("{episodeID}/Images/{imageType}")] + public ActionResult DeleteSeriesDefaultImageForType([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromRoute] Image.ImageType imageType) + { + if (!_allowedImageTypes.Contains(imageType)) + return NotFound(); + + var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); + if (episode == null) + return NotFound(EpisodeNotFoundWithEpisodeID); + + var series = episode.AnimeSeries; + if (series is null) + return InternalError(EpisodeNoSeriesForEpisodeID); + + if (!User.AllowedSeries(series)) + return Forbid(EpisodeForbiddenForUser); + + // Check if a default image is set. + var imageEntityType = imageType.ToServer(); + var defaultImage = RepoFactory.AniDB_Episode_PreferredImage.GetByAnidbEpisodeIDAndType(episode.AniDB_EpisodeID, imageEntityType); + if (defaultImage == null) + return ValidationProblem("No default banner."); + + // Delete the entry. + RepoFactory.AniDB_Episode_PreferredImage.Delete(defaultImage); + + // Don't return any content. + return NoContent(); + } + + #endregion + + #endregion + /// /// Set the watched status on an episode /// @@ -537,11 +841,8 @@ public ActionResult PostEpisodeVote([FromRoute] int episodeID, [FromBody] Vote v /// /// [HttpPost("{episodeID}/Watched/{watched}")] - public async Task SetWatchedStatusOnEpisode([FromRoute] int episodeID, [FromRoute] bool watched) + public async Task SetWatchedStatusOnEpisode([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromRoute] bool watched) { - if (episodeID == 0) - return BadRequest(EpisodeWithZeroID); - var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) return NotFound(EpisodeNotFoundWithEpisodeID); @@ -563,6 +864,7 @@ public async Task SetWatchedStatusOnEpisode([FromRoute] int episod /// /// Include specials in the list. /// Include file/episode cross-references with the episodes. + /// Only show episodes which has aired. /// Only show episodes for completed series. /// Limits the number of results per page. Set to 0 to disable the limit. /// Page number. @@ -571,11 +873,12 @@ public async Task SetWatchedStatusOnEpisode([FromRoute] int episod public ActionResult> GetMissingEpisodes( [FromQuery] bool includeSpecials = false, [FromQuery] bool includeXRefs = false, + [FromQuery] bool onlyAiredEpisodes = false, [FromQuery] bool onlyFinishedSeries = false, [FromQuery, Range(0, 1000)] int pageSize = 100, [FromQuery, Range(1, int.MaxValue)] int page = 1) { - IEnumerable enumerable = RepoFactory.AnimeEpisode.GetEpisodesWithNoFiles(includeSpecials); + IEnumerable enumerable = RepoFactory.AnimeEpisode.GetEpisodesWithNoFiles(includeSpecials, onlyAiredEpisodes); if (onlyFinishedSeries) { var dictSeriesFinishedAiring = RepoFactory.AnimeSeries.GetAll() @@ -587,11 +890,4 @@ public ActionResult> GetMissingEpisodes( return enumerable .ToListResult(episode => new Episode(HttpContext, episode, withXRefs: includeXRefs), page, pageSize); } - - public EpisodeController(ISettingsProvider settingsProvider, AnimeSeriesService seriesService, AnimeGroupService groupService, WatchedStatusService watchedService) : base(settingsProvider) - { - _seriesService = seriesService; - _groupService = groupService; - _watchedService = watchedService; - } } diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index 24bd1a0b6..8ce0aa969 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -6,12 +6,12 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.StaticFiles; using Quartz; using Shoko.Models.Enums; -using Shoko.Models.Server; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; @@ -35,7 +35,6 @@ using File = Shoko.Server.API.v3.Models.Shoko.File; using MediaInfo = Shoko.Server.API.v3.Models.Shoko.MediaInfo; using Path = System.IO.Path; -using RelocateResult = Shoko.Server.API.v3.Models.Shoko.Renamer.RelocateResult; namespace Shoko.Server.API.v3.Controllers; @@ -293,11 +292,9 @@ public ActionResult GetFileBySha1( /// Include data from selected s. /// [HttpGet("{fileID}")] - public ActionResult GetFile([FromRoute] int fileID, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileNonDefaultIncludeType[] include = default, + public ActionResult GetFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileNonDefaultIncludeType[] include = default, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) { - if (fileID is <= 0) - return NotFound(FileNotFoundWithFileID); var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) return NotFound(FileNotFoundWithFileID); @@ -318,10 +315,8 @@ public ActionResult GetFile([FromRoute] int fileID, [FromQuery, ModelBinde /// [Authorize("admin")] [HttpDelete("{fileID}")] - public async Task DeleteFile([FromRoute] int fileID, [FromQuery] bool removeFiles = true, [FromQuery] bool removeFolder = true) + public async Task DeleteFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool removeFiles = true, [FromQuery] bool removeFolder = true) { - if (fileID is <= 0) - return NotFound(FileNotFoundWithFileID); var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) return NotFound(FileNotFoundWithFileID); @@ -341,10 +336,8 @@ public async Task DeleteFile([FromRoute] int fileID, [FromQuery] b /// Include absolute paths for the file locations. /// A list of file locations associated with the specified file ID. [HttpGet("{fileID}/Location")] - public ActionResult> GetFileLocations([FromRoute] int fileID, [FromQuery] bool includeAbsolutePaths = false) + public ActionResult> GetFileLocations([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool includeAbsolutePaths = false) { - if (fileID is <= 0) - return NotFound(FileLocationNotFoundWithLocationID); var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) return NotFound(FileLocationNotFoundWithLocationID); @@ -361,10 +354,8 @@ public async Task DeleteFile([FromRoute] int fileID, [FromQuery] b /// /// Returns the file location information. [HttpGet("Location/{locationID}")] - public ActionResult GetFileLocation([FromRoute] int locationID) + public ActionResult GetFileLocation([FromRoute, Range(1, int.MaxValue)] int locationID) { - if (locationID is <= 0) - return NotFound(FileLocationNotFoundWithLocationID); var fileLocation = RepoFactory.VideoLocalPlace.GetByID(locationID); if (fileLocation == null) return NotFound(FileLocationNotFoundWithLocationID); @@ -383,10 +374,8 @@ public async Task DeleteFile([FromRoute] int fileID, [FromQuery] b /// [Authorize("admin")] [HttpDelete("Location/{locationID}")] - public async Task DeleteFileLocation([FromRoute] int locationID, [FromQuery] bool deleteFile = true, [FromQuery] bool deleteFolder = true) + public async Task DeleteFileLocation([FromRoute, Range(1, int.MaxValue)] int locationID, [FromQuery] bool deleteFile = true, [FromQuery] bool deleteFolder = true) { - if (locationID is <= 0) - return NotFound(FileLocationNotFoundWithLocationID); var fileLocation = RepoFactory.VideoLocalPlace.GetByID(locationID); if (fileLocation == null) return NotFound(FileLocationNotFoundWithLocationID); @@ -399,142 +388,6 @@ public async Task DeleteFileLocation([FromRoute] int locationID, [ return Ok(); } - /// - /// Directly relocates a file to a new location specified by the user. - /// - /// The ID of the file location to be relocated. - /// New location information. - /// A result object containing information about the relocation process. - [Authorize("admin")] - [HttpPost("Location/{locationID}/Relocate")] - public async Task> DirectlyRelocateFileLocation([FromRoute] int locationID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] File.Location.NewLocationBody body) - { - if (locationID is <= 0) - return NotFound(FileLocationNotFoundWithLocationID); - var fileLocation = RepoFactory.VideoLocalPlace.GetByID(locationID); - if (fileLocation == null) - return NotFound(FileLocationNotFoundWithLocationID); - - var importFolder = RepoFactory.ImportFolder.GetByID(body.ImportFolderID); - if (importFolder == null) - return BadRequest($"Unknown import folder with the given id `{body.ImportFolderID}`."); - - // Sanitize relative path and reject paths leading to outside the import folder. - var fullPath = Path.GetFullPath(Path.Combine(importFolder.ImportFolderLocation, body.RelativePath)); - if (!fullPath.StartsWith(importFolder.ImportFolderLocation, StringComparison.OrdinalIgnoreCase)) - return BadRequest("The provided relative path leads outside the import folder."); - var sanitizedRelativePath = Path.GetRelativePath(importFolder.ImportFolderLocation, fullPath); - - // Store the old import folder id and relative path for comparison. - var oldImportFolderId = fileLocation.ImportFolderID; - var oldRelativePath = fileLocation.FilePath; - - // Rename and move the file. - var result = await _vlPlaceService.DirectlyRelocateFile( - fileLocation, - new() - { - ImportFolder = importFolder, - RelativePath = sanitizedRelativePath, - DeleteEmptyDirectories = body.DeleteEmptyDirectories - } - ); - if (!result.Success) - return new RelocateResult - { - FileID = fileLocation.VideoLocalID, - FileLocationID = fileLocation.VideoLocal_Place_ID, - IsSuccess = false, - ErrorMessage = result.ErrorMessage, - }; - - // Check if it was actually relocated, or if we landed on the same location as earlier. - var relocated = !string.Equals(oldRelativePath, result.RelativePath, StringComparison.InvariantCultureIgnoreCase) || oldImportFolderId != result.ImportFolder.ImportFolderID; - return new RelocateResult - { - FileID = fileLocation.VideoLocalID, - FileLocationID = fileLocation.VideoLocal_Place_ID, - ImportFolderID = result.ImportFolder.ImportFolderID, - IsSuccess = true, - IsRelocated = relocated, - RelativePath = result.RelativePath, - AbsolutePath = result.AbsolutePath, - }; - } - - /// - /// Automatically relocates a file to a new location based on predefined rules. - /// - /// The ID of the file location to be relocated. - /// Parameters for the automatic relocation process. - /// A result object containing information about the relocation process. - [Authorize("admin")] - [HttpPost("Location/{locationID}/AutoRelocate")] - public async Task> AutomaticallyRelocateFileLocation([FromRoute] int locationID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] File.Location.AutoRelocateBody body) - { - if (locationID is <= 0) - return NotFound(FileLocationNotFoundWithLocationID); - var fileLocation = RepoFactory.VideoLocalPlace.GetByID(locationID); - if (fileLocation == null) - return NotFound(FileLocationNotFoundWithLocationID); - - // Make sure we have a valid script to use. - RenameScript script; - if (!body.ScriptID.HasValue || body.ScriptID.Value <= 0) - { - script = RepoFactory.RenameScript.GetDefaultOrFirst(); - if (script == null || string.Equals(script.ScriptName, Shoko.Models.Constants.Renamer.TempFileName)) - return BadRequest($"No default script have been selected! Select one before continuing."); - } - else - { - script = RepoFactory.RenameScript.GetByID(body.ScriptID.Value); - if (script == null || string.Equals(script.ScriptName, Shoko.Models.Constants.Renamer.TempFileName)) - return BadRequest($"Unknown script with id \"{body.ScriptID.Value}\"! Omit `ScriptID` or set it to 0 to use the default script!"); - } - - // Store the old import folder id and relative path for comparison. - var oldImportFolderId = fileLocation.ImportFolderID; - var oldRelativePath = fileLocation.FilePath; - var settings = SettingsProvider.GetSettings(); - - // Rename and move the file, or preview where it would land if we did. - var result = await _vlPlaceService.AutoRelocateFile( - fileLocation, - new() - { - DeleteEmptyDirectories = body.DeleteEmptyDirectories, - Move = body.Move ?? settings.Import.MoveOnImport, - Preview = body.Preview, - Rename = body.Rename ?? settings.Import.RenameOnImport, - ScriptID = script.RenameScriptID, - } - ); - if (!result.Success) - return new RelocateResult - { - FileID = fileLocation.VideoLocalID, - FileLocationID = fileLocation.VideoLocal_Place_ID, - IsSuccess = false, - ErrorMessage = result.ErrorMessage, - }; - - // Check if it was actually relocated, or if we landed on the same location as earlier. - var relocated = !string.Equals(oldRelativePath, result.RelativePath, StringComparison.InvariantCultureIgnoreCase) || oldImportFolderId != result.ImportFolder.ImportFolderID; - return new RelocateResult - { - FileID = fileLocation.VideoLocalID, - FileLocationID = fileLocation.VideoLocal_Place_ID, - ScriptID = script.RenameScriptID, - ImportFolderID = result.ImportFolder.ImportFolderID, - IsSuccess = true, - IsRelocated = relocated, - IsPreview = body.Preview, - RelativePath = result.RelativePath, - AbsolutePath = result.AbsolutePath, - }; - } - /// /// Get the using the . /// @@ -544,7 +397,7 @@ public async Task> AutomaticallyRelocateFileLocatio /// Shoko File ID /// [HttpGet("{fileID}/AniDB")] - public ActionResult GetFileAnidbByFileID([FromRoute] int fileID) + public ActionResult GetFileAnidbByFileID([FromRoute, Range(1, int.MaxValue)] int fileID) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -566,7 +419,7 @@ public async Task> AutomaticallyRelocateFileLocatio /// AniDB File ID /// [HttpGet("AniDB/{anidbFileID}")] - public ActionResult GetFileAnidbByAnidbFileID([FromRoute] int anidbFileID) + public ActionResult GetFileAnidbByAnidbFileID([FromRoute, Range(1, int.MaxValue)] int anidbFileID) { var anidb = RepoFactory.AniDB_File.GetByFileID(anidbFileID); if (anidb == null) @@ -588,7 +441,7 @@ public async Task> AutomaticallyRelocateFileLocatio /// Include absolute paths for the file locations. /// [HttpGet("AniDB/{anidbFileID}/File")] - public ActionResult GetFileByAnidbFileID([FromRoute] int anidbFileID, [FromQuery] bool includeXRefs = false, + public ActionResult GetFileByAnidbFileID([FromRoute, Range(1, int.MaxValue)] int anidbFileID, [FromQuery] bool includeXRefs = false, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, [FromQuery] bool includeMediaInfo = false, [FromQuery] bool includeAbsolutePaths = false) { @@ -610,7 +463,7 @@ public ActionResult GetFileByAnidbFileID([FromRoute] int anidbFileID, [Fro /// Increase the priority to the max for the queued command. /// [HttpPost("AniDB/{anidbFileID}/Rescan")] - public async Task RescanFileByAniDBFileID([FromRoute] int anidbFileID, [FromQuery] bool priority = false) + public async Task RescanFileByAniDBFileID([FromRoute, Range(1, int.MaxValue)] int anidbFileID, [FromQuery] bool priority = false) { var anidb = RepoFactory.AniDB_File.GetByFileID(anidbFileID); if (anidb == null) @@ -656,7 +509,7 @@ await scheduler.StartJob(c => [HttpHead("{fileID}/Stream")] [HttpGet("{fileID}/StreamDirectory/{filename}")] [HttpHead("{fileID}/StreamDirectory/{filename}")] - public ActionResult GetFileStream([FromRoute] int fileID, [FromRoute] string filename = null, [FromQuery] bool streamPositionScrobbling = false) + public ActionResult GetFileStream([FromRoute, Range(1, int.MaxValue)] int fileID, [FromRoute] string filename = null, [FromQuery] bool streamPositionScrobbling = false) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -694,7 +547,7 @@ public ActionResult GetFileStream([FromRoute] int fileID, [FromRoute] string fil /// A file stream for the specified file. [AllowAnonymous] [HttpGet("{fileID}/StreamDirectory/")] - public ActionResult GetFileStreamDirectory([FromRoute] int fileID) + public ActionResult GetFileStreamDirectory([FromRoute, Range(1, int.MaxValue)] int fileID) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -713,7 +566,7 @@ public ActionResult GetFileStreamDirectory([FromRoute] int fileID) /// [AllowAnonymous] [HttpGet("{fileID}/StreamDirectory/ExternalSub/{filename}")] - public ActionResult GetExternalSubtitle([FromRoute] int fileID, [FromRoute] string filename) + public ActionResult GetExternalSubtitle([FromRoute, Range(1, int.MaxValue)] int fileID, [FromRoute] string filename) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -739,7 +592,7 @@ public ActionResult GetExternalSubtitle([FromRoute] int fileID, [FromRoute] stri /// Shoko ID /// [HttpGet("{fileID}/MediaInfo")] - public ActionResult GetFileMediaInfo([FromRoute] int fileID) + public ActionResult GetFileMediaInfo([FromRoute, Range(1, int.MaxValue)] int fileID) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -758,7 +611,7 @@ public ActionResult GetFileMediaInfo([FromRoute] int fileID) /// Shoko file ID /// The user stats if found. [HttpGet("{fileID}/UserStats")] - public ActionResult GetFileUserStats([FromRoute] int fileID) + public ActionResult GetFileUserStats([FromRoute, Range(1, int.MaxValue)] int fileID) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -780,7 +633,7 @@ public ActionResult GetFileMediaInfo([FromRoute] int fileID) /// The new and/or update file stats to put for the file. /// The new and/or updated user stats. [HttpPut("{fileID}/UserStats")] - public ActionResult PutFileUserStats([FromRoute] int fileID, [FromBody] File.FileUserStats fileUserStats) + public ActionResult PutFileUserStats([FromRoute, Range(1, int.MaxValue)] int fileID, [FromBody] File.FileUserStats fileUserStats) { // Make sure the file exists. var file = RepoFactory.VideoLocal.GetByID(fileID); @@ -795,6 +648,34 @@ public ActionResult GetFileMediaInfo([FromRoute] int fileID) return fileUserStats.MergeWithExisting(userStats, file); } + /// + /// Patch a object down for the with the given . + /// + /// Shoko file ID + /// The JSON patch document to apply to the existing . + /// The new and/or updated user stats. + [HttpPatch("{fileID}/UserStats")] + public ActionResult PatchFileUserStats([FromRoute, Range(1, int.MaxValue)] int fileID, [FromBody] JsonPatchDocument patchDocument) + { + // Make sure the file exists. + var file = RepoFactory.VideoLocal.GetByID(fileID); + if (file == null) + return NotFound(FileNotFoundWithFileID); + + // Get the user data. + var user = HttpContext.GetUser(); + var userStats = _vlService.GetOrCreateUserRecord(file, user.JMMUserID); + + // Patch the body with the existing model. + var body = new File.FileUserStats(userStats); + patchDocument.ApplyTo(body, ModelState); + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + // Merge with the existing entry and return an updated version of the stats. + return body.MergeWithExisting(userStats, file); + } + /// /// Mark a file as watched or unwatched. /// @@ -802,7 +683,7 @@ public ActionResult GetFileMediaInfo([FromRoute] int fileID) /// Is it watched? /// [HttpPost("{fileID}/Watched/{watched?}")] - public async Task SetWatchedStatusOnFile([FromRoute] int fileID, [FromRoute] bool watched = true) + public async Task SetWatchedStatusOnFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromRoute] bool watched = true) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -822,10 +703,10 @@ public async Task SetWatchedStatusOnFile([FromRoute] int fileID, [ /// True if file should be marked as watched, false if file should be unmarked, or null if it shall not be updated. /// Number of ticks into the video to resume from, or null if it shall not be updated. /// + [HttpGet("{fileID}/Scrobble")] [HttpPatch("{fileID}/Scrobble")] - public async Task ScrobbleFileAndEpisode([FromRoute] int fileID, [FromQuery(Name = "event")] string eventName = null, [FromQuery] int? episodeID = null, [FromQuery] bool? watched = null, [FromQuery] long? resumePosition = null) + public async Task ScrobbleFileAndEpisode([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery(Name = "event")] string eventName = null, [FromQuery] int? episodeID = null, [FromQuery] bool? watched = null, [FromQuery] long? resumePosition = null) { - var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) return NotFound(FileNotFoundWithFileID); @@ -925,7 +806,7 @@ private async Task ScrobbleStatusOnFile(SVR_VideoLocal file, bool? wat /// Thew new ignore value. /// [HttpPut("{fileID}/Ignore")] - public ActionResult MarkFileAsIgnored([FromRoute] int fileID, [FromQuery] bool value = true) + public ActionResult MarkFileAsIgnored([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool value = true) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -944,7 +825,7 @@ public ActionResult MarkFileAsIgnored([FromRoute] int fileID, [FromQuery] bool v /// Thew new variation value. /// [HttpPut("{fileID}/Variation")] - public ActionResult MarkFileAsVariation([FromRoute] int fileID, [FromQuery] bool value = true) + public ActionResult MarkFileAsVariation([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool value = true) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -964,7 +845,7 @@ public ActionResult MarkFileAsVariation([FromRoute] int fileID, [FromQuery] bool /// Immediately run the AVDump, without adding the command to the queue. /// [HttpPost("{fileID}/AVDump")] - public async Task> AvDumpFile([FromRoute] int fileID, [FromQuery] bool priority = false, + public async Task> AvDumpFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool priority = false, [FromQuery] bool immediate = true) { var file = RepoFactory.VideoLocal.GetByID(fileID); @@ -1004,7 +885,7 @@ public ActionResult MarkFileAsVariation([FromRoute] int fileID, [FromQuery] bool /// Increase the priority to the max for the queued command. /// [HttpPost("{fileID}/Rescan")] - public async Task RescanFile([FromRoute] int fileID, [FromQuery] bool priority = false) + public async Task RescanFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool priority = false) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -1039,7 +920,7 @@ await scheduler.StartJob(c => /// Whether to start the job immediately. Default true /// [HttpPost("{fileID}/Rehash")] - public async Task RehashFile([FromRoute] int fileID, [FromQuery] bool priority = true) + public async Task RehashFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool priority = true) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -1075,7 +956,7 @@ await scheduler.StartJob(c => /// The body. /// [HttpPost("{fileID}/Link")] - public async Task LinkSingleEpisodeToFile([FromRoute] int fileID, [FromBody] File.Input.LinkEpisodesBody body) + public async Task LinkSingleEpisodeToFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromBody] File.Input.LinkEpisodesBody body) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -1123,7 +1004,7 @@ await scheduler.StartJobNow(c => /// The body. /// [HttpPost("{fileID}/LinkFromSeries")] - public async Task LinkMultipleEpisodesToFile([FromRoute] int fileID, [FromBody] File.Input.LinkSeriesBody body) + public async Task LinkMultipleEpisodesToFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromBody] File.Input.LinkSeriesBody body) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -1211,6 +1092,24 @@ await scheduler.StartJobNow(c => return Ok(); } + /// + /// Force add a file to AniDB MyList + /// + /// The file id. + /// + [HttpPost("{fileID}/AddToMyList")] + public async Task AddFileToMyList([FromRoute, Range(1, int.MaxValue)] int fileID) + { + var file = RepoFactory.VideoLocal.GetByID(fileID); + if (file == null) + return NotFound(FileNotFoundWithFileID); + + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJobNow(c => c.Hash = file.Hash); + + return Ok(); + } + /// /// Unlink all the episodes if no body is given, or only the spesified episodes from the file. /// @@ -1218,7 +1117,7 @@ await scheduler.StartJobNow(c => /// Optional. The body. /// [HttpDelete("{fileID}/Link")] - public async Task UnlinkMultipleEpisodesFromFile([FromRoute] int fileID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] File.Input.UnlinkEpisodesBody body) + public async Task UnlinkMultipleEpisodesFromFile([FromRoute, Range(1, int.MaxValue)] int fileID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] File.Input.UnlinkEpisodesBody body) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) @@ -1508,7 +1407,7 @@ private ActionResult> PathEndsWithInternal(string path, bool includeX return false; var xrefs = file.EpisodeCrossRefs; - var series = xrefs.Count > 0 ? RepoFactory.AnimeSeries.GetByAnimeID(xrefs[0].AnimeID) : null; + var series = xrefs.FirstOrDefault(xref => xref.AnimeID is not 0)?.AnimeSeries; return series == null || User.AllowedSeries(series); }) .DistinctBy(file => file.VideoLocalID); @@ -1599,6 +1498,7 @@ public ActionResult> RegexSearchByFileName([FromRoute] string path) /// Page number. /// Set to false to exclude series and episode cross-references. /// + [Obsolete("Use the universal file endpoint instead.")] [HttpGet("MissingCrossReferenceData")] public ActionResult> GetFilesWithMissingCrossReferenceData( [FromQuery, Range(0, 1000)] int pageSize = 100, diff --git a/Shoko.Server/API/v3/Controllers/FilterController.cs b/Shoko.Server/API/v3/Controllers/FilterController.cs index 8b827b13f..dde469488 100644 --- a/Shoko.Server/API/v3/Controllers/FilterController.cs +++ b/Shoko.Server/API/v3/Controllers/FilterController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; +using Shoko.Commons.Extensions; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; @@ -13,10 +14,10 @@ using Shoko.Server.Extensions; using Shoko.Server.Filters; using Shoko.Server.Filters.Interfaces; -using Shoko.Server.Models; using Shoko.Server.Repositories; using Shoko.Server.Settings; +#pragma warning disable CA1822 namespace Shoko.Server.API.v3.Controllers; [ApiController] @@ -28,7 +29,6 @@ public class FilterController : BaseController internal const string FilterNotFound = "No Filter entry for the given filterID"; private readonly FilterFactory _factory; - private readonly SeriesFactory _seriesFactory; private readonly FilterEvaluator _filterEvaluator; private static Filter.FilterExpressionHelp[] _expressionTypes; private static Filter.SortingCriteriaHelp[] _sortingTypes; @@ -46,8 +46,8 @@ public class FilterController : BaseController /// [HttpGet] public ActionResult> GetAllFilters([FromQuery] bool includeEmpty = false, - [FromQuery] bool showHidden = false, [FromQuery] [Range(0, 100)] int pageSize = 10, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool withConditions = false) + [FromQuery] bool showHidden = false, [FromQuery, Range(0, 100)] int pageSize = 10, + [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] bool withConditions = false) { var user = User; @@ -105,10 +105,11 @@ public ActionResult AddNewFilter(Filter.Input.CreateOrUpdateFilterBody b /// Optional. The Expression types to return /// Optional. The Expression groups to return [HttpGet("Expressions")] - public ActionResult GetExpressions([FromQuery]Filter.FilterExpressionHelp.FilterExpressionParameterType[] types = null, [FromQuery]FilterExpressionGroup[] groups = null) + public ActionResult GetExpressions([FromQuery] Filter.FilterExpressionHelp.FilterExpressionParameterType[] types = null, + [FromQuery] FilterExpressionGroup[] groups = null) { - types ??= Array.Empty(); - groups ??= Array.Empty(); + types ??= []; + groups ??= []; // get all classes that derive from FilterExpression, but not SortingExpression _expressionTypes ??= AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()) @@ -137,6 +138,7 @@ public ActionResult AddNewFilter(Filter.Input.CreateOrUpdateFilterBody b }; Filter.FilterExpressionHelp.FilterExpressionParameterType? parameter = expression switch { + IWithBoolParameter => Filter.FilterExpressionHelp.FilterExpressionParameterType.Bool, IWithDateParameter => Filter.FilterExpressionHelp.FilterExpressionParameterType.Date, IWithNumberParameter => Filter.FilterExpressionHelp.FilterExpressionParameterType.Number, IWithStringParameter => Filter.FilterExpressionHelp.FilterExpressionParameterType.String, @@ -213,7 +215,7 @@ public ActionResult AddNewFilter(Filter.Input.CreateOrUpdateFilterBody b /// Include conditions and sort criteria in the response. /// The filter [HttpGet("{filterID}")] - public ActionResult GetFilter([FromRoute] int filterID, [FromQuery] bool withConditions = false) + public ActionResult GetFilter([FromRoute, Range(1, int.MaxValue)] int filterID, [FromQuery] bool withConditions = false) { var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); if (filterPreset == null) @@ -231,7 +233,7 @@ public ActionResult GetFilter([FromRoute] int filterID, [FromQuery] bool /// The updated filter. [Authorize("admin")] [HttpPatch("{filterID}")] - public ActionResult PatchFilter([FromRoute] int filterID, JsonPatchDocument document) + public ActionResult PatchFilter([FromRoute, Range(1, int.MaxValue)] int filterID, JsonPatchDocument document) { var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); if (filterPreset == null) @@ -265,7 +267,7 @@ public ActionResult PatchFilter([FromRoute] int filterID, JsonPatchDocum /// The updated filter. [Authorize("admin")] [HttpPut("{filterID}")] - public ActionResult PutFilter([FromRoute] int filterID, Filter.Input.CreateOrUpdateFilterBody body) + public ActionResult PutFilter([FromRoute, Range(1, int.MaxValue)] int filterID, Filter.Input.CreateOrUpdateFilterBody body) { var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); if (filterPreset == null) @@ -293,7 +295,7 @@ public ActionResult PutFilter([FromRoute] int filterID, Filter.Input.Cre /// Void. [Authorize("admin")] [HttpDelete("{filterID}")] - public ActionResult DeleteFilter(int filterID) + public ActionResult DeleteFilter([FromRoute, Range(1, int.MaxValue)] int filterID) { var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); if (filterPreset == null) @@ -316,12 +318,12 @@ public ActionResult DeleteFilter(int filterID) /// The page size. Set to 0 to disable pagination. /// The page index. /// Include with missing s in the search. - /// Randomise images shown for the . + /// Randomize images shown for the . /// Ignore the group filter sort criteria and always order the returned list by name. /// [HttpPost("Preview/Group")] public ActionResult> GetPreviewFilteredGroups([FromBody] Filter.Input.CreateOrUpdateFilterBody filter, - [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValue)] int page = 1, + [FromQuery, Range(0, 100)] int pageSize = 50, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeEmpty = false, [FromQuery] bool randomImages = false, [FromQuery] bool orderByName = false) { // Directories should only contain sub-filters, not groups and series. @@ -336,18 +338,16 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu if (!results.Any()) return new ListResult(); var groups = results - .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)) - .Where(group => - { - // not top level groups - if (group == null || group.AnimeGroupParentID.HasValue) - return false; + .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)?.TopLevelAnimeGroup) + .WhereNotNull() + .DistinctBy(group => group.AnimeGroupID) + .Where(group => includeEmpty || group.AllSeries.Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0))); + + if (orderByName) + groups = groups.OrderBy(group => group.SortName); - return includeEmpty || group.AllSeries - .Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0)); - }); return groups - .ToListResult(group => new Group(HttpContext, group, randomImages), page, pageSize); + .ToListResult(group => new Group(group, User.JMMUserID, randomImages), page, pageSize); } /// @@ -373,15 +373,10 @@ public ActionResult> GetPreviewGroupNameLettersInFilter([F return new Dictionary(); return results - .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)) - .Where(group => - { - if (group is not { AnimeGroupParentID: null }) - return false; - - return includeEmpty || group.AllSeries - .Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0)); - }) + .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)?.TopLevelAnimeGroup) + .WhereNotNull() + .DistinctBy(group => group.AnimeGroupID) + .Where(group => includeEmpty || group.AllSeries.Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0))) .GroupBy(group => group.SortName[0]) .OrderBy(groupList => groupList.Key) .ToDictionary(groupList => groupList.Key, groupList => groupList.Count()); @@ -393,13 +388,13 @@ public ActionResult> GetPreviewGroupNameLettersInFilter([F /// The filter to preview /// The page size. Set to 0 to disable pagination. /// The page index. - /// Randomise images shown for each . + /// Randomize images shown for each . /// Include with missing /// s in the count. /// [HttpPost("Preview/Series")] public ActionResult> GetPreviewSeriesInFilteredGroup([FromBody] Filter.Input.CreateOrUpdateFilterBody filter, - [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValue)] int page = 1, + [FromQuery, Range(0, 100)] int pageSize = 50, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] bool randomImages = false, [FromQuery] bool includeMissing = false) { // Directories should only contain sub-filters, not groups and series. @@ -416,8 +411,30 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu // We don't need separate logic for ApplyAtSeriesLevel, as the FilterEvaluator handles that return results.SelectMany(a => a.Select(id => RepoFactory.AnimeSeries.GetByID(id))) .Where(series => series != null && (includeMissing || series.VideoLocals.Count > 0)) - .OrderBy(series => series.SeriesName.ToLowerInvariant()) - .ToListResult(series => _seriesFactory.GetSeries(series, randomImages), page, pageSize); + .OrderBy(series => series.PreferredTitle.ToLowerInvariant()) + .ToListResult(series => new Series(series, User.JMMUserID, randomImages), page, pageSize); + } + + /// + /// Get a raw list of all IDs for the live filter for + /// client-side filtering. + /// + /// The filter to preview + /// + [HttpPost("Preview/Series/OnlyIDs")] + public ActionResult> GetPreviewFilteredSeriesIDs([FromBody] Filter.Input.CreateOrUpdateFilterBody filter) + { + // Directories should only contain sub-filters, not groups and series. + if (filter.IsDirectory) + return new List(); + + // Fast path when user is not in the filter. + var filterPreset = _factory.GetFilterPreset(filter, ModelState); + if (!ModelState.IsValid) return ValidationProblem(ModelState); + + var results = _filterEvaluator.EvaluateFilter(filterPreset, User.JMMUserID); + return results.SelectMany(groupBy => groupBy) + .ToList(); } /// @@ -425,18 +442,17 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu /// /// The filter to preview /// ID - /// Randomise images shown for the . + /// Randomize images shown for the . /// Include with missing s in the search. /// [HttpPost("Preview/Group/{groupID}/Group")] - public ActionResult> GetPreviewFilteredSubGroups([FromBody] Filter.Input.CreateOrUpdateFilterBody filter, [FromRoute] int groupID, + public ActionResult> GetPreviewFilteredSubGroups([FromBody] Filter.Input.CreateOrUpdateFilterBody filter, [FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool randomImages = false, [FromQuery] bool includeEmpty = false) { var filterPreset = _factory.GetFilterPreset(filter, ModelState); if (!ModelState.IsValid) return ValidationProblem(ModelState); // Check if the group exists. - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) return NotFound(GroupController.GroupNotFound); @@ -474,7 +490,7 @@ public ActionResult> GetPreviewFilteredSubGroups([FromBody] Filter.I return groups.Contains(subGroup.AnimeGroupID); }) .OrderBy(a => Array.IndexOf(orderedGroups, a.AnimeGroupID)) - .Select(g => new Group(HttpContext, g, randomImages)) + .Select(g => new Group(g, User.JMMUserID, randomImages)) .ToList(); } @@ -485,11 +501,11 @@ public ActionResult> GetPreviewFilteredSubGroups([FromBody] Filter.I /// ID /// Show all the within the . Even the within the sub-s. /// Include with missing s in the list. - /// Randomise images shown for each within the . + /// Randomize images shown for each within the . /// Include data from selected s. /// /// [HttpPost("Preview/Group/{groupID}/Series")] - public ActionResult> GetPreviewSeriesInFilteredGroup([FromBody] Filter.Input.CreateOrUpdateFilterBody filter, [FromRoute] int groupID, + public ActionResult> GetPreviewSeriesInFilteredGroup([FromBody] Filter.Input.CreateOrUpdateFilterBody filter, [FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool recursive = false, [FromQuery] bool includeMissing = false, [FromQuery] bool randomImages = false, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) { @@ -497,7 +513,6 @@ public ActionResult> GetPreviewSeriesInFilteredGroup([FromBody] Fil if (!ModelState.IsValid) return ValidationProblem(ModelState); // Check if the group exists. - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) return NotFound(GroupController.GroupNotFound); @@ -513,7 +528,7 @@ public ActionResult> GetPreviewSeriesInFilteredGroup([FromBody] Fil return (recursive ? group.AllSeries : group.Series) .Where(a => User.AllowedSeries(a)) .OrderBy(series => series.AniDB_Anime?.AirDate ?? DateTime.MaxValue) - .Select(series => _seriesFactory.GetSeries(series, randomImages, includeDataFrom)) + .Select(series => new Series(series, User.JMMUserID, randomImages, includeDataFrom)) .Where(series => series.Size > 0 || includeMissing) .ToList(); @@ -527,20 +542,17 @@ public ActionResult> GetPreviewSeriesInFilteredGroup([FromBody] Fil ? group.AllChildren.SelectMany(a => results.FirstOrDefault(b => b.Key == a.AnimeGroupID)?.ToList() ?? []).ToList() : results.FirstOrDefault(a => a.Key == groupID)?.ToList(); - var series = seriesIDs?.Select(a => RepoFactory.AnimeSeries.GetByID(a)).Where(a => (a?.VideoLocals.Any() ?? false) || includeMissing) ?? - Array.Empty(); - + var series = seriesIDs?.Select(RepoFactory.AnimeSeries.GetByID).Where(a => includeMissing || ((a?.VideoLocals.Count ?? 0) != 0)) ?? []; return series - .Select(a => _seriesFactory.GetSeries(a, randomImages, includeDataFrom)) + .Select(a => new Series(a, User.JMMUserID, randomImages, includeDataFrom)) .ToList(); } #endregion - public FilterController(ISettingsProvider settingsProvider, FilterFactory factory, SeriesFactory seriesFactory, FilterEvaluator filterEvaluator) : base(settingsProvider) + public FilterController(ISettingsProvider settingsProvider, FilterFactory factory, FilterEvaluator filterEvaluator) : base(settingsProvider) { _factory = factory; - _seriesFactory = seriesFactory; _filterEvaluator = filterEvaluator; } } diff --git a/Shoko.Server/API/v3/Controllers/FolderController.cs b/Shoko.Server/API/v3/Controllers/FolderController.cs index 57e28e3bb..f971cbfce 100644 --- a/Shoko.Server/API/v3/Controllers/FolderController.cs +++ b/Shoko.Server/API/v3/Controllers/FolderController.cs @@ -4,6 +4,7 @@ using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Shoko.Server.API.Annotations; using Shoko.Server.API.v3.Models.Shoko; using Shoko.Server.Settings; @@ -16,8 +17,10 @@ namespace Shoko.Server.API.v3.Controllers; [Authorize] public class FolderController : BaseController { - private static HashSet ExcludedFormats = new() - { + private readonly ILogger _logger; + + private static readonly HashSet _excludedFormats = + [ "msdos", // fat32 - might be overkill, but the esp (u)efi partition is usually formatted as such. "ramfs", "configfs", @@ -35,7 +38,12 @@ public class FolderController : BaseController "proc", "tmpfs", "sysfs", - }; + ]; + + public FolderController(ILogger logger, ISettingsProvider settingsProvider) : base(settingsProvider) + { + _logger = logger; + } [HttpGet("MountPoints")] [HttpGet("Drives")] @@ -52,8 +60,9 @@ public ActionResult> GetMountPoints() { fullName = d.RootDirectory.FullName; } - catch + catch (Exception ex) { + _logger.LogError(ex, "An exception occurred while trying to get the full name of the drive: {ex}", ex.Message); return null; } @@ -62,12 +71,13 @@ public ActionResult> GetMountPoints() { driveFormat = d.DriveFormat; } - catch + catch (Exception ex) { + _logger.LogError("An exception occurred while trying to get the drive format of the drive: {ex}", ex.Message); return null; } - foreach (var format in ExcludedFormats) + foreach (var format in _excludedFormats) { if (driveFormat == format) return null; @@ -84,8 +94,9 @@ public ActionResult> GetMountPoints() } : null; } - catch (UnauthorizedAccessException) + catch (Exception ex) { + _logger.LogError(ex, "An exception occurred while trying to get the child items of the drive: {ex}", ex.Message); } return new Drive() @@ -118,11 +129,13 @@ public ActionResult> GetFolder([FromQuery] string path) { childItems = new ChildItems() { - Files = dir.GetFiles()?.Length ?? 0, Folders = dir.GetDirectories()?.Length ?? 0 + Files = dir.GetFiles()?.Length ?? 0, + Folders = dir.GetDirectories()?.Length ?? 0 }; } - catch (UnauthorizedAccessException) + catch (Exception ex) { + _logger.LogError(ex, "An exception occurred while trying to get the child items of the directory: {ex}", ex.Message); } return new Folder() { Path = dir.FullName, IsAccessible = childItems != null, Sizes = childItems }; @@ -130,8 +143,4 @@ public ActionResult> GetFolder([FromQuery] string path) .OrderBy(folder => folder.Path) .ToList(); } - - public FolderController(ISettingsProvider settingsProvider) : base(settingsProvider) - { - } } diff --git a/Shoko.Server/API/v3/Controllers/GroupController.cs b/Shoko.Server/API/v3/Controllers/GroupController.cs index 7f871a20a..cc8142668 100644 --- a/Shoko.Server/API/v3/Controllers/GroupController.cs +++ b/Shoko.Server/API/v3/Controllers/GroupController.cs @@ -30,8 +30,6 @@ public class GroupController : BaseController #region Return messages - internal const string GroupWithZeroID = "GroupID must be greater than 0"; - internal const string GroupNotFound = "No Group entry for the given groupID"; internal const string GroupForbiddenForUser = "Accessing Group is not allowed for the current user"; @@ -48,13 +46,13 @@ public class GroupController : BaseController /// The page size. /// The page index. /// Include with missing s in the search. - /// Randomise images shown for the main within the . + /// Randomize images shown for the main within the . /// Only list the top level groups if set. /// Search only for groups that start with the given query. /// [HttpGet] - public ActionResult> GetAllGroups([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeEmpty = false, + public ActionResult> GetAllGroups([FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeEmpty = false, [FromQuery] bool randomImages = false, [FromQuery] bool topLevelOnly = true, [FromQuery] string startsWith = "") { startsWith = startsWith.ToLowerInvariant(); @@ -67,7 +65,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeEmpty return false; } - if (!string.IsNullOrEmpty(startsWith) && !group.GroupName.ToLowerInvariant().StartsWith(startsWith)) + if (!string.IsNullOrEmpty(startsWith) && !group.GroupName.StartsWith(startsWith, StringComparison.InvariantCultureIgnoreCase)) { return false; } @@ -81,7 +79,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeEmpty .Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0)); }) .OrderBy(group => group.SortName) - .ToListResult(group => new Group(HttpContext, group, randomImages), page, pageSize); + .ToListResult(group => new Group(group, User.JMMUserID, randomImages), page, pageSize); } /// @@ -143,7 +141,7 @@ public ActionResult CreateGroup([FromBody] Group.Input.CreateOrUpdateGrou MissingEpisodeCountGroups = 0, OverrideDescription = 0, }; - var group = body.MergeWithExisting(HttpContext, animeGroup, ModelState); + var group = body.MergeWithExisting(animeGroup, User.JMMUserID, ModelState); if (!ModelState.IsValid) { return ValidationProblem(ModelState); @@ -162,9 +160,8 @@ public ActionResult CreateGroup([FromBody] Group.Input.CreateOrUpdateGrou /// /// [HttpGet("{groupID}")] - public ActionResult GetGroup([FromRoute] int groupID) + public ActionResult GetGroup([FromRoute, Range(1, int.MaxValue)] int groupID) { - if (groupID == 0) return BadRequest(GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) { @@ -176,7 +173,7 @@ public ActionResult GetGroup([FromRoute] int groupID) return Forbid(GroupForbiddenForUser); } - return new Group(HttpContext, group); + return new Group(group, User.JMMUserID); } /// @@ -190,9 +187,8 @@ public ActionResult GetGroup([FromRoute] int groupID) /// The new details for the group. /// The updated group. [HttpPut("{groupID}")] - public ActionResult PutGroup([FromRoute] int groupID, [FromBody] Group.Input.CreateOrUpdateGroupBody body) + public ActionResult PutGroup([FromRoute, Range(1, int.MaxValue)] int groupID, [FromBody] Group.Input.CreateOrUpdateGroupBody body) { - if (groupID == 0) return BadRequest(GroupWithZeroID); var animeGroup = RepoFactory.AnimeGroup.GetByID(groupID); if (animeGroup == null) { @@ -204,7 +200,7 @@ public ActionResult PutGroup([FromRoute] int groupID, [FromBody] Group.In return Forbid(GroupForbiddenForUser); } - var group = body.MergeWithExisting(HttpContext, animeGroup, ModelState); + var group = body.MergeWithExisting(animeGroup, User.JMMUserID, ModelState); if (!ModelState.IsValid) { return ValidationProblem(ModelState); @@ -226,9 +222,8 @@ public ActionResult PutGroup([FromRoute] int groupID, [FromBody] Group.In /// The JSON Patch document containing the changes to be applied to the group. /// The updated group. [HttpPatch("{groupID}")] - public ActionResult PatchGroup([FromRoute] int groupID, [FromBody] JsonPatchDocument patchDocument) + public ActionResult PatchGroup([FromRoute, Range(1, int.MaxValue)] int groupID, [FromBody] JsonPatchDocument patchDocument) { - if (groupID == 0) return BadRequest(GroupWithZeroID); var animeGroup = RepoFactory.AnimeGroup.GetByID(groupID); if (animeGroup == null) { @@ -248,7 +243,7 @@ public ActionResult PatchGroup([FromRoute] int groupID, [FromBody] JsonPa return ValidationProblem(ModelState); } - var group = body.MergeWithExisting(HttpContext, animeGroup, ModelState); + var group = body.MergeWithExisting(animeGroup, User.JMMUserID, ModelState); if (!ModelState.IsValid) { return ValidationProblem(ModelState); @@ -268,10 +263,9 @@ public ActionResult PatchGroup([FromRoute] int groupID, [FromBody] JsonPa /// Show relations for all series within the group, even for series within sub-groups. /// [HttpGet("{groupID}/Relations")] - public ActionResult> GetShokoRelationsBySeriesID([FromRoute] int groupID, + public ActionResult> GetShokoRelationsBySeriesID([FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool recursive = false) { - if (groupID == 0) return BadRequest(GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) { @@ -315,9 +309,8 @@ public ActionResult> GetShokoRelationsBySeriesID([FromRoute /// [Authorize("admin")] [HttpDelete("{groupID}")] - public async Task DeleteGroup(int groupID, bool deleteSeries = false, bool deleteFiles = false) + public async Task DeleteGroup([FromRoute, Range(1, int.MaxValue)] int groupID, bool deleteSeries = false, bool deleteFiles = false) { - if (groupID == 0) return BadRequest(GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) { @@ -351,9 +344,8 @@ public async Task DeleteGroup(int groupID, bool deleteSeries = fal /// /// [HttpPost("{groupID}/Recalculate")] - public async Task RecalculateStats(int groupID) + public async Task RecalculateStats([FromRoute, Range(1, int.MaxValue)] int groupID) { - if (groupID == 0) return BadRequest(GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) { @@ -374,7 +366,7 @@ public async Task RecalculateStats(int groupID) /// [Authorize("admin")] [HttpGet("RecreateAllGroups")] - [Obsolete] + [Obsolete("Use the actions endpoint instead.")] public ActionResult RecreateAllGroups() { Task.Run(async () => await _groupCreator.RecreateAllGroups()); diff --git a/Shoko.Server/API/v3/Controllers/ImageController.cs b/Shoko.Server/API/v3/Controllers/ImageController.cs index e5c51680e..e0659707d 100644 --- a/Shoko.Server/API/v3/Controllers/ImageController.cs +++ b/Shoko.Server/API/v3/Controllers/ImageController.cs @@ -1,12 +1,14 @@ -using System.IO; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.Annotations; using Shoko.Server.API.v3.Models.Common; -using Shoko.Server.Properties; using Shoko.Server.Repositories; using Shoko.Server.Settings; -using Mime = MimeMapping.MimeUtility; +using Shoko.Server.Utilities; namespace Shoko.Server.API.v3.Controllers; @@ -20,102 +22,127 @@ public class ImageController : BaseController /// /// Returns the image for the given , and . /// - /// AniDB, TvDB, MovieDB, Shoko - /// Poster, Fanart, Banner, Thumb, Static - /// Usually the ID, but the resource name in the case of image/Shoko/Static/{value} + /// AniDB, TMDB, Shoko, etc. + /// Poster, Backdrop, Banner, Thumbnail, etc. + /// The image ID. /// 200 on found, 400/404 if the type or source are invalid, and 404 if the id is not found [HttpGet("{source}/{type}/{value}")] - [HttpHead("{source}/{type}/{value}")] [ResponseCache(Duration = 3600 /* 1 hour in seconds */)] [ProducesResponseType(typeof(FileStreamResult), 200)] [ProducesResponseType(404)] public ActionResult GetImage([FromRoute] Image.ImageSource source, [FromRoute] Image.ImageType type, - [FromRoute] string value) + [FromRoute, Range(1, int.MaxValue)] int value) { - // No value no image. - if (string.IsNullOrEmpty(value)) + // Unrecognized combination of source, type and/or value. + var dataSource = source.ToServer(); + var imageEntityType = type.ToServer(); + if (imageEntityType == ImageEntityType.None || dataSource == DataSourceType.None) return NotFound(ImageNotFound); - var sourceType = Image.GetImageTypeFromSourceAndType(source, type) ?? ImageEntityType.None; - switch (sourceType) + // User avatars are stored in the database. + if (imageEntityType == ImageEntityType.Art && dataSource == DataSourceType.User) { - // Unrecognised combination of source and type. - case ImageEntityType.None: + var user = RepoFactory.JMMUser.GetByID(value); + if (!user.HasAvatarImage) return NotFound(ImageNotFound); - // Static resources are stored in the resource manager. - case ImageEntityType.Static: - { - var fileName = Path.GetFileNameWithoutExtension(value); - var buffer = (byte[])Resources.ResourceManager.GetObject(fileName); - if (buffer == null || buffer.Length == 0) - return NotFound(ImageNotFound); - - return File(buffer, Mime.GetMimeMapping(fileName) ?? "image/png"); - } - - // User avatars are stored in the database. - case ImageEntityType.UserAvatar: - { - if (!int.TryParse(value, out var id)) - return NotFound(ImageNotFound); - - var user = RepoFactory.JMMUser.GetByID(id); - if (!user.HasAvatarImage) - return NotFound(ImageNotFound); - - return File(user.AvatarImageBlob, user.AvatarImageMetadata.ContentType); - } - - // All other valid types. - default: - { - if (!int.TryParse(value, out var id)) - return NotFound(ImageNotFound); - - var path = Image.GetImagePath(sourceType, id); - if (string.IsNullOrEmpty(path)) - return NotFound(ImageNotFound); - - return File(System.IO.File.OpenRead(path), Mime.GetMimeMapping(path)); - } + return File(user.AvatarImageBlob, user.AvatarImageMetadata.ContentType); } + + var metadata = ImageUtils.GetImageMetadata(dataSource, imageEntityType, value); + if (metadata is null || metadata.GetStream() is not { } stream) + return NotFound(ImageNotFound); + + return File(stream, metadata.ContentType); + } + + /// + /// Enable or disable an image. Disabled images are hidden unless explicitly + /// asked for. + /// + /// AniDB, TMDB, Shoko, etc. + /// Poster, Backdrop, Banner, Thumbnail, etc. + /// The image ID. + /// + /// + [Authorize("admin")] + [HttpPost("{source}/{type}/{value}/Enabled")] + public ActionResult EnableOrDisableImage([FromRoute] Image.ImageSource source, [FromRoute] Image.ImageType type, [FromRoute, Range(1, int.MaxValue)] int value, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Image.Input.EnableImageBody body) + { + // Unrecognized combination of source, type and/or value. + var dataSource = source.ToServer(); + var imageEntityType = type.ToServer(); + if (imageEntityType == ImageEntityType.None || dataSource == DataSourceType.None) + return NotFound(ImageNotFound); + + // User avatars are stored in the database. + if (imageEntityType == ImageEntityType.Art && dataSource == DataSourceType.User) + { + var user = RepoFactory.JMMUser.GetByID(value); + if (!user.HasAvatarImage) + return NotFound(ImageNotFound); + + return ValidationProblem($"Unable to enable or disable user avatar with id {value}!"); + } + + var metadata = ImageUtils.GetImageMetadata(dataSource, imageEntityType, value); + if (metadata is null) + return NotFound(ImageNotFound); + + if (!ImageUtils.SetEnabled(dataSource, imageEntityType, value, body.Enabled)) + return ValidationProblem($"Unable to enable or disable {source} {type} with id {value}!"); + + return NoContent(); } /// /// Returns a random image for the . /// - /// Poster, Fanart, Banner, Thumb, Static + /// Poster, Backdrop, Banner, Thumb, Static /// 200 on found, 400/404 if the type or source are invalid, and 404 if the id is not found [HttpGet("Random/{imageType}")] [ProducesResponseType(typeof(FileStreamResult), 200)] [ProducesResponseType(400)] + [ProducesResponseType(404)] [ProducesResponseType(500)] public ActionResult GetRandomImageForType([FromRoute] Image.ImageType imageType) { - if (imageType == Image.ImageType.Static || imageType == Image.ImageType.Avatar) + if (imageType == Image.ImageType.Avatar) return ValidationProblem("Unsupported image type for random image.", "imageType"); - var source = Image.GetRandomImageSource(imageType); - var sourceType = Image.GetImageTypeFromSourceAndType(source, imageType) ?? ImageEntityType.None; - if (sourceType == ImageEntityType.None) + var dataSource = Image.GetRandomImageSource(imageType); + var imageEntityType = imageType.ToServer(); + if (imageEntityType == ImageEntityType.None) return InternalError("Could not generate a valid image type to fetch."); - var id = Image.GetRandomImageID(sourceType); - if (!id.HasValue) - return InternalError("Unable to find a random image to send."); + // Try 5 times to get a valid image. + var tries = 0; + do + { + var metadata = ImageUtils.GetRandomImageID(dataSource, imageEntityType); + if (metadata is null) + break; + + if (!metadata.IsLocalAvailable) + continue; + + var series = ImageUtils.GetFirstSeriesForImage(metadata); + if (series == null || (series.AniDB_Anime?.IsRestricted ?? false)) + continue; + + if (metadata.GetStream(allowRemote: false) is not { } stream) + continue; - var path = Image.GetImagePath(sourceType, id.Value); - if (string.IsNullOrEmpty(path)) - return InternalError("Unable to load image from disk."); + return File(stream, metadata.ContentType); + } while (tries++ < 5); - return File(System.IO.File.OpenRead(path), Mime.GetMimeMapping(path)); + return InternalError("Unable to find a random image to send."); } /// /// Returns the metadata for a random image for the . /// - /// Poster, Fanart, Banner, Thumb + /// Poster, Backdrop, Banner, Thumb /// 200 on found, 400 if the type or source are invalid [HttpGet("Random/{imageType}/Metadata")] [ProducesResponseType(typeof(Image), 200)] @@ -123,30 +150,31 @@ public ActionResult GetRandomImageForType([FromRoute] Image.ImageType imageType) [ProducesResponseType(500)] public ActionResult GetRandomImageMetadataForType([FromRoute] Image.ImageType imageType) { - if (imageType == Image.ImageType.Static) + if (imageType == Image.ImageType.Avatar) return ValidationProblem("Unsupported image type for random image.", "imageType"); - var source = Image.GetRandomImageSource(imageType); - var sourceType = Image.GetImageTypeFromSourceAndType(source, imageType) ?? ImageEntityType.None; - if (sourceType == ImageEntityType.None) + var dataSource = Image.GetRandomImageSource(imageType); + var imageEntityType = imageType.ToServer(); + if (imageEntityType == ImageEntityType.None) return InternalError("Could not generate a valid image type to fetch."); // Try 5 times to get a valid image. var tries = 0; - do + do { - var id = Image.GetRandomImageID(sourceType); - if (!id.HasValue) + var metadata = ImageUtils.GetRandomImageID(dataSource, imageEntityType); + if (metadata is null) break; - var path = Image.GetImagePath(sourceType, id.Value); - if (string.IsNullOrEmpty(path)) + if (!metadata.IsLocalAvailable) + continue; + + var image = new Image(metadata); + var series = ImageUtils.GetFirstSeriesForImage(metadata); + if (series == null || (series.AniDB_Anime?.IsRestricted ?? false)) continue; - var image = new Image(id.Value, sourceType, false, false); - var series = Image.GetFirstSeriesForImage(sourceType, id.Value); - if (series != null) - image.Series = new(series.AnimeSeriesID, series.SeriesName); + image.Series = new(series.AnimeSeriesID, series.PreferredTitle); return image; } while (tries++ < 5); diff --git a/Shoko.Server/API/v3/Controllers/ImportFolderController.cs b/Shoko.Server/API/v3/Controllers/ImportFolderController.cs index fd82d8e8d..92b0ac21b 100644 --- a/Shoko.Server/API/v3/Controllers/ImportFolderController.cs +++ b/Shoko.Server/API/v3/Controllers/ImportFolderController.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -40,14 +42,19 @@ public ActionResult> GetAllImportFolders() public ActionResult AddImportFolder([FromBody] ImportFolder folder) { if (!ModelState.IsValid) - { return ValidationProblem(ModelState); - } - if (folder.Path == string.Empty) - { - return ValidationProblem("The Folder path must not be Empty", nameof(folder.Path)); - } + if (string.IsNullOrEmpty(folder.Path)) + return ValidationProblem("Path not provided. Import Folders must be a location that exists on the server.", nameof(folder.Path)); + + folder.Path = folder.Path.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + if (folder.Path[^1] != Path.DirectorySeparatorChar) + folder.Path += Path.DirectorySeparatorChar; + if (!Directory.Exists(folder.Path)) + return ValidationProblem("Path does not exist. Import Folders must be a location that exists on the server.", nameof(folder.Path)); + + if (RepoFactory.ImportFolder.GetAll().ExceptBy([folder.ID], iF => iF.ImportFolderID).Any(iF => folder.Path.StartsWith(iF.ImportFolderLocation, StringComparison.OrdinalIgnoreCase) || iF.ImportFolderLocation.StartsWith(folder.Path, StringComparison.OrdinalIgnoreCase))) + return ValidationProblem("Unable to nest an import folder within another import folder."); try { @@ -69,9 +76,8 @@ public ActionResult AddImportFolder([FromBody] ImportFolder folder /// Import Folder ID /// [HttpGet("{folderID}")] - public ActionResult GetImportFolderByFolderID([FromRoute] int folderID) + public ActionResult GetImportFolderByFolderID([FromRoute, Range(1, int.MaxValue)] int folderID) { - if (folderID == 0) return BadRequest("ID must be greater than 0"); var folder = RepoFactory.ImportFolder.GetByID(folderID); if (folder == null) { @@ -89,7 +95,7 @@ public ActionResult GetImportFolderByFolderID([FromRoute] int fold /// [Authorize("admin")] [HttpPatch("{folderID}")] - public ActionResult PatchImportFolderByFolderID([FromRoute] int folderID, + public ActionResult PatchImportFolderByFolderID([FromRoute, Range(1, int.MaxValue)] int folderID, [FromBody] JsonPatchDocument patch) { if (patch == null) @@ -105,11 +111,8 @@ public ActionResult PatchImportFolderByFolderID([FromRoute] int folderID, var patchModel = new ImportFolder(existing); patch.ApplyTo(patchModel, ModelState); - TryValidateModel(patchModel); - if (!ModelState.IsValid) - { + if (!TryValidateModel(patchModel)) return ValidationProblem(ModelState); - } var serverModel = patchModel.GetServerModel(); RepoFactory.ImportFolder.SaveImportFolder(serverModel); @@ -126,7 +129,17 @@ public ActionResult PatchImportFolderByFolderID([FromRoute] int folderID, public ActionResult EditImportFolder([FromBody] ImportFolder folder) { if (string.IsNullOrEmpty(folder.Path)) - ModelState.AddModelError(nameof(folder.Path), "Path missing. Import Folders must be a location that exists on the server."); + ModelState.AddModelError(nameof(folder.Path), "Path not provided. Import Folders must be a location that exists on the server."); + + folder.Path = folder.Path?.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar) ?? string.Empty; + if (folder.Path[^1] != Path.DirectorySeparatorChar) + folder.Path += Path.DirectorySeparatorChar; + if (!string.IsNullOrEmpty(folder.Path) && !Directory.Exists(folder.Path)) + ModelState.AddModelError(nameof(folder.Path), "Path does not exist. Import Folders must be a location that exists on the server."); + + if (RepoFactory.ImportFolder.GetAll().ExceptBy([folder.ID], iF => iF.ImportFolderID) + .Any(iF => folder.Path.StartsWith(iF.ImportFolderLocation, StringComparison.OrdinalIgnoreCase) || iF.ImportFolderLocation.StartsWith(folder.Path, StringComparison.OrdinalIgnoreCase))) + ModelState.AddModelError(nameof(folder.Path), "Unable to nest an import folder within another import folder."); if (folder.ID == 0) ModelState.AddModelError(nameof(folder.ID), "ID missing. If this is a new Folder, then use POST."); @@ -150,14 +163,9 @@ public ActionResult EditImportFolder([FromBody] ImportFolder folder) /// [Authorize("admin")] [HttpDelete("{folderID}")] - public async Task DeleteImportFolderByFolderID([FromRoute] int folderID, [FromQuery] bool removeRecords = true, + public async Task DeleteImportFolderByFolderID([FromRoute, Range(1, int.MaxValue)] int folderID, [FromQuery] bool removeRecords = true, [FromQuery] bool updateMyList = true) { - if (folderID == 0) - { - return NotFound("Folder not found."); - } - if (!removeRecords) { // These are annoying to clean up later, so do it now. We can easily recreate them. @@ -182,7 +190,7 @@ public async Task DeleteImportFolderByFolderID([FromRoute] int fol /// Import Folder ID /// [HttpGet("{folderID}/Scan")] - public async Task ScanImportFolderByFolderID([FromRoute] int folderID) + public async Task ScanImportFolderByFolderID([FromRoute, Range(1, int.MaxValue)] int folderID) { var folder = RepoFactory.ImportFolder.GetByID(folderID); if (folder == null) diff --git a/Shoko.Server/API/v3/Controllers/InitController.cs b/Shoko.Server/API/v3/Controllers/InitController.cs index 43ff63d48..de49d3f57 100644 --- a/Shoko.Server/API/v3/Controllers/InitController.cs +++ b/Shoko.Server/API/v3/Controllers/InitController.cs @@ -2,16 +2,20 @@ using System.Diagnostics; using System.IO; using System.Reflection; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.Annotations; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.API.WebUI; using Shoko.Server.Databases; +using Shoko.Server.Providers.AniDB.Interfaces; using Shoko.Server.Server; using Shoko.Server.Settings; using Shoko.Server.Utilities; + using Constants = Shoko.Server.Server.Constants; using ServerStatus = Shoko.Server.API.v3.Models.Shoko.ServerStatus; @@ -27,12 +31,22 @@ namespace Shoko.Server.API.v3.Controllers; public class InitController : BaseController { private readonly ILogger _logger; - private readonly ShokoServer _shokoServer; + private readonly IConnectivityService _connectivityService; + private readonly IUDPConnectionHandler _udpHandler; + private readonly IHttpConnectionHandler _httpHandler; - public InitController(ILogger logger, ISettingsProvider settingsProvider, ShokoServer shokoServer) : base(settingsProvider) + public InitController( + ISettingsProvider settingsProvider, + ILogger logger, + IConnectivityService connectivityService, + IUDPConnectionHandler udpHandler, + IHttpConnectionHandler httpHandler + ) : base(settingsProvider) { _logger = logger; - _shokoServer = shokoServer; + _connectivityService = connectivityService; + _udpHandler = udpHandler; + _httpHandler = httpHandler; } /// @@ -72,10 +86,10 @@ public ComponentVersionSet GetVersion() { versionSet.WebUI = new() { - Version = webuiVersion.package, - ReleaseChannel = webuiVersion.debug ? ReleaseChannel.Debug : webuiVersion.package.Contains("-dev") ? ReleaseChannel.Dev : ReleaseChannel.Stable, - Commit = webuiVersion.git, - ReleaseDate = webuiVersion.date, + Version = webuiVersion.Package, + ReleaseChannel = webuiVersion.Debug ? ReleaseChannel.Debug : webuiVersion.Package.Contains("-dev") ? ReleaseChannel.Dev : ReleaseChannel.Stable, + Commit = webuiVersion.Git, + ReleaseDate = webuiVersion.Date, }; } @@ -119,6 +133,38 @@ public ServerStatus GetServerStatus() return status; } + /// + /// Gets the current network connectivity details for the server. + /// + /// + [InitFriendly] + [HttpGet("Connectivity")] + public ActionResult GetNetworkAvailability() + { + return new ConnectivityDetails + { + NetworkAvailability = _connectivityService.NetworkAvailability, + LastChangedAt = _connectivityService.LastChangedAt, + IsAniDBUdpReachable = _udpHandler.IsAlive && _udpHandler.IsNetworkAvailable, + IsAniDBUdpBanned = _udpHandler.IsBanned, + IsAniDBHttpBanned = _httpHandler.IsBanned + }; + } + + /// + /// Forcefully re-checks the current network connectivity, then returns the + /// updated details for the server. + /// + /// + [Authorize("admin")] + [HttpPost("Connectivity")] + public async Task> CheckNetworkAvailability() + { + await _connectivityService.CheckAvailability(); + + return GetNetworkAvailability(); + } + /// /// Gets whether anything is actively using the API /// @@ -128,7 +174,7 @@ public bool ApiInUse() { return ServerState.Instance.ApiInUse; } - + /// /// Gets the Default user's credentials. Will only return on first run /// diff --git a/Shoko.Server/API/v3/Controllers/PlaylistController.cs b/Shoko.Server/API/v3/Controllers/PlaylistController.cs new file mode 100644 index 000000000..287758eb0 --- /dev/null +++ b/Shoko.Server/API/v3/Controllers/PlaylistController.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Shoko.Server.API.Annotations; +using Shoko.Server.API.ModelBinders; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.API.v3.Models.Shoko; +using Shoko.Server.Models; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Services; +using Shoko.Server.Settings; + +#nullable enable +namespace Shoko.Server.API.v3.Controllers; + +[ApiController, Route("/api/v{version:apiVersion}/[controller]"), ApiV3, Authorize] +public class PlaylistController : BaseController +{ + private readonly GeneratedPlaylistService _playlistService; + + private readonly AnimeSeriesRepository _seriesRepository; + + private readonly AnimeEpisodeRepository _episodeRepository; + + private readonly VideoLocalRepository _videoRepository; + + public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistService playlistService, AnimeSeriesRepository animeSeriesRepository, AnimeEpisodeRepository animeEpisodeRepository, VideoLocalRepository videoRepository) : base(settingsProvider) + { + _playlistService = playlistService; + _seriesRepository = animeSeriesRepository; + _episodeRepository = animeEpisodeRepository; + _videoRepository = videoRepository; + } + + /// + /// Generate an on-demand playlist for the specified list of items. + /// + /// The list of item IDs to include in the playlist. If no prefix is provided for an id then it will be assumed to be a series id. + /// Include media info data. + /// Include absolute paths for the file locations. + /// Include file/episode cross-references with the episodes. + /// Include data from selected s. + /// + [HttpGet("Generate")] + public ActionResult> GetGeneratedPlaylistJson( + [FromQuery(Name = "playlist"), ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null, + [FromQuery] bool includeMediaInfo = false, + [FromQuery] bool includeAbsolutePaths = false, + [FromQuery] bool includeXRefs = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null + ) + { + if (!_playlistService.TryParsePlaylist(items ?? [], out var playlist, ModelState)) + return ValidationProblem(ModelState); + + return playlist + .Select(tuple => new PlaylistItem( + tuple.episodes + .Select(episode => new Episode(HttpContext, (episode as SVR_AnimeEpisode)!, includeDataFrom, withXRefs: includeXRefs)) + .ToList(), + tuple.videos + .Select(video => new File(HttpContext, (video as SVR_VideoLocal)!, withXRefs: includeXRefs, includeDataFrom, includeMediaInfo, includeAbsolutePaths)) + .ToList() + )) + .ToList(); + } + + /// + /// Generate an on-demand playlist for the specified list of items, as a .m3u8 file. + /// + /// The list of item IDs to include in the playlist. If no prefix is provided for an id then it will be assumed to be a series id. + /// + [ProducesResponseType(typeof(FileStreamResult), 200)] + [ProducesResponseType(404)] + [Produces("application/x-mpegURL")] + [HttpGet("Generate.m3u8")] + [HttpHead("Generate.m3u8")] + public ActionResult GetGeneratedPlaylistM3U8( + [FromQuery(Name = "playlist"), ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null + ) + { + if (!_playlistService.TryParsePlaylist(items ?? [], out var playlist, ModelState)) + return ValidationProblem(ModelState); + + return _playlistService.GeneratePlaylist(playlist, "Mixed"); + } +} diff --git a/Shoko.Server/API/v3/Controllers/ReleaseManagementController.cs b/Shoko.Server/API/v3/Controllers/ReleaseManagementController.cs index 579f3bb1d..acc7609d9 100644 --- a/Shoko.Server/API/v3/Controllers/ReleaseManagementController.cs +++ b/Shoko.Server/API/v3/Controllers/ReleaseManagementController.cs @@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Shoko.Commons.Extensions; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; @@ -14,6 +13,7 @@ using Shoko.Server.Settings; using Shoko.Server.Utilities; +#pragma warning disable CA1822 namespace Shoko.Server.API.v3.Controllers; [ApiController] @@ -21,9 +21,6 @@ namespace Shoko.Server.API.v3.Controllers; [ApiV3] public class ReleaseManagementController : BaseController { - private readonly ILogger _logger; - private readonly SeriesFactory _seriesFactory; - /// /// Get series with multiple releases. /// @@ -34,7 +31,7 @@ public class ReleaseManagementController : BaseController /// Page number. /// [HttpGet("Series")] - public ActionResult> GetSeriesWithMultipleReleases( + public ActionResult> GetSeriesWithMultipleReleases( [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, [FromQuery] bool ignoreVariations = true, [FromQuery] bool onlyFinishedSeries = false, @@ -45,9 +42,9 @@ public ActionResult> GetSeriesWithM if (onlyFinishedSeries) enumerable = enumerable.Where(a => a.AniDB_Anime.GetFinishedAiring()); return enumerable - .OrderBy(series => series.SeriesName) + .OrderBy(series => series.PreferredTitle) .ThenBy(series => series.AniDB_ID) - .ToListResult(series => _seriesFactory.GetSeriesWithMultipleReleasesResult(series, false, includeDataFrom, ignoreVariations), page, pageSize); + .ToListResult(series => new Series.WithMultipleReleasesResult(series, User.JMMUserID, includeDataFrom, ignoreVariations), page, pageSize); } /// @@ -65,7 +62,7 @@ public ActionResult> GetSeriesWithM /// [HttpGet("Series/{seriesID}")] public ActionResult> GetEpisodesForSeries( - [FromRoute] int seriesID, + [FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, [FromQuery] bool includeFiles = true, [FromQuery] bool includeMediaInfo = true, @@ -75,7 +72,6 @@ public ActionResult> GetEpisodesForSeries( [FromQuery, Range(0, 1000)] int pageSize = 100, [FromQuery, Range(1, int.MaxValue)] int page = 1) { - if (seriesID == 0) return BadRequest(SeriesController.SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return new ListResult(); @@ -127,11 +123,10 @@ public ActionResult> GetEpisodes( /// [HttpGet("Series/{seriesID}/Episode/FilesToDelete")] public ActionResult> GetFileIdsWithPreference( - [FromRoute] int seriesID, + [FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool ignoreVariations = true ) { - if (seriesID == 0) return BadRequest(SeriesController.SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return new List(); @@ -182,9 +177,7 @@ public ActionResult> GetFileIdsWithPreference( .ToList(); } - public ReleaseManagementController(ISettingsProvider settingsProvider, ILogger logger, SeriesFactory seriesFactory) : base(settingsProvider) + public ReleaseManagementController(ISettingsProvider settingsProvider) : base(settingsProvider) { - _logger = logger; - _seriesFactory = seriesFactory; } } diff --git a/Shoko.Server/API/v3/Controllers/RenamerController.cs b/Shoko.Server/API/v3/Controllers/RenamerController.cs index 6f2a307e3..1df473ab9 100644 --- a/Shoko.Server/API/v3/Controllers/RenamerController.cs +++ b/Shoko.Server/API/v3/Controllers/RenamerController.cs @@ -1,20 +1,31 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Force.DeepCloner; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Attributes; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.Annotations; +using Shoko.Server.API.v3.Models.Shoko.Relocation; using Shoko.Server.Renamer; using Shoko.Server.Repositories.Cached; using Shoko.Server.Repositories.Direct; using Shoko.Server.Services; -using Shoko.Server.Settings; - -using ApiRenamer = Shoko.Server.API.v3.Models.Shoko.Renamer; -using RenameFileHelper = Shoko.Server.Renamer.RenameFileHelper; +using Shoko.Server.Utilities; +using ApiRenamer = Shoko.Server.API.v3.Models.Shoko.Relocation.Renamer; +using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider; +using RelocationResult = Shoko.Server.API.v3.Models.Shoko.Relocation.RelocationResult; #nullable enable namespace Shoko.Server.API.v3.Controllers; @@ -25,315 +36,686 @@ namespace Shoko.Server.API.v3.Controllers; [Authorize] public class RenamerController : BaseController { - private readonly VideoLocal_PlaceService _vlpService; - + private readonly ILogger _logger; + private readonly ImportFolderRepository _importFolderRepository; private readonly VideoLocalRepository _vlRepository; + private readonly VideoLocal_PlaceRepository _vlpRepository; + private readonly VideoLocal_PlaceService _vlpService; + private readonly RenamerConfigRepository _renamerConfigRepository; + private readonly RenameFileService _renameFileService; + private readonly ISettingsProvider _settingsProvider; - private readonly RenameScriptRepository _rsRepository; - - public RenamerController(ISettingsProvider settingsProvider, VideoLocal_PlaceService vlpService, VideoLocalRepository vlRepository, RenameScriptRepository rsRepository) : base(settingsProvider) + public RenamerController(ILogger logger, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlpService, VideoLocalRepository vlRepository, RenamerConfigRepository renamerConfigRepository, RenameFileService renameFileService, ImportFolderRepository importFolderRepository, VideoLocal_PlaceRepository vlpRepository) : base(settingsProvider) { + _logger = logger; + _settingsProvider = settingsProvider; _vlpService = vlpService; _vlRepository = vlRepository; - _rsRepository = rsRepository; + _renamerConfigRepository = renamerConfigRepository; + _renameFileService = renameFileService; + _importFolderRepository = importFolderRepository; + _vlpRepository = vlpRepository; } /// - /// Get a list of all s. + /// Get a list of all Renamers. /// /// [HttpGet] public ActionResult> GetAllRenamers() { - return RenameFileHelper.Renamers - .Select(p => new ApiRenamer(p.Key, p.Value)) - .ToList(); + return _renameFileService.AllRenamers.Select(a => GetRenamer(a.Key, a.Value)).ToList(); } /// - /// Preview batch changes to files. + /// Get the Renamer by the given RenamerID /// - /// Contains the files, renamer and script to use for the preview. - /// A stream of relocate results. - [Authorize("admin")] - [HttpPost("Preview")] - public ActionResult> BatchPreviewRelocateFiles([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] ApiRenamer.Input.BatchPreviewAutoRelocateWithRenamerBody body) + /// RenamerID + /// + [HttpGet("{renamerID}")] + public ActionResult GetRenamer([FromRoute] string renamerID) { - if (!RenameFileHelper.Renamers.ContainsKey(body.RenamerName)) - ModelState.AddModelError(nameof(body.RenamerName), "Renamer not found."); + if (!_renameFileService.RenamersByKey.TryGetValue(renamerID, out var value)) + return NotFound("Renamer not found"); - if (!ModelState.IsValid) - return ValidationProblem(ModelState); + return GetRenamer(value, true); + } - return new ActionResult>( - InternalBatchRelocateFiles(body.FileIDs, new() { RenamerName = body.RenamerName, ScriptBody = body.ScriptBody, Preview = true, Move = body.Move }) - ); + private static ApiRenamer GetRenamer(IBaseRenamer renamer, bool enabled) + { + // we can suppress nullability, because we check this when loading + var attribute = renamer.GetType().GetCustomAttributes().FirstOrDefault()!; + var settingsType = renamer.GetType().GetInterfaces().FirstOrDefault(a => a.IsGenericType && a.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetGenericArguments().FirstOrDefault(); + var settings = new List(); + if (settingsType == null) + return new ApiRenamer + { + RenamerID = attribute.RenamerId, + Name = renamer.Name, + Description = renamer.Description, + Version = renamer.GetType().Assembly.GetName().Version?.ToString(), + Enabled = enabled, + Settings = settings + }; + + // settings + var properties = settingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var property in properties) + { + var renamerSettingAttribute = property.GetCustomAttribute(); + var rangeAttribute = property.GetCustomAttribute(); + var settingType = renamerSettingAttribute?.Type ?? RenamerSettingType.Auto; + if (settingType == RenamerSettingType.Auto) + { + // we can't use a switch statement, because typeof() is not supported as a constant + if (property.PropertyType == typeof(bool) || property.PropertyType == typeof(bool?)) + settingType = RenamerSettingType.Boolean; + else if (property.PropertyType == typeof(int) || property.PropertyType == typeof(int?)) + settingType = RenamerSettingType.Integer; + else if (property.PropertyType == typeof(string)) + settingType = RenamerSettingType.Text; + else if (property.PropertyType == typeof(double) || property.PropertyType == typeof(double?)) + settingType = RenamerSettingType.Decimal; + } + + settings.Add(new SettingDefinition + { + Name = renamerSettingAttribute?.Name ?? property.Name, + Description = renamerSettingAttribute?.Description, + Language = settingType is RenamerSettingType.Code ? renamerSettingAttribute?.Language : null, + SettingType = settingType, + MinimumValue = rangeAttribute?.Minimum, + MaximumValue = rangeAttribute?.Maximum, + }); + } + + var defaultSettings = new List(); + properties = settingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var defaultSettingsObject = renamer.GetType().GetInterfaces().FirstOrDefault(a => a.IsGenericType && a.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetProperties(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(a => a.Name == "DefaultSettings")?.GetMethod?.Invoke(renamer, null); + + foreach (var property in properties) + { + var renamerSettingAttribute = property.GetCustomAttribute(); + defaultSettings.Add(new Setting + { + Name = renamerSettingAttribute?.Name ?? property.Name, + Value = property.GetValue(defaultSettingsObject) + }); + } + return new ApiRenamer + { + RenamerID = attribute.RenamerId, + Name = renamer.Name, + Description = renamer.Description, + Version = renamer.GetType().Assembly.GetName().Version?.ToString(), + Enabled = enabled, + Settings = settings, + DefaultSettings = defaultSettings + }; } /// - /// Get the by the given . + /// Get a list of all Configs /// - /// Renamer ID /// - [HttpGet("{renamerName}")] - public ActionResult GetRenamer([FromRoute] string renamerName) + [HttpGet("Config")] + public ActionResult> GetAllRenamerConfigs() + { + return _renamerConfigRepository.GetAll().Select(GetRenamerConfig).WhereNotNull().ToList(); + } + + private static RenamerConfig GetRenamerConfig(Shoko.Server.Models.RenamerConfig p) { - if (!RenameFileHelper.Renamers.TryGetValue(renamerName, out var value)) - return NotFound("Renamer not found."); + // p.Type can be null if the config exists but the renamer doesn't + if (p.Type is null) + return new RenamerConfig { RenamerID = string.Empty, Name = p.Name }; + + // we can suppress nullability, because we check this when loading + var attribute = p.Type.GetCustomAttributes().FirstOrDefault()!; + var settingsType = p.Type.GetInterfaces().FirstOrDefault(a => a.IsGenericType && a.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetGenericArguments().FirstOrDefault(); + var settings = new List(); + if (settingsType == null) + return new RenamerConfig { RenamerID = attribute.RenamerId, Name = p.Name, Settings = settings }; + + // settings + var properties = settingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var property in properties) + { + var renamerSettingAttribute = property.GetCustomAttribute(); + settings.Add(new Setting + { + Name = renamerSettingAttribute?.Name ?? property.Name, + Value = property.GetValue(p.Settings) + }); + } - return new ApiRenamer(renamerName, value); + return new RenamerConfig + { + RenamerID = attribute.RenamerId, + Name = p.Name, + Settings = settings, + }; } /// - /// Modifies the settings of the with the - /// given /// . + /// Get the Renamer by the given Config Name /// - /// - /// The name of the renamer to be updated. - /// - /// - /// An object containing the modifications to be applied to the renamer. - /// - /// - /// The modified renamer if the operation is successful, or an error - /// response if the renamer is not found or the modification fails. - /// + /// Config Name + /// + [HttpGet("Config/{configName}/Renamer")] + public ActionResult GetRenamerFromConfig([FromRoute] string configName) + { + var renamerConfig = _renamerConfigRepository.GetByName(configName); + if (renamerConfig == null) + return NotFound("Config not found"); + if (!_renameFileService.RenamersByType.TryGetValue(renamerConfig.Type, out var value)) + return NotFound("Renamer not found"); + + return GetRenamer(value, true); + } + + /// + /// Get the Config by the given Name + /// + /// Config Name + /// + [HttpGet("Config/{configName}")] + public ActionResult GetRenamerConfig([FromRoute] string configName) + { + var config = _renamerConfigRepository.GetByName(configName); + if (config == null) + return NotFound("Config not found"); + + return GetRenamerConfig(config); + } + + /// + /// Create a new Config + /// + /// Config + /// + [Authorize("admin")] + [HttpPost("Config")] + public ActionResult PostRenamerConfig([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] RenamerConfig body) + { + if (string.IsNullOrWhiteSpace(body.Name)) return BadRequest("Name is required"); + if (!_renameFileService.RenamersByKey.TryGetValue(body.RenamerID, out var renamer)) + return NotFound("Renamer not found"); + + var existingRenamer = _renamerConfigRepository.GetByName(body.Name); + if (existingRenamer != null) + return Conflict($"Config with name {body.Name} already exists"); + + var config = new Shoko.Server.Models.RenamerConfig + { + Name = body.Name, + Type = renamer.GetType(), + }; + + if (!ApplyRenamerConfigSettings(body, config)) + return ValidationProblem(ModelState); + + if (body.Settings == null || body.Settings.Count == 0) + { + var defaultSettingsObject = renamer.GetType().GetInterfaces().FirstOrDefault(a => a.IsGenericType && a.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetProperties(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(a => a.Name == "DefaultSettings")?.GetMethod?.Invoke(renamer, null); + config.Settings = defaultSettingsObject; + } + + _renamerConfigRepository.Save(config); + + return GetRenamerConfig(config); + } + + /// + /// Update the Config by the given Name + /// + /// Config Name + /// Config + /// [Authorize("admin")] - [HttpPut("{renamerName}")] - public ActionResult PutRenamer([FromRoute] string renamerName, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] ApiRenamer.Input.ModifyRenamerBody body) + [HttpPut("Config/{configName}")] + public ActionResult PutRenamerConfig([FromRoute] string configName, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] RenamerConfig body) { - if (!RenameFileHelper.Renamers.TryGetValue(renamerName, out var value)) - return NotFound("Renamer not found."); + var renamerConfig = _renamerConfigRepository.GetByName(configName); + if (renamerConfig == null) + return NotFound("Config not found"); + + if (!_renameFileService.RenamersByKey.TryGetValue(body.RenamerID, out var renamer)) + return NotFound("Renamer not found"); + + var oldName = renamerConfig.Name; + var temp = renamerConfig.DeepClone(); + + temp.Type = renamer.GetType(); + if (body.Settings == null || body.Settings.Count == 0) + return BadRequest("Settings are required for a put request"); + + if (!ApplyRenamerConfigSettings(body, temp)) + return ValidationProblem(ModelState); + + temp.DeepCloneTo(renamerConfig); + _renamerConfigRepository.Save(renamerConfig); + + // update default renamer in settings. + var settings = _settingsProvider.GetSettings(); + var nameChanged = renamerConfig.Name != oldName; + if (nameChanged && settings.Plugins.Renamer.DefaultRenamer == oldName) + { + settings.Plugins.Renamer.DefaultRenamer = renamerConfig.Name; + _settingsProvider.SaveSettings(settings); + } + + return GetRenamerConfig(renamerConfig); + } + + private bool ApplyRenamerConfigSettings(RenamerConfig body, Shoko.Server.Models.RenamerConfig renamerConfig) + { + if (body.Settings == null || body.Settings.Count == 0) return true; + var result = true; + var settingsType = renamerConfig.Type.GetInterfaces().FirstOrDefault(a => a.IsGenericType && a.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetGenericArguments().FirstOrDefault(); + if (settingsType == null) return true; + + renamerConfig.Settings ??= ActivatorUtilities.CreateInstance(Utils.ServiceContainer, settingsType); + + var properties = settingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var setting in body.Settings) + { + var property = properties.FirstOrDefault(x => x.Name == setting.Name) ?? properties.FirstOrDefault(x => x.GetCustomAttribute()?.Name == setting.Name); + + if (property == null) + continue; - return body.MergeWithExisting(renamerName, value); + try + { + var convertedValue = Convert.ChangeType(setting.Value, property.PropertyType); + property.SetValue(renamerConfig.Settings, convertedValue); + } + catch (Exception ex) + { + _logger.LogError(ex, "Setting {Setting} has an invalid type {ActualPropertyType}, but should be of type {PropertyType}", setting.Name, setting.Value?.GetType().Name, property.PropertyType.Name); + ModelState.AddModelError("Settings[" + setting.Name + "].Value", "Value must be of type " + property.PropertyType.Name); + result = false; + continue; + } + } + + return result; } /// - /// Applies a JSON patch document to modify the settings of the - /// with the given - /// . + /// Applies a JSON patch document to modify the Config with the given Name /// - /// - /// The name of the renamer to be patched. + /// + /// The name of the config to be patched. /// /// - /// A JSON Patch document containing the modifications to be applied to the - /// renamer. + /// A JSON Patch document containing the modifications to be applied to the config. /// /// - /// The modified renamer if the operation is successful, or an error - /// response if the renamer is not found, the patch document is invalid, or + /// The modified config if the operation is successful, or an error + /// response if the config is not found, the patch document is invalid, or /// the modifications fail. /// [Authorize("admin")] - [HttpPatch("{renamerName}")] - public ActionResult PatchRenamer([FromRoute] string renamerName, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] JsonPatchDocument patchDocument) + [HttpPatch("Config/{configName}")] + public ActionResult PatchRenamer([FromRoute] string configName, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] JsonPatchDocument patchDocument) { - if (!RenameFileHelper.Renamers.TryGetValue(renamerName, out var value)) - return NotFound("Renamer not found."); + var renamerConfig = _renamerConfigRepository.GetByName(configName); + if (renamerConfig == null) + return NotFound("Config not found."); // Patch the renamer in the v3 model and merge it back into the // settings. - var modifyRenamer = new ApiRenamer.Input.ModifyRenamerBody(renamerName); + var oldName = renamerConfig.Name; + var modifyRenamer = GetRenamerConfig(renamerConfig); patchDocument.ApplyTo(modifyRenamer, ModelState); - if (!ModelState.IsValid) + + // validate + if (!ModelState.IsValid) return ValidationProblem(ModelState); + var existingRenamer = _renamerConfigRepository.GetByName(modifyRenamer.Name); + if (existingRenamer != null && existingRenamer.ID != renamerConfig.ID) + return Conflict($"Renamer with name {modifyRenamer.Name} already exists."); + + if (!_renameFileService.RenamersByKey.TryGetValue(modifyRenamer.RenamerID, out var renamer)) + return NotFound("Renamer not found"); + + // apply + var temp = renamerConfig.DeepClone(); + temp.Name = modifyRenamer.Name; + temp.Type = renamer.GetType(); + + if (!ApplyRenamerConfigSettings(modifyRenamer, temp)) return ValidationProblem(ModelState); - return modifyRenamer.MergeWithExisting(renamerName, value); - } + temp.DeepCloneTo(renamerConfig); - /// - /// Get the s for all or a single renamer. - /// - /// Renamer ID - /// The scripts. - [HttpGet("Script")] - public ActionResult> GetAllRenamerScripts([FromQuery] string? renamerName = null) - { - if (!string.IsNullOrEmpty(renamerName)) + _renamerConfigRepository.Save(renamerConfig); + + // update default renamer in settings. + var settings = _settingsProvider.GetSettings(); + var nameChanged = modifyRenamer.Name != oldName; + if (nameChanged && settings.Plugins.Renamer.DefaultRenamer == oldName) { - if (!RenameFileHelper.Renamers.ContainsKey(renamerName)) - return new List(); - - return _rsRepository.GetByRenamerType(renamerName) - .Where(s => s.ScriptName != Shoko.Models.Constants.Renamer.TempFileName) - .Select(s => new ApiRenamer.Script(s)) - .OrderBy(s => s.ID) - .ToList(); + settings.Plugins.Renamer.DefaultRenamer = modifyRenamer.Name; + _settingsProvider.SaveSettings(settings); } - return _rsRepository.GetAll() - .Where(s => s.ScriptName != Shoko.Models.Constants.Renamer.TempFileName) - .Select(s => new ApiRenamer.Script(s)) - .OrderBy(s => s.ID) - .ToList(); + return GetRenamerConfig(renamerConfig); } /// - /// Add a new script. + /// Delete the Config by the given Name /// - /// The script to add. + /// Config Name /// [Authorize("admin")] - [HttpPost("Script")] - public ActionResult AddRenamerScript([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] ApiRenamer.Input.NewScriptBody body) + [HttpDelete("Config/{configName}")] + public ActionResult DeleteRenamerConfig([FromRoute] string configName) { - if (string.IsNullOrWhiteSpace(body.Name)) - return ValidationProblem("Script name cannot be empty.", nameof(body.Name)); - - if (string.Equals(body.Name, Shoko.Models.Constants.Renamer.TempFileName)) - return ValidationProblem("Script name cannot be the same as the v1 temp script file.", nameof(body.Name)); + var renamerConfig = _renamerConfigRepository.GetByName(configName); + if (renamerConfig == null) + return NotFound("Config not found"); - var script = _rsRepository.GetByName(body.Name); - if (script is not null) - return ValidationProblem("A script with the given name already exists!", nameof(body.Name)); + var settings = _settingsProvider.GetSettings(); + if (settings.Plugins.Renamer.DefaultRenamer == configName) + return ValidationProblem("Default renamer config cannot be deleted!"); - script = new Shoko.Models.Server.RenameScript - { - ScriptName = body.Name, - RenamerType = body.RenamerName, - IsEnabledOnImport = body.EnabledOnImport ? 1 : 0, - Script = body.Body, - ExtraData = null, - }; - _rsRepository.Save(script); + _renamerConfigRepository.Delete(renamerConfig); - return Created($"/api/v3/Renamer/Script/{script.RenameScriptID}", new ApiRenamer.Script(script)); + return Ok(); } /// - /// Get a by the given . + /// Preview the changes made by a provided Config /// - /// Script ID - /// The script - [HttpGet("Script/{scriptID}")] - public ActionResult GetRenamerScriptByScriptID([FromRoute] int scriptID) + /// A model for the arguments + /// Whether or not to get the destination of the files. If `null`, defaults to `Settings.Plugins.Renamer.MoveOnImport` + /// Whether or not to get the new name of the files. If `null`, defaults to `Settings.Plugins.Renamer.RenameOnImport` + /// Whether or not to allow relocation of files inside the destination. If `null`, defaults to `Settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport` + /// A stream of relocate results. + [Authorize("admin")] + [HttpPost("Preview")] + public ActionResult> BatchPreviewFilesByScriptID( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] BatchRelocateBody args, + bool? move = null, + bool? rename = null, + bool? allowRelocationInsideDestination = null + ) { - var script = scriptID is > 0 ? _rsRepository.GetByID(scriptID) : null; - if (script is null || string.Equals(script.ScriptName, Shoko.Models.Constants.Renamer.TempFileName)) - return NotFound("Renamer.Script not found."); + Shoko.Server.Models.RenamerConfig? config = null; + if (args.Config != null) + { + config = new Shoko.Server.Models.RenamerConfig { Name = args.Config.Name }; + if (!_renameFileService.RenamersByKey.TryGetValue(args.Config.RenamerID, out var renamer)) + return NotFound("Renamer not found"); + + config.Type = renamer.GetType(); - return new ApiRenamer.Script(script); + if (!ApplyRenamerConfigSettings(args.Config, config)) + return ValidationProblem(ModelState); + } + + var settings = _settingsProvider.GetSettings(); + var configName = settings.Plugins.Renamer.DefaultRenamer; + config ??= _renamerConfigRepository.GetByName(configName); + if (config is null) + return NotFound("Default Config not found"); + + if (!_renameFileService.RenamersByType.ContainsKey(config.Type)) + return BadRequest("Renamer for Default Config not found"); + + var results = GetNewLocationsForFiles( + args.FileIDs, + config, + move ?? settings.Plugins.Renamer.MoveOnImport, + rename ?? settings.Plugins.Renamer.RenameOnImport, + allowRelocationInsideDestination ?? settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport + ); + return Ok(results); } /// - /// Replace an existing by the given . + /// Preview the changes made by an existing Config, by the given Config Name /// - /// Script ID - /// The modified script to replace the existing script with. - /// + /// Config Name + /// The file IDs to preview + /// Whether or not to get the destination of the files. If `null`, defaults to `Settings.Plugins.Renamer.MoveOnImport` + /// Whether or not to get the new name of the files. If `null`, defaults to `Settings.Plugins.Renamer.RenameOnImport` + /// Whether or not to allow relocation of files inside the destination. If `null`, defaults to `Settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport` + /// A stream of relocate results. [Authorize("admin")] - [HttpPut("Script/{scriptID}")] - public ActionResult PutRenamerScriptByScriptID([FromRoute] int scriptID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] ApiRenamer.Input.ModifyScriptBody modifyScript) + [HttpPost("Config/{configName}/Preview")] + public ActionResult> BatchRelocateFilesByScriptID( + [FromRoute] string configName, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] IEnumerable fileIDs, + bool? move = null, + bool? rename = null, + bool? allowRelocationInsideDestination = null + ) { - var script = scriptID is > 0 ? _rsRepository.GetByID(scriptID) : null; - if (script is null || string.Equals(script.ScriptName, Shoko.Models.Constants.Renamer.TempFileName)) - return NotFound("Renamer.Script not found."); - - if (string.IsNullOrWhiteSpace(modifyScript.Name)) - return ValidationProblem("Script name cannot be empty.", nameof(modifyScript.Name)); + var config = _renamerConfigRepository.GetByName(configName); + if (config is null) + return NotFound("Config not found"); + + if (!_renameFileService.RenamersByType.ContainsKey(config.Type)) + return BadRequest("Renamer for Config not found"); + + var settings = _settingsProvider.GetSettings(); + var results = GetNewLocationsForFiles( + fileIDs, + config, + move ?? settings.Plugins.Renamer.MoveOnImport, + rename ?? settings.Plugins.Renamer.RenameOnImport, + allowRelocationInsideDestination ?? settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport + ); + return Ok(results); + } - // Guard against rename-collisions. - if (modifyScript.Name != script.ScriptName) + private IEnumerable GetNewLocationsForFiles(IEnumerable fileIDs, Shoko.Server.Models.RenamerConfig config, bool move, bool rename, bool allowRelocationInsideDestination) + { + foreach (var vlID in fileIDs) { - var anotherScript = _rsRepository.GetByName(modifyScript.Name); - if (anotherScript != null) - return ValidationProblem("Another script with the given name already exists!", nameof(modifyScript.Name)); - } + var vl = vlID > 0 ? _vlRepository.GetByID(vlID) : null; + if (vl is null) + { + yield return new RelocationResult + { + FileID = vlID, + IsSuccess = false, + IsPreview = true, + ErrorMessage = $"Unable to find File with ID {vlID}", + }; + continue; + } - return modifyScript.MergeWithExisting(script); + var vlp = vl.FirstResolvedPlace; + if (vlp is null) + { + vlp = vl.FirstValidPlace; + yield return new RelocationResult + { + FileID = vlID, + IsSuccess = false, + IsPreview = true, + ErrorMessage = vlp is not null + ? $"Unable to find any resolvable File.Location for File with ID {vlID}. Found valid but non-resolvable File.Location \"{vlp.FullServerPath}\" with ID {vlp.VideoLocal_Place_ID}." + : $"Unable to find any resolvable File.Location for File with ID {vlID}.", + }; + continue; + } + + var result = _renameFileService.GetNewPath(vlp, config, move, rename, allowRelocationInsideDestination); + + yield return new RelocationResult + { + FileID = vlID, + IsSuccess = result.Success, + IsPreview = true, + IsRelocated = result.Moved || result.Renamed, + ConfigName = config.ID > 0 ? config.Name : null, + AbsolutePath = result.AbsolutePath, + ImportFolderID = result.ImportFolder?.ID, + RelativePath = result.RelativePath, + ErrorMessage = result.ErrorMessage, + FileLocationID = vlp.VideoLocal_Place_ID + }; + } } /// - /// Patch an existing by the given - /// . + /// Directly relocates a file to a new location specified by the user. /// - /// Script ID - /// The json patch document to update the script - /// with. - /// The updated + /// The ID of the file location to be relocated. + /// New location information. + /// A result object containing information about the relocation process. [Authorize("admin")] - [HttpPatch("Script/{scriptID}")] - public ActionResult PatchRenamerScriptByScriptID([FromRoute] int scriptID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] JsonPatchDocument patchDocument) + [HttpPost("Relocate/Location/{locationID}")] + public async Task> DirectlyRelocateFileLocation([FromRoute, Range(1, int.MaxValue)] int locationID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] RelocateBody body) { - var script = scriptID is > 0 ? _rsRepository.GetByID(scriptID) : null; - if (script is null || string.Equals(script.ScriptName, Shoko.Models.Constants.Renamer.TempFileName)) - return NotFound("Renamer.Script not found."); - - // Patch the script in the v3 model and merge it back into the database - // model. - var modifyScript = new ApiRenamer.Input.ModifyScriptBody(script); - patchDocument.ApplyTo(modifyScript, ModelState); - if (!ModelState.IsValid) - return ValidationProblem(ModelState); - - if (string.IsNullOrWhiteSpace(modifyScript.Name)) - return ValidationProblem("Script name cannot be empty.", nameof(modifyScript.Name)); + var fileLocation = _vlpRepository.GetByID(locationID); + if (fileLocation == null) + return NotFound(FileController.FileLocationNotFoundWithLocationID); + + var importFolder = _importFolderRepository.GetByID(body.ImportFolderID); + if (importFolder == null) + return BadRequest($"Unknown import folder with the given id `{body.ImportFolderID}`."); + + // Sanitize relative path and reject paths leading to outside the import folder. + var fullPath = Path.GetFullPath(Path.Combine(importFolder.ImportFolderLocation, body.RelativePath)); + if (!fullPath.StartsWith(importFolder.ImportFolderLocation, StringComparison.OrdinalIgnoreCase)) + return BadRequest("The provided relative path leads outside the import folder."); + var sanitizedRelativePath = Path.GetRelativePath(importFolder.ImportFolderLocation, fullPath); + + // Store the old import folder id and relative path for comparison. + var oldImportFolderId = fileLocation.ImportFolderID; + var oldRelativePath = fileLocation.FilePath; + + // Rename and move the file. + var result = await _vlpService.DirectlyRelocateFile( + fileLocation, + new DirectRelocateRequest + { + ImportFolder = importFolder, + RelativePath = sanitizedRelativePath, + DeleteEmptyDirectories = body.DeleteEmptyDirectories, + AllowRelocationInsideDestination = true, + } + ); + if (!result.Success) + return new RelocationResult + { + FileID = fileLocation.VideoLocalID, + FileLocationID = fileLocation.VideoLocal_Place_ID, + IsSuccess = false, + ErrorMessage = result.ErrorMessage, + }; - // Guard against rename-collisions. - if (modifyScript.Name != script.ScriptName) + // Check if it was actually relocated, or if we landed on the same location as earlier. + var relocated = !string.Equals(oldRelativePath, result.RelativePath, StringComparison.InvariantCultureIgnoreCase) || oldImportFolderId != result.ImportFolder.ID; + return new RelocationResult { - var anotherScript = _rsRepository.GetByName(modifyScript.Name); - if (anotherScript != null) - return ValidationProblem("Another script with the given name already exists!", nameof(modifyScript.Name)); - } - - return modifyScript.MergeWithExisting(script); + FileID = fileLocation.VideoLocalID, + FileLocationID = fileLocation.VideoLocal_Place_ID, + ImportFolderID = result.ImportFolder.ID, + IsSuccess = true, + IsRelocated = relocated, + RelativePath = result.RelativePath, + AbsolutePath = result.AbsolutePath, + }; } /// - /// Delete an existing by the given + /// Relocate a batch of files using a Config of the given name /// - /// Script ID - /// + /// Config Name + /// The files to relocate + /// Whether or not to delete empty directories + /// Whether or not to move the files. If `null`, defaults to `Settings.Plugins.Renamer.MoveOnImport` + /// Whether or not to rename the files. If `null`, defaults to `Settings.Plugins.Renamer.RenameOnImport` + /// Whether or not to allow relocation of files inside the destination. If `null`, defaults to `Settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport` + /// A stream of relocation results. [Authorize("admin")] - [HttpDelete("Script/{scriptID}")] - public ActionResult DeleteRenamerScriptByScriptID([FromRoute] int scriptID) + [HttpPost("Config/{configName}/Relocate")] + public ActionResult> BatchRelocateFilesByConfig([FromRoute] string configName, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] IEnumerable fileIDs, [FromQuery] bool deleteEmptyDirectories = true, + [FromQuery] bool? move = null, [FromQuery] bool? rename = null, [FromQuery] bool? allowRelocationInsideDestination = null) { - var script = scriptID is > 0 ? _rsRepository.GetByID(scriptID) : null; - if (script is null || string.Equals(script.ScriptName, Shoko.Models.Constants.Renamer.TempFileName)) - return NotFound("Renamer.Script not found."); + var config = _renamerConfigRepository.GetByName(configName); + if (config is null) + return NotFound("Config not found."); - _rsRepository.Delete(script); + if (!_renameFileService.RenamersByType.ContainsKey(config.Type)) + return BadRequest("Renamer not found."); - return NoContent(); + var settings = _settingsProvider.GetSettings(); + return new ActionResult>( + InternalBatchRelocateFiles(fileIDs, new AutoRelocateRequest + { + Renamer = config, + DeleteEmptyDirectories = deleteEmptyDirectories, + Move = move ?? settings.Plugins.Renamer.MoveOnImport, + Rename = rename ?? settings.Plugins.Renamer.RenameOnImport, + AllowRelocationInsideDestination = allowRelocationInsideDestination ?? settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport, + }) + ); } /// - /// Execute the script and either preview the changes or commit the changes - /// on a batch of files. + /// Relocate a batch of files using the default Config /// - /// Script ID - /// Contains the files, renamer and script to use for the preview. - /// A stream of relocate results. + /// The files to relocate + /// Whether or not to delete empty directories + /// Whether or not to move the files. If `null`, defaults to `Settings.Plugins.Renamer.MoveOnImport` + /// Whether or not to rename the files. If `null`, defaults to `Settings.Plugins.Renamer.RenameOnImport` + /// Whether or not to allow relocation of files inside the destination. If `null`, defaults to `Settings.Plugins.Renamer.AllowRelocationInsideDestination` + /// A stream of relocation results. [Authorize("admin")] - [HttpPost("Script/{scriptID}/Execute")] - public ActionResult> BatchRelocateFilesByScriptID([FromRoute] int scriptID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] ApiRenamer.Input.BatchAutoRelocateBody body) + [HttpPost("Relocate")] + public ActionResult> BatchRelocateFilesWithDefaultConfig([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] IEnumerable fileIDs, [FromQuery] bool deleteEmptyDirectories = true, [FromQuery] bool? move = null, [FromQuery] bool? rename = null, [FromQuery] bool? allowRelocationInsideDestination = null) { - var script = scriptID is > 0 ? _rsRepository.GetByID(scriptID) : null; - if (script is null || string.Equals(script.ScriptName, Shoko.Models.Constants.Renamer.TempFileName)) - return NotFound("Renamer.Script not found."); + var settings = _settingsProvider.GetSettings(); + var configName = settings.Plugins.Renamer.DefaultRenamer; + if (string.IsNullOrEmpty(configName)) return BadRequest("Default Config not set. Set it in Settings > Plugins > Renamer > DefaultRenamer"); + + var config = _renamerConfigRepository.GetByName(configName); + if (config is null) + return NotFound("Config not found."); - if (!RenameFileHelper.Renamers.ContainsKey(script.RenamerType)) - return BadRequest("Renamer for Renamer.Script not found."); + if (!_renameFileService.RenamersByType.ContainsKey(config.Type)) + return BadRequest("Renamer not found."); - return new ActionResult>( - InternalBatchRelocateFiles(body.FileIDs, new() { DeleteEmptyDirectories = body.DeleteEmptyDirectories, Move = body.Move, Preview = body.Preview, ScriptID = scriptID }) + return new ActionResult>( + InternalBatchRelocateFiles(fileIDs, new AutoRelocateRequest + { + Renamer = config, + DeleteEmptyDirectories = deleteEmptyDirectories, + Move = move ?? settings.Plugins.Renamer.MoveOnImport, + Rename = rename ?? settings.Plugins.Renamer.RenameOnImport, + AllowRelocationInsideDestination = allowRelocationInsideDestination ?? settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport, + }) ); } - [NonAction] - private async IAsyncEnumerable InternalBatchRelocateFiles(IEnumerable fileIDs, AutoRelocateRequest request) + private async IAsyncEnumerable InternalBatchRelocateFiles(IEnumerable fileIDs, AutoRelocateRequest request) { + var defaultConfig = _settingsProvider.GetSettings().Plugins.Renamer.DefaultRenamer; + var configName = request.Renamer?.Name ?? _renamerConfigRepository.GetByName(defaultConfig)?.Name; foreach (var vlID in fileIDs) { - var vl = vlID is > 0 ? _vlRepository.GetByID(vlID) : null; + var vl = vlID > 0 ? _vlRepository.GetByID(vlID) : null; if (vl is null) { - yield return new() + yield return new RelocationResult { FileID = vlID, IsSuccess = false, + ConfigName = configName, ErrorMessage = $"Unable to find File with ID {vlID}", }; continue; @@ -343,9 +725,10 @@ public ActionResult DeleteRenamerScriptByScriptID([FromRoute] int scriptID) if (vlp is null) { vlp = vl.FirstValidPlace; - yield return new() + yield return new RelocationResult { FileID = vlID, + ConfigName = configName, IsSuccess = false, ErrorMessage = vlp is not null ? $"Unable to find any resolvable File.Location for File with ID {vlID}. Found valid but non-resolvable File.Location \"{vlp.FullServerPath}\" with ID {vlp.VideoLocal_Place_ID}." @@ -360,54 +743,52 @@ public ActionResult DeleteRenamerScriptByScriptID([FromRoute] int scriptID) var result = await _vlpService.AutoRelocateFile(vlp, request); if (!result.Success) { - yield return new() + yield return new RelocationResult { FileID = vlp.VideoLocalID, FileLocationID = vlp.VideoLocal_Place_ID, + ConfigName = configName, IsSuccess = false, ErrorMessage = result.ErrorMessage, }; continue; } - if (!request.Preview) + Renamer.RelocationResult? otherResult = null; + foreach (var otherVlp in vl.Places.Where(p => !string.IsNullOrEmpty(p?.FullServerPath) && System.IO.File.Exists(p.FullServerPath))) { - RelocationResult? otherResult = null; - foreach (var otherVlp in vl.Places.Where(p => !string.IsNullOrEmpty(p?.FullServerPath) && System.IO.File.Exists(p.FullServerPath))) - { - if (otherVlp.VideoLocal_Place_ID == vlp.VideoLocal_Place_ID) - continue; - - otherResult = await _vlpService.AutoRelocateFile(otherVlp, request); - if (!otherResult.Success) - break; - } - if (otherResult is not null && !otherResult.Success) - { - yield return new() - { - FileID = vlp.VideoLocalID, - FileLocationID = vlp.VideoLocal_Place_ID, - IsSuccess = false, - ErrorMessage = result.ErrorMessage, - }; + if (otherVlp.VideoLocal_Place_ID == vlp.VideoLocal_Place_ID) continue; - } + + otherResult = await _vlpService.AutoRelocateFile(otherVlp, request); + if (!otherResult.Success) + break; + } + if (otherResult is not null && !otherResult.Success) + { + yield return new RelocationResult + { + FileID = vlp.VideoLocalID, + FileLocationID = vlp.VideoLocal_Place_ID, + ConfigName = configName, + IsSuccess = false, + ErrorMessage = result.ErrorMessage, + }; + continue; } // Check if it was actually relocated, or if we landed on the same location as earlier. - var relocated = !string.Equals(oldRelativePath, result.RelativePath, StringComparison.InvariantCultureIgnoreCase) || oldImportFolderId != result.ImportFolder.ImportFolderID; - yield return new() + var relocated = !string.Equals(oldRelativePath, result.RelativePath, StringComparison.InvariantCultureIgnoreCase) || oldImportFolderId != result.ImportFolder.ID; + yield return new RelocationResult { FileID = vlp.VideoLocalID, FileLocationID = vlp.VideoLocal_Place_ID, - ImportFolderID = result.ImportFolder.ImportFolderID, - ScriptID = request.ScriptID, + ImportFolderID = result.ImportFolder.ID, + ConfigName = configName, IsSuccess = true, IsRelocated = relocated, - IsPreview = request.Preview, RelativePath = result.RelativePath, - AbsolutePath = result.AbsolutePath, + AbsolutePath = result.AbsolutePath }; } } diff --git a/Shoko.Server/API/v3/Controllers/ReverseTreeController.cs b/Shoko.Server/API/v3/Controllers/ReverseTreeController.cs index d44efaf06..5f7d47d69 100644 --- a/Shoko.Server/API/v3/Controllers/ReverseTreeController.cs +++ b/Shoko.Server/API/v3/Controllers/ReverseTreeController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,8 +25,6 @@ public class ReverseTreeController : BaseController { private readonly FilterFactory _filterFactory; - private readonly SeriesFactory _seriesFactory; - /// /// Get the parent for the with the given . /// @@ -40,7 +39,7 @@ public class ReverseTreeController : BaseController /// Always get the top-level /// [HttpGet("Filter/{filterID}/Parent")] - public ActionResult GetParentFromFilter([FromRoute] int filterID, [FromQuery] bool topLevel = false) + public ActionResult GetParentFromFilter([FromRoute, Range(1, int.MaxValue)] int filterID, [FromQuery] bool topLevel = false) { var filter = RepoFactory.FilterPreset.GetByID(filterID); if (filter == null) @@ -76,9 +75,8 @@ public ActionResult GetParentFromFilter([FromRoute] int filterID, [FromQ /// Always get the top-level /// [HttpGet("Group/{groupID}/Parent")] - public ActionResult GetParentFromGroup([FromRoute] int groupID, [FromQuery] bool topLevel = false) + public ActionResult GetParentFromGroup([FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool topLevel = false) { - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) { @@ -101,7 +99,7 @@ public ActionResult GetParentFromGroup([FromRoute] int groupID, [FromQuer return InternalError("No parent Group entry for the given groupID"); } - return new Group(HttpContext, parentGroup); + return new Group(parentGroup, User.JMMUserID); } /// @@ -116,9 +114,8 @@ public ActionResult GetParentFromGroup([FromRoute] int groupID, [FromQuer /// Always get the top-level /// [HttpGet("Series/{seriesID}/Group")] - public ActionResult GetGroupFromSeries([FromRoute] int seriesID, [FromQuery] bool topLevel = false) + public ActionResult GetGroupFromSeries([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool topLevel = false) { - if (seriesID == 0) return BadRequest(SeriesController.SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -136,18 +133,18 @@ public ActionResult GetGroupFromSeries([FromRoute] int seriesID, [FromQue return InternalError("No Group entry for the Series"); } - return new Group(HttpContext, group); + return new Group(group, User.JMMUserID); } /// /// Get the for the with the given . /// /// ID - /// Randomise images shown for the . + /// Randomize images shown for the . /// Include data from selected s. /// [HttpGet("Episode/{episodeID}/Series")] - public ActionResult GetSeriesFromEpisode([FromRoute] int episodeID, [FromQuery] bool randomImages = false, + public ActionResult GetSeriesFromEpisode([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromQuery] bool randomImages = false, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) { var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); @@ -167,7 +164,7 @@ public ActionResult GetSeriesFromEpisode([FromRoute] int episodeID, [Fro return Forbid(EpisodeController.EpisodeForbiddenForUser); } - return _seriesFactory.GetSeries(series, randomImages, includeDataFrom); + return new Series(series, User.JMMUserID, randomImages, includeDataFrom); } /// @@ -182,7 +179,7 @@ public ActionResult GetSeriesFromEpisode([FromRoute] int episodeID, [Fro /// [HttpGet("File/{fileID}/Episode")] public ActionResult> GetEpisodeFromFile( - [FromRoute] int fileID, + [FromRoute, Range(1, int.MaxValue)] int fileID, [FromQuery] bool includeFiles = false, [FromQuery] bool includeMediaInfo = false, [FromQuery] bool includeAbsolutePaths = false, @@ -206,9 +203,8 @@ public ActionResult> GetEpisodeFromFile( .ToList(); } - public ReverseTreeController(ISettingsProvider settingsProvider, FilterFactory filterFactory, SeriesFactory seriesFactory) : base(settingsProvider) + public ReverseTreeController(ISettingsProvider settingsProvider, FilterFactory filterFactory) : base(settingsProvider) { _filterFactory = filterFactory; - _seriesFactory = seriesFactory; } } diff --git a/Shoko.Server/API/v3/Controllers/SeriesController.cs b/Shoko.Server/API/v3/Controllers/SeriesController.cs index 67ab8a64b..c1770e230 100644 --- a/Shoko.Server/API/v3/Controllers/SeriesController.cs +++ b/Shoko.Server/API/v3/Controllers/SeriesController.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.DependencyInjection; using Quartz; using Shoko.Commons.Extensions; using Shoko.Models.Enums; @@ -23,9 +22,11 @@ using Shoko.Server.API.v3.Helpers; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.API.v3.Models.Shoko; +using Shoko.Server.API.v3.Models.TMDB.Input; +using Shoko.Server.Extensions; using Shoko.Server.Models; using Shoko.Server.Providers.AniDB.Titles; -using Shoko.Server.Providers.TvDB; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Repositories.Cached; using Shoko.Server.Scheduling; @@ -37,7 +38,14 @@ using EpisodeType = Shoko.Server.API.v3.Models.Shoko.EpisodeType; using AniDBEpisodeType = Shoko.Models.Enums.EpisodeType; using DataSource = Shoko.Server.API.v3.Models.Common.DataSource; - +using TmdbEpisode = Shoko.Server.API.v3.Models.TMDB.Episode; +using TmdbMovie = Shoko.Server.API.v3.Models.TMDB.Movie; +using TmdbSearch = Shoko.Server.API.v3.Models.TMDB.Search; +using TmdbSeason = Shoko.Server.API.v3.Models.TMDB.Season; +using TmdbShow = Shoko.Server.API.v3.Models.TMDB.Show; + +#pragma warning disable CA1822 +#nullable enable namespace Shoko.Server.API.v3.Controllers; [ApiController] @@ -47,26 +55,50 @@ namespace Shoko.Server.API.v3.Controllers; public class SeriesController : BaseController { private readonly AnimeSeriesService _seriesService; + + private readonly AnimeGroupService _groupService; + private readonly AniDBTitleHelper _titleHelper; - private readonly SeriesFactory _seriesFactory; + private readonly ISchedulerFactory _schedulerFactory; - private readonly JobFactory _jobFactory; - public SeriesController(ISettingsProvider settingsProvider, SeriesFactory seriesFactory, ISchedulerFactory schedulerFactory, JobFactory jobFactory, AniDBTitleHelper titleHelper, CrossRef_File_EpisodeRepository crossRefFileEpisode, AnimeSeriesService seriesService, WatchedStatusService watchedService) : base(settingsProvider) + private readonly TmdbLinkingService _tmdbLinkingService; + + private readonly TmdbMetadataService _tmdbMetadataService; + + + private readonly TmdbSearchService _tmdbSearchService; + + private readonly CrossRef_File_EpisodeRepository _crossRefFileEpisode; + + private readonly WatchedStatusService _watchedService; + + public SeriesController( + ISettingsProvider settingsProvider, + AnimeSeriesService seriesService, + AnimeGroupService groupService, + AniDBTitleHelper titleHelper, + ISchedulerFactory schedulerFactory, + TmdbLinkingService tmdbLinkingService, + TmdbMetadataService tmdbMetadataService, + TmdbSearchService tmdbSearchService, + CrossRef_File_EpisodeRepository crossRefFileEpisode, + WatchedStatusService watchedService + ) : base(settingsProvider) { - _seriesFactory = seriesFactory; - _schedulerFactory = schedulerFactory; - _jobFactory = jobFactory; + _seriesService = seriesService; + _groupService = groupService; _titleHelper = titleHelper; + _schedulerFactory = schedulerFactory; + _tmdbLinkingService = tmdbLinkingService; + _tmdbMetadataService = tmdbMetadataService; + _tmdbSearchService = tmdbSearchService; _crossRefFileEpisode = crossRefFileEpisode; - _seriesService = seriesService; _watchedService = watchedService; } #region Return messages - internal const string SeriesWithZeroID = "SeriesID must be greater than 0"; - internal const string SeriesNotFoundWithSeriesID = "No Series entry for the given seriesID"; internal const string SeriesNotFoundWithAnidbID = "No Series entry for the given anidbID"; @@ -79,11 +111,9 @@ public SeriesController(ISettingsProvider settingsProvider, SeriesFactory series internal const string AnidbForbiddenForUser = "Accessing Series.AniDB is not allowed for the current user"; - internal const string TvdbNotFoundForSeriesID = "No Series.TvDB entry for the given seriesID"; - - internal const string TvdbNotFoundForTvdbID = "No Series.TvDB entry for the given tvdbID"; + internal const string TmdbNotFoundForSeriesID = "No TMDB.Show entry for the given seriesID"; - internal const string TvdbForbiddenForUser = "Accessing Series.TvDB is not allowed for the current user"; + internal const string TmdbForbiddenForUser = "Accessing TMDB.Show is not allowed for the current user"; #endregion @@ -99,13 +129,15 @@ public SeriesController(ISettingsProvider settingsProvider, SeriesFactory series /// Search only for series with a main title that start with the given query. /// [HttpGet] - public ActionResult> GetAllSeries([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] string startsWith = "") + public ActionResult> GetAllSeries( + [FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] string startsWith = "") { startsWith = startsWith.ToLowerInvariant(); var user = User; return RepoFactory.AnimeSeries.GetAll() - .Select(series => (series, seriesName: series.SeriesName.ToLowerInvariant())) + .Select(series => (series, seriesName: series.PreferredTitle.ToLowerInvariant())) .Where(tuple => { var (series, seriesName) = tuple; @@ -117,21 +149,20 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] string startsWith return user.AllowedSeries(series); }) .OrderBy(a => a.seriesName) - .ToListResult(tuple => _seriesFactory.GetSeries(tuple.series), page, pageSize); + .ToListResult(tuple => new Series(tuple.series, user.JMMUserID), page, pageSize); } /// /// Get the series with ID /// /// Shoko ID - /// Randomise images shown for the . + /// Randomize images shown for the . /// Include data from selected s. /// [HttpGet("{seriesID}")] - public ActionResult GetSeries([FromRoute] int seriesID, [FromQuery] bool randomImages = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) + public ActionResult GetSeries([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool randomImages = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -143,7 +174,7 @@ public ActionResult GetSeries([FromRoute] int seriesID, [FromQuery] bool return Forbid(SeriesForbiddenForUser); } - return _seriesFactory.GetSeries(series, randomImages, includeDataFrom); + return new Series(series, User.JMMUserID, randomImages, includeDataFrom); } /// @@ -155,11 +186,8 @@ public ActionResult GetSeries([FromRoute] int seriesID, [FromQuery] bool /// [Authorize("admin")] [HttpDelete("{seriesID}")] - public async Task DeleteSeries([FromRoute] int seriesID, [FromQuery] bool deleteFiles = false, [FromQuery] bool completelyRemove = false) + public async Task DeleteSeries([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool deleteFiles = false, [FromQuery] bool completelyRemove = false) { - if (seriesID == 0) - return BadRequest(SeriesWithZeroID); - var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -177,11 +205,8 @@ public async Task DeleteSeries([FromRoute] int seriesID, [FromQuer /// [Authorize("admin")] [HttpPost("{seriesID}/OverrideTitle")] - public ActionResult OverrideSeriesTitle([FromRoute] int seriesID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] SeriesTitleOverride body) + public ActionResult OverrideSeriesTitle([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.TitleOverrideBody body) { - if (seriesID == 0) - return BadRequest(SeriesWithZeroID); - var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -192,8 +217,10 @@ public ActionResult OverrideSeriesTitle([FromRoute] int seriesID, [FromBody(Empt if (!string.Equals(series.SeriesNameOverride, body.Title)) { series.SeriesNameOverride = body.Title; + series.ResetPreferredTitle(); + series.ResetAnimeTitles(); - RepoFactory.AnimeSeries.Save(series); + RepoFactory.AnimeSeries.Save(series, true); ShokoEventHandler.Instance.OnSeriesUpdated(series, UpdateReason.Updated); } @@ -208,9 +235,8 @@ public ActionResult OverrideSeriesTitle([FromRoute] int seriesID, [FromBody(Empt /// [Authorize("admin")] [HttpGet("{seriesID}/AutoMatchSettings")] - public ActionResult GetAutoMatchSettingsBySeriesID([FromRoute] int seriesID) + public ActionResult GetAutoMatchSettingsBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -230,9 +256,8 @@ public ActionResult OverrideSeriesTitle([FromRoute] int seriesID, [FromBody(Empt /// [Authorize("admin")] [HttpPatch("{seriesID}/AutoMatchSettings")] - public ActionResult PatchAutoMatchSettingsBySeriesID([FromRoute] int seriesID, [FromBody] JsonPatchDocument patchDocument) + public ActionResult PatchAutoMatchSettingsBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromBody] JsonPatchDocument patchDocument) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -259,9 +284,8 @@ public ActionResult OverrideSeriesTitle([FromRoute] int seriesID, [FromBody(Empt /// [Authorize("admin")] [HttpPut("{seriesID}/AutoMatchSettings")] - public ActionResult PutAutoMatchSettingsBySeriesID([FromRoute] int seriesID, [FromBody] Series.AutoMatchSettings autoMatchSettings) + public ActionResult PutAutoMatchSettingsBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromBody] Series.AutoMatchSettings autoMatchSettings) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -278,9 +302,8 @@ public ActionResult OverrideSeriesTitle([FromRoute] int seriesID, [FromBody(Empt /// Shoko ID /// [HttpGet("{seriesID}/Relations")] - public ActionResult> GetShokoRelationsBySeriesID([FromRoute] int seriesID) + public ActionResult> GetShokoRelationsBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -306,16 +329,41 @@ public ActionResult> GetShokoRelationsBySeriesID([FromRoute /// /// The page size. /// The page index. + /// An optional search query to filter series based on their titles. + /// Indicates that fuzzy-matching should be used for the search query. /// [HttpGet("WithoutFiles")] - public ActionResult> GetSeriesWithoutFiles([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1) + public ActionResult> GetSeriesWithoutFiles( + [FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] string? search = null, + [FromQuery] bool fuzzy = true) { var user = User; - return RepoFactory.AnimeSeries.GetAll() - .Where(series => user.AllowedSeries(series) && series.VideoLocals.Count == 0) - .OrderBy(series => series.SeriesName.ToLowerInvariant()) - .ToListResult(series => _seriesFactory.GetSeries(series), page, pageSize); + var query = RepoFactory.AnimeSeries.GetAll() + .Where(series => user.AllowedSeries(series) && series.VideoLocals.Count == 0); + if (!string.IsNullOrWhiteSpace(search)) + { + var languages = SettingsProvider.GetSettings() + .Language.SeriesTitleLanguageOrder + .Select(lang => lang.GetTitleLanguage()) + .Concat(new TitleLanguage[] { TitleLanguage.English, TitleLanguage.Romaji }) + .ToHashSet(); + return query + .Search( + search, + series => series.Titles + .Where(title => languages.Contains(title.Language)) + .Select(title => title.Title) + .Distinct() + .ToList(), + fuzzy + ) + .ToListResult(searchResult => new Series(searchResult.Result, User.JMMUserID), page, pageSize); + } + return query + .OrderBy(series => series.PreferredTitle.ToLowerInvariant()) + .ToListResult(series => new Series(series, User.JMMUserID), page, pageSize); } /// @@ -323,17 +371,42 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) /// /// The page size. /// The page index. + /// An optional search query to filter series based on their titles. + /// Indicates that fuzzy-matching should be used for the search query. /// [HttpGet("WithManuallyLinkedFiles")] - public ActionResult> GetSeriesWithManuallyLinkedFiles([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1) + public ActionResult> GetSeriesWithManuallyLinkedFiles( + [FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] string? search = null, + [FromQuery] bool fuzzy = true) { var user = User; - return RepoFactory.AnimeSeries.GetAll() + var query = RepoFactory.AnimeSeries.GetAll() .Where(series => user.AllowedSeries(series) && _crossRefFileEpisode.GetByAnimeID(series.AniDB_ID).Where(a => a.VideoLocal != null) - .Any(a => a.CrossRefSource == (int)CrossRefSource.User)) - .OrderBy(series => series.SeriesName.ToLowerInvariant()) - .ToListResult(series => _seriesFactory.GetSeries(series), page, pageSize); + .Any(a => a.CrossRefSource == (int)CrossRefSource.User)); + if (!string.IsNullOrWhiteSpace(search)) + { + var languages = SettingsProvider.GetSettings() + .Language.SeriesTitleLanguageOrder + .Select(lang => lang.GetTitleLanguage()) + .Concat(new TitleLanguage[] { TitleLanguage.English, TitleLanguage.Romaji }) + .ToHashSet(); + return query + .Search( + search, + series => series.Titles + .Where(title => languages.Contains(title.Language)) + .Select(title => title.Title) + .Distinct() + .ToList(), + fuzzy + ) + .ToListResult(searchResult => new Series(searchResult.Result, User.JMMUserID), page, pageSize); + } + return query + .OrderBy(series => series.PreferredTitle.ToLowerInvariant()) + .ToListResult(series => new Series(series, User.JMMUserID), page, pageSize); } #endregion @@ -348,8 +421,8 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) /// Search only for anime with a main title that start with the given query. /// [HttpGet("AniDB")] - public ActionResult> GetAllAnime([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] string startsWith = "") + public ActionResult> GetAllAnime([FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] string startsWith = "") { startsWith = startsWith.ToLowerInvariant(); var user = User; @@ -366,7 +439,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] string startsWith return user.AllowedAnime(anime); }) .OrderBy(a => a.animeTitle) - .ToListResult(tuple => _seriesFactory.GetAniDB(tuple.anime), page, pageSize); + .ToListResult(tuple => new Series.AniDB(tuple.anime), page, pageSize); } /// @@ -376,8 +449,8 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] string startsWith /// The page index. /// [HttpGet("AniDB/Relations")] - public ActionResult> GetAnidbRelations([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1) + public ActionResult> GetAnidbRelations([FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1) { return RepoFactory.AniDB_Anime_Relation.GetAll() .OrderBy(a => a.AnimeID) @@ -391,9 +464,8 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) /// Shoko ID /// [HttpGet("{seriesID}/AniDB")] - public ActionResult GetSeriesAnidbBySeriesID([FromRoute] int seriesID) + public ActionResult GetSeriesAnidbBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -411,7 +483,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) return InternalError(AnidbNotFoundForSeriesID); } - return _seriesFactory.GetAniDB(anidb, series); + return new Series.AniDB(anidb, series); } /// @@ -420,9 +492,8 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) /// Shoko ID /// [HttpGet("{seriesID}/AniDB/Similar")] - public ActionResult> GetAnidbSimilarBySeriesID([FromRoute] int seriesID) + public ActionResult> GetAnidbSimilarBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -441,7 +512,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) } return RepoFactory.AniDB_Anime_Similar.GetByAnimeID(anidb.AnimeID) - .Select(similar => _seriesFactory.GetAniDB(similar)) + .Select(similar => new Series.AniDB(similar)) .ToList(); } @@ -451,9 +522,8 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) /// Shoko ID /// [HttpGet("{seriesID}/AniDB/Related")] - public ActionResult> GetAnidbRelatedBySeriesID([FromRoute] int seriesID) + public ActionResult> GetAnidbRelatedBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -472,7 +542,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) } return RepoFactory.AniDB_Anime_Relation.GetByAnimeID(anidb.AnimeID) - .Select(relation => _seriesFactory.GetAniDB(relation)) + .Select(relation => new Series.AniDB(relation)) .ToList(); } @@ -482,9 +552,8 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) /// Shoko ID /// [HttpGet("{seriesID}/AniDB/Relations")] - public ActionResult> GetAnidbRelationsBySeriesID([FromRoute] int seriesID) + public ActionResult> GetAnidbRelationsBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -518,7 +587,7 @@ public ActionResult> GetAnidbRelationsBySeriesID([FromRoute /// Include restricted (H) series. /// Start date to use if recommending for a watch period. Only setting the and not will result in using the watch history from the start date to the present date. /// End date to use if recommending for a watch period. - /// Minumum approval percentage for similar animes. + /// Minimum approval percentage for similar anime. /// [HttpGet("AniDB/RecommendedForYou")] public ActionResult> GetAnimeRecommendedForYou( @@ -541,7 +610,7 @@ public ActionResult> GetAnidbRelationsBySeriesID([FromRoute if (startDate.HasValue) { - if (endDate.Value > DateTime.Now) + if (endDate!.Value > DateTime.Now) ModelState.AddModelError(nameof(endDate), "End date cannot be set into the future."); if (startDate.Value > endDate.Value) @@ -566,17 +635,14 @@ public ActionResult> GetAnidbRelationsBySeriesID([FromRoute } return anime.SimilarAnime - .Where(similar => unwatchedAnimeDict.Keys.Contains(similar.SimilarAnimeID)); + .Where(similar => unwatchedAnimeDict.ContainsKey(similar.SimilarAnimeID)); }) .GroupBy(anime => anime.SimilarAnimeID) .Select(similarTo => { var (anime, series) = unwatchedAnimeDict[similarTo.Key]; var similarToCount = similarTo.Count(); - return new Series.AniDBRecommendedForYou() - { - Anime = _seriesFactory.GetAniDB(anime, series), SimilarTo = similarToCount - }; + return new Series.AniDBRecommendedForYou(new Series.AniDB(anime, series), similarToCount); }) .OrderByDescending(e => e.SimilarTo) .ToListResult(page, pageSize); @@ -593,7 +659,7 @@ public ActionResult> GetAnidbRelationsBySeriesID([FromRoute /// The end date of the period. /// The watched anime for the user. [NonAction] - private List GetWatchedAnimeForPeriod( + private static List GetWatchedAnimeForPeriod( SVR_JMMUser user, bool includeRestricted = false, DateTime? startDate = null, @@ -617,15 +683,16 @@ private List GetWatchedAnimeForPeriod( return userDataQuery .OrderByDescending(userData => userData.LastUpdated) .Select(userData => RepoFactory.VideoLocal.GetByID(userData.VideoLocalID)) - .Where(file => file != null) + .WhereNotNull() .Select(file => file.EpisodeCrossRefs.OrderBy(xref => xref.EpisodeOrder).ThenBy(xref => xref.Percentage) .FirstOrDefault()) - .Where(xref => xref != null) + .WhereNotNull() .Select(xref => RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(xref.EpisodeID)) - .Where(episode => episode != null) + .WhereNotNull() .DistinctBy(episode => episode.AnimeSeriesID) - .Select(episode => episode.AnimeSeries.AniDB_Anime) - .Where(anime => user.AllowedAnime(anime) && (includeRestricted || anime.Restricted != 1)) + .Select(episode => episode.AnimeSeries?.AniDB_Anime) + .WhereNotNull() + .Where(anime => user.AllowedAnime(anime) && (includeRestricted || !anime.IsRestricted)) .ToList(); } @@ -638,11 +705,11 @@ private List GetWatchedAnimeForPeriod( /// Optional. Re-use an existing list of the watched anime. /// The unwatched anime for the user. [NonAction] - private Dictionary GetUnwatchedAnime( + private static Dictionary GetUnwatchedAnime( SVR_JMMUser user, bool showAll, bool includeRestricted = false, - IEnumerable watchedAnime = null) + IEnumerable? watchedAnime = null) { // Get all watched series (reuse if date is not set) var watchedSeriesSet = (watchedAnime ?? GetWatchedAnimeForPeriod(user)) @@ -652,16 +719,16 @@ private List GetWatchedAnimeForPeriod( if (showAll) { return RepoFactory.AniDB_Anime.GetAll() - .Where(anime => user.AllowedAnime(anime) && !watchedSeriesSet.Contains(anime.AnimeID) && (includeRestricted || anime.Restricted != 1)) - .ToDictionary(anime => anime.AnimeID, + .Where(anime => user.AllowedAnime(anime) && !watchedSeriesSet.Contains(anime.AnimeID) && (includeRestricted || !anime.IsRestricted)) + .ToDictionary(anime => anime.AnimeID, anime => (anime, null)); } return RepoFactory.AnimeSeries.GetAll() .Where(series => user.AllowedSeries(series) && !watchedSeriesSet.Contains(series.AniDB_ID)) .Select(series => (anime: series.AniDB_Anime, series)) - .Where(tuple => includeRestricted || tuple.anime.Restricted != 1) - .ToDictionary(tuple => tuple.anime.AnimeID); + .Where(tuple => tuple.anime is not null && (includeRestricted || !tuple.anime.IsRestricted)) + .ToDictionary<(SVR_AniDB_Anime? anime, SVR_AnimeSeries series), int, (SVR_AniDB_Anime, SVR_AnimeSeries?)>(tuple => tuple.anime!.AnimeID, tuple => (tuple.anime!, tuple.series)); } #endregion @@ -685,7 +752,7 @@ private List GetWatchedAnimeForPeriod( return Forbid(AnidbForbiddenForUser); } - return _seriesFactory.GetAniDB(anidb); + return new Series.AniDB(anidb); } /// @@ -708,7 +775,7 @@ private List GetWatchedAnimeForPeriod( } return RepoFactory.AniDB_Anime_Similar.GetByAnimeID(anidbID) - .Select(similar => _seriesFactory.GetAniDB(similar)) + .Select(similar => new Series.AniDB(similar)) .ToList(); } @@ -732,7 +799,7 @@ private List GetWatchedAnimeForPeriod( } return RepoFactory.AniDB_Anime_Relation.GetByAnimeID(anidbID) - .Select(relation => _seriesFactory.GetAniDB(relation)) + .Select(relation => new Series.AniDB(relation)) .ToList(); } @@ -764,12 +831,12 @@ public ActionResult> GetAnidbRelationsByAnidbID([FromRoute] /// Get a Series from the AniDB ID /// /// AniDB ID - /// Randomise images shown for the . + /// Randomize images shown for the . /// Include data from selected s. /// [HttpGet("AniDB/{anidbID}/Series")] public ActionResult GetSeriesByAnidbID([FromRoute] int anidbID, [FromQuery] bool randomImages = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null) { var series = RepoFactory.AnimeSeries.GetByAnimeID(anidbID); if (series == null) @@ -782,14 +849,14 @@ public ActionResult GetSeriesByAnidbID([FromRoute] int anidbID, [FromQue return Forbid(SeriesForbiddenForUser); } - return _seriesFactory.GetSeries(series, randomImages, includeDataFrom); + return new Series(series, User.JMMUserID, randomImages, includeDataFrom); } /// /// Queue a refresh of the AniDB Info for series with AniDB ID /// /// AniDB ID - /// Try to forcefully retrive updated data from AniDB if + /// Try to forcefully retrieve updated data from AniDB if /// we're not banned and if the the last update is outside the no-update /// window (configured in the settings). /// Download relations for the series @@ -811,7 +878,7 @@ public async Task> RefreshAniDBByAniDBID([FromRoute] int anid } // TODO No - return await _seriesFactory.QueueAniDBRefresh(_schedulerFactory, _jobFactory, anidbID, force, downloadRelations, + return await _seriesService.QueueAniDBRefresh(anidbID, force, downloadRelations, createSeriesEntry.Value, immediate, cacheOnly); } @@ -819,7 +886,7 @@ public async Task> RefreshAniDBByAniDBID([FromRoute] int anid /// Queue a refresh of the AniDB Info for series with ID /// /// Shoko ID - /// Try to forcefully retrive updated data from AniDB if + /// Try to forcefully retrieve updated data from AniDB if /// we're not banned and if the the last update is outside the no-update /// window (configured in the settings). /// Download relations for the series @@ -830,11 +897,10 @@ public async Task> RefreshAniDBByAniDBID([FromRoute] int anid /// Only used data from the cache when performing the refresh. takes precedence over this option. /// True if the refresh is done, otherwise false if it was queued. [HttpPost("{seriesID}/AniDB/Refresh")] - public async Task> RefreshAniDBBySeriesID([FromRoute] int seriesID, [FromQuery] bool force = false, + public async Task> RefreshAniDBBySeriesID([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool force = false, [FromQuery] bool downloadRelations = false, [FromQuery] bool? createSeriesEntry = null, [FromQuery] bool immediate = false, [FromQuery] bool cacheOnly = false) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); if (!createSeriesEntry.HasValue) { var settings = SettingsProvider.GetSettings(); @@ -859,7 +925,7 @@ public async Task> RefreshAniDBBySeriesID([FromRoute] int ser } // TODO No - return await _seriesFactory.QueueAniDBRefresh(_schedulerFactory, _jobFactory, anidb.AnimeID, force, downloadRelations, + return await _seriesService.QueueAniDBRefresh(anidb.AnimeID, force, downloadRelations, createSeriesEntry.Value, immediate, cacheOnly); } @@ -869,195 +935,835 @@ public async Task> RefreshAniDBBySeriesID([FromRoute] int ser /// Shoko ID /// True if the refresh is done, otherwise false if it failed. [HttpPost("{seriesID}/AniDB/Refresh/ForceFromXML")] - [Obsolete] - public async Task> RefreshAniDBFromXML([FromRoute] int seriesID) + [Obsolete("Use Refresh with cacheOnly set to true")] + public async Task> RefreshAniDBFromXML([FromRoute, Range(1, int.MaxValue)] int seriesID) => await RefreshAniDBBySeriesID(seriesID, false, false, true, true, true); - #endregion + #endregion + + #region TMDB + + /// + /// Automagically search for one or more matches for the Shoko Series by ID, + /// and return the results. + /// + /// Shoko Series ID. + /// Void. + [HttpGet("{seriesID}/TMDB/Action/AutoSearch")] + public async Task>> PreviewAutoMatchTMDBMoviesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + var anime = series.AniDB_Anime; + if (anime is null) + return InternalError($"Unable to get Series.AniDB with ID {series.AniDB_ID} for Series with ID {series.AnimeSeriesID}!"); + + var results = await _tmdbSearchService.SearchForAutoMatch(anime); + + return results.Select(r => new TmdbSearch.AutoMatchResult(r)).ToList(); + } + + /// + /// Schedule an automagically search for one or more matches for the Shoko + /// Series by ID to take place in the background. + /// + /// Shoko Series ID. + /// Forcefully update the metadata of the matched entities. + /// Void. + [HttpPost("{seriesID}/TMDB/Action/AutoSearch")] + public async Task ScheduleAutoMatchTMDBMoviesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery] bool force = false + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + await _tmdbMetadataService.ScheduleSearchForMatch(series.AniDB_ID, force); + + return NoContent(); + } + + #region Movie + + /// + /// Get all TMDB Movies linked to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Extra details to include. + /// Language to fetch some details in. Omitting will fetch all languages. + /// All TMDB Movies linked to the Shoko Series. + [HttpGet("{seriesID}/TMDB/Movie")] + public ActionResult> GetTMDBMoviesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + return series.TmdbMovieCrossReferences + .DistinctBy(o => o.TmdbMovieID) + .Select(o => o.TmdbMovie) + .WhereNotNull() + .Select(tmdbMovie => new TmdbMovie(tmdbMovie, include?.CombineFlags(), language)) + .ToList(); + } + + /// + /// Add a new TMDB Movie cross-reference to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Body containing the information about the new cross-reference to be made. + /// Void. + [Authorize("admin")] + [HttpPost("{seriesID}/TMDB/Movie")] + public async Task AddLinkToTMDBMoviesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.LinkMovieBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + if (RepoFactory.AniDB_Episode.GetByEpisodeID(body.EpisodeID) is not { } episode) + return ValidationProblem("Episode not found.", nameof(body.EpisodeID)); + + if (episode.AnimeID != series.AniDB_ID) + return ValidationProblem("Episode does not belong to the series.", nameof(body.EpisodeID)); + + await _tmdbLinkingService.AddMovieLinkForEpisode(body.EpisodeID, body.ID, additiveLink: !body.Replace); + + var needRefresh = RepoFactory.TMDB_Movie.GetByTmdbMovieID(body.ID) is null || body.Refresh; + if (needRefresh) + await _tmdbMetadataService.ScheduleUpdateOfMovie(body.ID, forceRefresh: body.Refresh, downloadImages: true); + + return NoContent(); + } + + /// + /// Remove one or all TMDB Movie links from the series. + /// + /// Shoko Series ID. + /// Optional. Body containing information about the link to be removed. + /// + [Authorize("admin")] + [HttpDelete("{seriesID}/TMDB/Movie")] + public async Task RemoveLinkToTMDBMoviesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Series.Input.UnlinkMovieBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + var episodeIDs = series.AllAnimeEpisodes.Select(e => e.AniDB_EpisodeID).ToList(); + if (body.EpisodeID > 0) + { + if (!episodeIDs.Contains(body.EpisodeID)) + return ValidationProblem("The specified episode is not part of the series.", nameof(body.EpisodeID)); + episodeIDs = [body.EpisodeID]; + } + + foreach (var episodeID in episodeIDs) + { + if (body != null && body.ID > 0) + await _tmdbLinkingService.RemoveMovieLinkForEpisode(episodeID, body.ID, body.Purge); + else + await _tmdbLinkingService.RemoveAllMovieLinksForEpisode(episodeID, body?.Purge ?? false); + } + + return NoContent(); + } + + /// + /// Refresh all TMDB Movies linked to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Body containing options for refreshing or downloading metadata. + /// + /// If is , returns an , + /// otherwise returns a . + /// + [Authorize("admin")] + [HttpPost("{seriesID}/TMDB/Movie/Action/Refresh")] + public async Task RefreshTMDBMoviesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbRefreshMovieBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + if (body.Immediate) + { + var settings = SettingsProvider.GetSettings(); + await Task.WhenAll( + RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(series.AniDB_ID) + .Select(xref => body.SkipIfExists && RepoFactory.TMDB_Movie.GetByTmdbMovieID(xref.TmdbMovieID) is not null + ? Task.CompletedTask + : _tmdbMetadataService.UpdateMovie(xref.TmdbMovieID, body.Force, body.DownloadImages, body.DownloadCrewAndCast ?? settings.TMDB.AutoDownloadCrewAndCast, body.DownloadCollections ?? settings.TMDB.AutoDownloadCollections) + ) + ); + return Ok(); + } + + foreach (var xref in RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(series.AniDB_ID)) + await _tmdbMetadataService.ScheduleUpdateOfMovie(xref.TmdbMovieID, body.Force, body.DownloadImages, body.DownloadCrewAndCast, body.DownloadCollections); + + return NoContent(); + } + + /// + /// Download all images for all TMDB Movies linked to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Body containing options for refreshing or downloading metadata. + /// + /// If is , returns an , + /// otherwise returns a . + /// + [Authorize("admin")] + [HttpPost("{seriesID}/TMDB/Movie/Action/DownloadImages")] + public async Task DownloadTMDBMovieImagesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbDownloadImagesBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + if (body.Immediate) + { + await Task.WhenAll( + RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(series.AniDB_ID) + .Select(xref => _tmdbMetadataService.DownloadAllMovieImages(xref.TmdbMovieID, body.Force)) + ); + return Ok(); + } + + foreach (var xref in RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(series.AniDB_ID)) + await _tmdbMetadataService.ScheduleDownloadAllMovieImages(xref.TmdbMovieID, body.Force); + return NoContent(); + } + + /// + /// Get all TMDB Movie cross-references for the Shoko Series by ID. + /// + /// Shoko Series ID. + /// All TMDB Movie cross-references for the Shoko Series. + [HttpGet("{seriesID}/TMDB/Movie/CrossReferences")] + public ActionResult> GetTMDBMovieCrossReferenceBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + return series.TmdbMovieCrossReferences + .Select(xref => new TmdbMovie.CrossReference(xref)) + .OrderBy(xref => xref.TmdbMovieID) + .ToList(); + } + + #endregion + + #region Show + + /// + /// Get all TMDB Shows linked to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Extra details to include. + /// Language to fetch some details in. Omitting will fetch all languages. + /// + [HttpGet("{seriesID}/TMDB/Show")] + public ActionResult> GetTMDBShowsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + return series.TmdbShowCrossReferences + .Select(o => o.TmdbShow) + .WhereNotNull() + .Select(o => new TmdbShow(o, o.PreferredAlternateOrdering, include?.CombineFlags(), language)) + .ToList(); + } + + /// + /// Add a new TMDB Show cross-reference to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Body containing the information about the new cross-reference to be made. + /// Void. + [Authorize("admin")] + [HttpPost("{seriesID}/TMDB/Show")] + public async Task AddLinkToTMDBShowsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.LinkShowBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + await _tmdbLinkingService.AddShowLink(series.AniDB_ID, body.ID, additiveLink: !body.Replace); + + var needRefresh = body.Refresh || RepoFactory.TMDB_Show.GetByTmdbShowID(body.ID) is not { } tmdbShow || tmdbShow.CreatedAt == tmdbShow.LastUpdatedAt; + if (needRefresh) + await _tmdbMetadataService.ScheduleUpdateOfShow(body.ID, forceRefresh: body.Refresh, downloadImages: true); + + // Reset series/group titles/descriptions when a new link is added. + series.ResetAnimeTitles(); + series.ResetPreferredTitle(); + series.ResetPreferredOverview(); + _groupService.UpdateStatsFromTopLevel(series?.AnimeGroup?.TopLevelAnimeGroup, false, false); + + return NoContent(); + } + + /// + /// Remove one or all TMDB Show cross-reference(s) for the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Optional. The unlink body with the details about the TMDB Show to remove. + /// Void. + [Authorize("admin")] + [HttpDelete("{seriesID}/TMDB/Show")] + public async Task RemoveLinkToTMDBShowsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Series.Input.UnlinkCommonBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + if (body != null && body.ID > 0) + await _tmdbLinkingService.RemoveShowLink(series.AniDB_ID, body.ID, body.Purge); + else + await _tmdbLinkingService.RemoveAllShowLinksForAnime(series.AniDB_ID, body?.Purge ?? false); + + // Reset series/group titles/descriptions when a link is removed. + series.ResetAnimeTitles(); + series.ResetPreferredTitle(); + series.ResetPreferredOverview(); + _groupService.UpdateStatsFromTopLevel(series?.AnimeGroup?.TopLevelAnimeGroup, false, false); + + + return NoContent(); + } + + /// + /// Refresh all TMDB Shows linked to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Body containing options for refreshing or downloading metadata. + /// + /// If is , returns an , + /// otherwise returns a . + /// + [Authorize("admin")] + [HttpPost("{seriesID}/TMDB/Show/Action/Refresh")] + public async Task RefreshTMDBShowsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbRefreshShowBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + if (body.Immediate) + { + var settings = SettingsProvider.GetSettings(); + await Task.WhenAll( + RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(series.AniDB_ID) + .Select(xref => _tmdbMetadataService.UpdateShow(xref.TmdbShowID, body.Force, body.DownloadImages, body.DownloadCrewAndCast ?? settings.TMDB.AutoDownloadCrewAndCast, body.DownloadAlternateOrdering ?? settings.TMDB.AutoDownloadAlternateOrdering, body.QuickRefresh) + ) + ); + return Ok(); + } + + foreach (var xref in RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(series.AniDB_ID)) + await _tmdbMetadataService.ScheduleUpdateOfShow(xref.TmdbShowID, body.Force, body.DownloadImages, body.DownloadCrewAndCast, body.DownloadAlternateOrdering); + + return NoContent(); + } + + /// + /// Download all images for all TMDB Shows linked to the Shoko Series by ID. + /// + /// Shoko Series ID. + /// Body containing options for refreshing or downloading metadata. + /// + /// If is , returns an , + /// otherwise returns a . + /// + [Authorize("admin")] + [HttpPost("{seriesID}/TMDB/Show/Action/DownloadImages")] + public async Task DownloadTMDBShowImagesBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbDownloadImagesBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + if (body.Immediate) + { + await Task.WhenAll( + RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(series.AniDB_ID) + .Select(xref => _tmdbMetadataService.DownloadAllShowImages(xref.TmdbShowID, body.Force)) + ); + return Ok(); + } + + foreach (var xref in RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(series.AniDB_ID)) + await _tmdbMetadataService.ScheduleDownloadAllShowImages(xref.TmdbShowID, body.Force); + return NoContent(); + } + + /// + /// Get all TMDB Show cross-references for the Shoko Series by ID. + /// + /// Shoko Series ID. + /// All TMDB Show cross-references for the Shoko Series. + [HttpGet("{seriesID}/TMDB/Show/CrossReferences")] + public ActionResult> GetTMDBShowCrossReferenceBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(SeriesNotFoundWithSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(SeriesForbiddenForUser); + + return series.TmdbShowCrossReferences + .Select(xref => new TmdbShow.CrossReference(xref)) + .OrderBy(xref => xref.TmdbShowID) + .ToList(); + } + + #region Episode Cross-references + + /// + /// Shows all existing episode mappings for a Shoko Series. Optionally + /// allows filtering it to a specific TMDB show. + /// + /// The Shoko Series ID. + /// The TMDB Show ID to filter the episode mappings. If not specified, mappings for any show may be included. + /// The page size. + /// The page index. + /// A list of TMDB episode cross-references as part of the preview result, based on the provided filtering and pagination settings. + [HttpGet("{seriesID}/TMDB/Show/CrossReferences/Episode")] + public ActionResult> GetTMDBEpisodeMappingsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery, Range(0, int.MaxValue)] int? tmdbShowID, + [FromQuery, Range(0, 1000)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(TmdbNotFoundForSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(TmdbForbiddenForUser); + + if (tmdbShowID.HasValue && tmdbShowID.Value > 0) + { + var xrefs = series.TmdbShowCrossReferences; + var xref = xrefs.FirstOrDefault(s => s.TmdbShowID == tmdbShowID.Value); + if (xref == null) + return ValidationProblem("Unable to find an existing cross-reference for the given TMDB Show ID. Please first link the TMDB Show to the Shoko Series.", "tmdbShowID"); + } + + return series.GetTmdbEpisodeCrossReferences(tmdbShowID) + .ToListResult(x => new TmdbEpisode.CrossReference(x), page, pageSize); + } + + /// + /// Modifies the existing episode mappings by resetting, replacing, adding, + /// or removing links between Shoko episodes and TMDB episodes of any TMDB + /// shows linked to the Shoko series. + /// + /// Shoko Series ID. + /// The payload containing the operations to be applied, detailing which mappings to reset, replace, add, or remove. + /// Void. + [Authorize("admin")] + [HttpPost("{seriesID}/TMDB/Show/CrossReferences/Episode")] + public async Task OverrideTMDBEpisodeMappingsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.OverrideTmdbEpisodeMappingBody body + ) + { + if (body == null || (body.Mapping.Count == 0 && !body.UnsetAll)) + return ValidationProblem("Empty body."); + + if (body.Mapping.Count > 0) + { + body.Mapping = body.Mapping.DistinctBy(x => (x.AniDBID, x.TmdbID)).ToList(); + } + + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(TmdbNotFoundForSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(TmdbForbiddenForUser); + + // Validate the mappings. + var xrefs = series.TmdbShowCrossReferences; + var showIDs = xrefs + .Select(xref => xref.TmdbShowID) + .ToHashSet(); + var missingIDs = new HashSet(); + var mapping = new List<(Series.Input.OverrideTmdbEpisodeLinkBody link, SVR_AniDB_Episode aniDBEpisode)>(); + foreach (var link in body.Mapping) + { + var shokoEpisode = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(link.AniDBID); + var anidbEpisode = shokoEpisode?.AniDB_Episode; + if (anidbEpisode is null) + { + ModelState.AddModelError("Mapping", $"Unable to find an AniDB Episode with id '{link.AniDBID}'"); + continue; + } + if (shokoEpisode is null || shokoEpisode.AnimeSeriesID != series.AnimeSeriesID) + { + ModelState.AddModelError("Mapping", $"The AniDB Episode with id '{link.AniDBID}' is not part of the series."); + continue; + } + var tmdbEpisode = link.TmdbID == 0 ? null : RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(link.TmdbID); + if (link.TmdbID != 0) + { + if (tmdbEpisode is null) + { + ModelState.AddModelError("Mapping", $"Unable to find TMDB Episode with the id '{link.TmdbID}' locally."); + continue; + } + if (!showIDs.Contains(tmdbEpisode.TmdbShowID)) + missingIDs.Add(tmdbEpisode.TmdbShowID); + } + + mapping.Add((link, anidbEpisode)); + } + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + // Add any missing links if needed. + foreach (var showId in missingIDs) + await _tmdbLinkingService.AddShowLink(series.AniDB_ID, showId, additiveLink: true); + + // Unset all links if we want to manually replace some or all of them. + if (body.UnsetAll) + _tmdbLinkingService.ResetAllEpisodeLinks(series.AniDB_ID, false); + + // Make sure the mappings are in the correct order before linking. + mapping = mapping + .OrderByDescending(x => x.link.Replace) + .ThenBy(x => x.aniDBEpisode.EpisodeTypeEnum) + .ThenBy(x => x.aniDBEpisode.EpisodeNumber) + .ToList(); + + // Do the actual linking. + foreach (var (link, _) in mapping) + _tmdbLinkingService.SetEpisodeLink(link.AniDBID, link.TmdbID, !link.Replace, link.Index); + + var scheduled = false; + foreach (var showId in missingIDs) + if (RepoFactory.TMDB_Show.GetByTmdbShowID(showId) is not { } tmdbShow || tmdbShow.CreatedAt == tmdbShow.LastUpdatedAt) + { + scheduled = true; + await _tmdbMetadataService.ScheduleUpdateOfShow(showId, downloadImages: true); + } + + if (scheduled) + return Created(); - #region TvDB + return NoContent(); + } /// - /// Get TvDB Info for series with ID + /// Preview the automagically matched Shoko episodes with the specified TMDB + /// show and/or season. If no season is specified, the operation applies to + /// any season of either the selected show or the first show already linked. + /// This endpoint allows for replacing all existing links or adding links to + /// episodes that currently lack any. /// - /// Shoko ID - /// - [HttpGet("{seriesID}/TvDB")] - public ActionResult> GetSeriesTvdb([FromRoute] int seriesID) + /// Shoko Series ID. + /// The specified TMDB Show ID to search for links. This parameter is used to select a specific show. + /// The specified TMDB Season ID to search for links. If not provided, links are searched for any season of the selected or first linked show. + /// Determines whether to retain any and all existing links. + /// The page size. + /// The page index. + /// A preview of the automagically matched episodes. + [Authorize("admin")] + [HttpGet("{seriesID}/TMDB/Show/CrossReferences/Episode/Auto")] + public ActionResult> PreviewAutoTMDBEpisodeMappingsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery] int? tmdbShowID, + [FromQuery] int? tmdbSeasonID, + [FromQuery] bool keepExisting = true, + [FromQuery, Range(0, 1000)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) + return NotFound(TmdbNotFoundForSeriesID); + + if (!User.AllowedSeries(series)) + return Forbid(TmdbForbiddenForUser); + + if (!tmdbShowID.HasValue) { - return NotFound(TvdbNotFoundForSeriesID); + var xrefs = series.TmdbShowCrossReferences; + var xref = xrefs.Count > 0 ? xrefs[0] : null; + if (xref == null) + return ValidationProblem("Unable to find an existing cross-reference for the series to use. Make sure at least one TMDB Show is linked to the Shoko Series.", "tmdbShowID"); + + tmdbShowID = xref.TmdbShowID; } - if (!User.AllowedSeries(series)) + if (tmdbSeasonID.HasValue) { - return Forbid(TvdbForbiddenForUser); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(tmdbSeasonID.Value); + if (season == null) + return ValidationProblem("Unable to find existing TMDB Season with the given season ID.", "tmdbSeasonID"); + + if (season.TmdbShowID != tmdbShowID.Value) + return ValidationProblem("The selected tmdbSeasonID does not belong to the selected tmdbShowID", "tmdbSeasonID"); } - return _seriesFactory.GetTvDBInfo(series); + return _tmdbLinkingService.MatchAnidbToTmdbEpisodes(series.AniDB_ID, tmdbShowID.Value, tmdbSeasonID, keepExisting, saveToDatabase: false) + .ToListResult(x => new TmdbEpisode.CrossReference(x), page, pageSize); } /// - /// Add a TvDB link to a series. + /// Automagically matches Shoko episodes with the specified TMDB show and/or + /// season. If no season is specified, the operation applies to any season + /// of either the selected show or the first show already linked. This + /// endpoint allows for replacing all existing links or adding links to + /// episodes that currently lack any. /// - /// Series ID - /// Body containing the information about the link to be made - /// + /// Shoko Series ID. + /// Optional. Any auto-match options. + /// Void. [Authorize("admin")] - [HttpPost("{seriesID}/TvDB")] - public async Task LinkTvDB([FromRoute] int seriesID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.LinkCommonBody body) + [HttpPost("{seriesID}/TMDB/Show/CrossReferences/Episode/Auto")] + public async Task AutoTMDBEpisodeMappingsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Series.Input.AutoMatchTmdbEpisodesBody? body = null + ) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); + body ??= new(); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) - return NotFound(TvdbNotFoundForSeriesID); + return NotFound(TmdbNotFoundForSeriesID); if (!User.AllowedSeries(series)) - return Forbid(TvdbForbiddenForUser); + return Forbid(TmdbForbiddenForUser); - var tvdbHelper = Utils.ServiceContainer.GetService(); - await tvdbHelper.LinkAniDBTvDB(series.AniDB_ID, body.ID, !body.Replace); + var isMissing = false; + var xrefs = series.TmdbShowCrossReferences; + if (body.TmdbShowID.HasValue) + { + isMissing = !xrefs.Any(s => s.TmdbShowID == body.TmdbShowID.Value); + } + else + { + var xref = xrefs.Count > 0 ? xrefs[0] : null; + if (xref == null) + return ValidationProblem("Unable to find an existing cross-reference for the series to use. Make sure at least one TMDB Show is linked to the Shoko Series.", "tmdbShowID"); - return Ok(); - } + body.TmdbShowID = xref.TmdbShowID; + } - /// - /// Remove one or all TvDB links from a series. - /// - /// Series ID - /// Optional. Body containing information about the link to be removed - /// - [Authorize("admin")] - [HttpDelete("{seriesID}/TvDB")] - public ActionResult UnlinkTvDB([FromRoute] int seriesID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Series.Input.UnlinkCommonBody body) - { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); - var series = RepoFactory.AnimeSeries.GetByID(seriesID); - if (series == null) - return NotFound(TvdbNotFoundForSeriesID); + // Hard bail if the TMDB show isn't locally available. + if (RepoFactory.TMDB_Show.GetByTmdbShowID(body.TmdbShowID.Value) is not { } tmdbShow) + return ValidationProblem("Unable to find the selected TMDB Show locally. Add the TMDB Show locally first.", "tmdbShowID"); - if (!User.AllowedSeries(series)) - return Forbid(TvdbForbiddenForUser); + if (body.TmdbSeasonID.HasValue) + { + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(body.TmdbSeasonID.Value); + if (season == null) + return ValidationProblem("Unable to find existing TMDB Season with the given season ID.", "tmdbSeasonID"); - var tvdbHelper = Utils.ServiceContainer.GetService(); - if (body != null && body.ID > 0) - tvdbHelper.RemoveLinkAniDBTvDB(series.AniDB_ID, body.ID); + if (season.TmdbShowID != body.TmdbShowID.Value) + return ValidationProblem("The selected tmdbSeasonID does not belong to the selected tmdbShowID", "tmdbSeasonID"); + } + + // Add the missing link if needed. + if (isMissing) + await _tmdbLinkingService.AddShowLink(series.AniDB_ID, body.TmdbShowID.Value, additiveLink: true); else - foreach (var xref in RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(series.AniDB_ID)) - tvdbHelper.RemoveLinkAniDBTvDB(series.AniDB_ID, xref.TvDBID); + _tmdbLinkingService.MatchAnidbToTmdbEpisodes(series.AniDB_ID, body.TmdbShowID.Value, body.TmdbSeasonID, body.KeepExisting, saveToDatabase: true); - return Ok(); + if (tmdbShow.CreatedAt == tmdbShow.LastUpdatedAt) + { + await _tmdbMetadataService.ScheduleUpdateOfShow(tmdbShow.Id, downloadImages: true); + return Created(); + } + + return NoContent(); } /// - /// Queue a refresh of the all the linked to the - /// using the . + /// Reset all existing episode mappings for the shoko series. /// - /// Shoko ID - /// Forcefully retrive updated data from TvDB - /// - [HttpPost("{seriesID}/TvDB/Refresh")] - public async Task RefreshSeriesTvdbBySeriesID([FromRoute] int seriesID, [FromQuery] bool force = false) + /// Shoko Series ID. + /// Void. + [Authorize("admin")] + [HttpDelete("{seriesID}/TMDB/Show/CrossReferences/Episode")] + public ActionResult RemoveTMDBEpisodeMappingsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID + ) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) - { - return NotFound(TvdbNotFoundForSeriesID); - } + return NotFound(TmdbNotFoundForSeriesID); if (!User.AllowedSeries(series)) - { - return Forbid(TvdbForbiddenForUser); - } + return Forbid(TmdbForbiddenForUser); - var tvSeriesList = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(series.AniDB_ID); - // TODO No - foreach (var crossRef in tvSeriesList) - await _seriesFactory.QueueTvDBRefresh(crossRef.TvDBID, force); + _tmdbLinkingService.ResetAllEpisodeLinks(series.AniDB_ID, true); - return Ok(); + return NoContent(); } /// - /// Get TvDB Info from the TvDB ID + /// Shows all existing episode cross-references for a Shoko Series grouped + /// by their corresponding cross-reference groups. Optionally allows + /// filtering it to a specific TMDB show. /// - /// TvDB ID - /// - [HttpGet("TvDB/{tvdbID}")] - public ActionResult GetSeriesTvdbByTvdbID([FromRoute] int tvdbID) + /// The Shoko Series ID. + /// The TMDB Show ID to filter the episode mappings. If not specified, mappings for any show may be included. + /// The page size. + /// The page index. + /// The list of grouped episode cross-references. + [HttpGet("{seriesID}/TMDB/Show/CrossReferences/EpisodeGroups")] + public ActionResult>> GetGroupedTMDBEpisodeMappingsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery] int? tmdbShowID, + [FromQuery, Range(0, 1000)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) { - var tvdb = RepoFactory.TvDB_Series.GetByTvDBID(tvdbID); - if (tvdb == null) - { - return NotFound(TvdbNotFoundForTvdbID); - } - - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(tvdbID).FirstOrDefault(); - if (xref == null) - { - return NotFound(TvdbNotFoundForTvdbID); - } - - var series = RepoFactory.AnimeSeries.GetByAnimeID(xref.AniDBID); + var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) - { - return NotFound(TvdbNotFoundForTvdbID); - } + return NotFound(TmdbNotFoundForSeriesID); if (!User.AllowedSeries(series)) + return Forbid(TmdbForbiddenForUser); + + if (tmdbShowID.HasValue) { - return Forbid(TvdbForbiddenForUser); + var xrefs = series.TmdbShowCrossReferences; + var xref = xrefs.FirstOrDefault(s => s.TmdbShowID == tmdbShowID.Value); + if (xref == null) + return ValidationProblem("Unable to find an existing cross-reference for the given TMDB Show ID. Please first link the TMDB Show to the Shoko Series.", "tmdbShowID"); } - return _seriesFactory.GetTvDB(tvdb, series); + return series.GetTmdbEpisodeCrossReferences(tmdbShowID) + .GroupByCrossReferenceType() + .ToListResult(list => list.Select((xref, index) => new TmdbEpisode.CrossReference(xref, index)).ToList(), page, pageSize); } - /// - /// Directly queue a refresh of the the data using - /// the . - /// - /// TvDB ID - /// Forcefully retrive updated data from TvDB - /// Try to immediately refresh the data. - /// - [HttpPost("TvDB/{tvdbID}/Refresh")] - public async Task> RefreshSeriesTvdbByTvdbId([FromRoute] int tvdbID, [FromQuery] bool force = false, [FromQuery] bool immediate = false) - { - // TODO No - return await _seriesFactory.QueueTvDBRefresh(tvdbID,force, immediate); - } + #endregion + + #endregion + + #region Season /// - /// Get a Series from the TvDB ID + /// Get all TMDB Season indirectly linked to the Shoko Series by ID. /// - /// TvDB ID - /// - [HttpGet("TvDB/{tvdbID}/Series")] - public ActionResult> GetSeriesByTvdbID([FromRoute] int tvdbID) + /// Shoko Series ID. + /// Extra details to include. + /// Language to fetch some details in. + /// All TMDB Seasons indirectly linked to the Shoko Series. + [HttpGet("{seriesID}/TMDB/Season")] + public ActionResult> GetTMDBSeasonsBySeriesID( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) { - var tvdb = RepoFactory.TvDB_Series.GetByTvDBID(tvdbID); - if (tvdb == null) - { - return NotFound(TvdbNotFoundForTvdbID); - } - - var seriesList = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(tvdbID) - .Select(xref => RepoFactory.AnimeSeries.GetByAnimeID(xref.AniDBID)) - .Where(series => series != null) - .ToList(); - - var user = User; - if (seriesList.Any(series => !user.AllowedSeries(series))) - { - return Forbid(SeriesForbiddenForUser); - } + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + return NotFound(TmdbNotFoundForSeriesID); - return seriesList - .Select(series => _seriesFactory.GetSeries(series)) + if (!User.AllowedSeries(series)) + return Forbid(TmdbForbiddenForUser); + + return series.TmdbEpisodeCrossReferences + .Select(o => o.TmdbEpisode) + .WhereNotNull() + .DistinctBy(o => o.TmdbSeasonID) + .Select(o => o.TmdbSeason) + .WhereNotNull() + .OrderBy(season => season.TmdbShowID) + .ThenBy(season => season.SeasonNumber) + .Select(o => new TmdbSeason(o, include?.CombineFlags(), language)) .ToList(); } @@ -1065,6 +1771,8 @@ public ActionResult> GetSeriesByTvdbID([FromRoute] int tvdbID) #endregion + #endregion + #region Episode /// @@ -1077,8 +1785,10 @@ public ActionResult> GetSeriesByTvdbID([FromRoute] int tvdbID) /// The page size. Set to 0 to disable pagination. /// The page index. /// Include missing episodes in the list. + /// Include unaired episodes in the list. /// Include hidden episodes in the list. /// Include watched episodes in the list. + /// Include manually linked episodes in the list. /// Include data from selected s. /// Filter episodes by the specified s. /// Include files with the episodes. @@ -1090,23 +1800,24 @@ public ActionResult> GetSeriesByTvdbID([FromRoute] int tvdbID) /// A list of episodes based on the specified filters. [HttpGet("{seriesID}/Episode")] public ActionResult> GetEpisodes( - [FromRoute] int seriesID, + [FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery, Range(0, 1000)] int pageSize = 20, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] IncludeOnlyFilter includeMissing = IncludeOnlyFilter.False, + [FromQuery] IncludeOnlyFilter includeUnaired = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeHidden = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeWatched = IncludeOnlyFilter.True, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet type = null, + [FromQuery] IncludeOnlyFilter includeManuallyLinked = IncludeOnlyFilter.True, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? type = null, [FromQuery] bool includeFiles = false, [FromQuery] bool includeMediaInfo = false, [FromQuery] bool includeAbsolutePaths = false, [FromQuery] bool includeXRefs = false, - [FromQuery] string search = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true ) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -1114,7 +1825,7 @@ public ActionResult> GetEpisodes( if (!User.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - return GetEpisodesInternal(series, includeMissing, includeHidden, includeWatched, type, search, fuzzy) + return GetEpisodesInternal(series, includeMissing, includeUnaired, includeHidden, includeWatched, includeManuallyLinked, type, search, fuzzy) .ToListResult(a => new Episode(HttpContext, a, includeDataFrom, includeFiles, includeMediaInfo, includeAbsolutePaths, includeXRefs), page, pageSize); } @@ -1124,6 +1835,7 @@ public ActionResult> GetEpisodes( /// Series ID /// The new watched state. /// Include missing episodes in the list. + /// Include unaired episodes in the list. /// Include hidden episodes in the list. /// Include watched episodes in the list. /// Filter episodes by the specified s. @@ -1132,16 +1844,16 @@ public ActionResult> GetEpisodes( /// [HttpPost("{seriesID}/Episode/Watched")] public async Task MarkSeriesWatched( - [FromRoute] int seriesID, + [FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool value = true, [FromQuery] IncludeOnlyFilter includeMissing = IncludeOnlyFilter.False, + [FromQuery] IncludeOnlyFilter includeUnaired = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeHidden = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeWatched = IncludeOnlyFilter.True, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet type = null, - [FromQuery] string search = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? type = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -1152,7 +1864,7 @@ public async Task MarkSeriesWatched( var userId = User.JMMUserID; var now = DateTime.Now; // this has a parallel query to evaluate filters and data in parallel, but that makes awaiting the SetWatchedStatus calls more difficult, so we ToList() it - await Task.WhenAll(GetEpisodesInternal(series, includeMissing, includeHidden, includeWatched, type, search, fuzzy).ToList() + await Task.WhenAll(GetEpisodesInternal(series, includeMissing, includeUnaired, includeHidden, includeWatched, IncludeOnlyFilter.True, type, search, fuzzy).ToList() .Select(episode => _watchedService.SetWatchedStatus(episode, value, true, now, false, userId, true))); _seriesService.UpdateStats(series, true, false); @@ -1164,10 +1876,12 @@ await Task.WhenAll(GetEpisodesInternal(series, includeMissing, includeHidden, in public ParallelQuery GetEpisodesInternal( SVR_AnimeSeries series, IncludeOnlyFilter includeMissing, + IncludeOnlyFilter includeUnaired, IncludeOnlyFilter includeHidden, IncludeOnlyFilter includeWatched, - HashSet type, - string search, + IncludeOnlyFilter includeManuallyLinked, + HashSet? type, + string? search, bool fuzzy) { var user = User; @@ -1184,7 +1898,7 @@ public ParallelQuery GetEpisodesInternal( if (anidb == null) return false; - // Filter by hidden state, if spesified + // Filter by hidden state, if specified if (includeHidden != IncludeOnlyFilter.True) { // If we should hide hidden episodes and the episode is hidden, then hide it. @@ -1208,8 +1922,28 @@ public ParallelQuery GetEpisodesInternal( // If we should hide missing episodes and the episode has no files, then hide it. // Or if we should only show missing episodes and the episode has files, the hide it. var shouldHideMissing = includeMissing == IncludeOnlyFilter.False; - var noFiles = shoko.VideoLocals.Count == 0; - if (shouldHideMissing == noFiles) + var isMissing = shoko.VideoLocals.Count == 0 && anidb.HasAired; + if (shouldHideMissing == isMissing) + return false; + } + if (includeUnaired != IncludeOnlyFilter.True) + { + // If we should hide unaired episodes and the episode has no files, then hide it. + // Or if we should only show unaired episodes and the episode has files, the hide it. + var shouldHideUnaired = includeUnaired == IncludeOnlyFilter.False; + var isUnaired = shoko.VideoLocals.Count == 0 && !anidb.HasAired; + if (shouldHideUnaired == isUnaired) + return false; + } + + // Filter by manually linked, if specified + if (includeManuallyLinked != IncludeOnlyFilter.True) + { + // If we should hide manually linked episodes and the episode is manually linked, then hide it. + // Or if we should only show manually linked episodes and the episode is not manually linked, then hide it. + var shouldHideManuallyLinked = includeManuallyLinked == IncludeOnlyFilter.False; + var isManuallyLinked = shoko.FileCrossReferences.Any(xref => xref.CrossRefSource != (int)CrossRefSource.AniDB); + if (shouldHideManuallyLinked == isManuallyLinked) return false; } @@ -1229,14 +1963,14 @@ public ParallelQuery GetEpisodesInternal( if (hasSearch) { var languages = SettingsProvider.GetSettings() - .LanguagePreference + .Language.EpisodeTitleLanguageOrder .Select(lang => lang.GetTitleLanguage()) .Concat(new TitleLanguage[] { TitleLanguage.English, TitleLanguage.Romaji }) .ToHashSet(); return episodes .Search( search, - ep => RepoFactory.AniDB_Episode_Title.GetByEpisodeID(ep.AniDB.EpisodeID) + ep => RepoFactory.AniDB_Episode_Title.GetByEpisodeID(ep.AniDB!.EpisodeID) .Where(title => title != null && languages.Contains(title.Language)) .Select(title => title.Title) .Append(ep.Shoko.PreferredTitle) @@ -1249,8 +1983,8 @@ public ParallelQuery GetEpisodesInternal( // Order the episodes since we're not using the search ordering. return episodes - .OrderBy(episode => episode.AniDB.EpisodeType) - .ThenBy(episode => episode.AniDB.EpisodeNumber) + .OrderBy(episode => episode.AniDB!.EpisodeType) + .ThenBy(episode => episode.AniDB!.EpisodeNumber) .Select(a => a.Shoko); } @@ -1264,6 +1998,7 @@ public ParallelQuery GetEpisodesInternal( /// The page size. Set to 0 to disable pagination. /// The page index. /// Include missing episodes in the list. + /// Include unaired episodes in the list. /// Include hidden episodes in the list. /// Include watched episodes in the list. /// Filter episodes by the specified s. @@ -1276,10 +2011,11 @@ public ParallelQuery GetEpisodesInternal( [FromQuery, Range(0, 1000)] int pageSize = 20, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] IncludeOnlyFilter includeMissing = IncludeOnlyFilter.False, + [FromQuery] IncludeOnlyFilter includeUnaired = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeHidden = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeWatched = IncludeOnlyFilter.True, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet type = null, - [FromQuery] string search = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? type = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true) { var anidbSeries = RepoFactory.AniDB_Anime.GetByAnimeID(anidbID); @@ -1303,7 +2039,7 @@ public ParallelQuery GetEpisodesInternal( if (anidb == null) return false; - // Filter by hidden state, if spesified + // Filter by hidden state, if specified if (includeHidden != IncludeOnlyFilter.True) { // If we should hide hidden episodes and the episode is hidden, then hide it. @@ -1328,9 +2064,17 @@ public ParallelQuery GetEpisodesInternal( // If we should hide missing episodes and the episode has no files, then hide it. // Or if we should only show missing episodes and the episode has files, the hide it. var shouldHideMissing = includeMissing == IncludeOnlyFilter.False; - var files = shoko?.VideoLocals.Count ?? 0; - var noFiles = files == 0; - if (shouldHideMissing == noFiles) + var isMissing = shoko is not null && shoko.VideoLocals.Count == 0 && anidb.HasAired; + if (shouldHideMissing == isMissing) + return false; + } + if (includeUnaired != IncludeOnlyFilter.True) + { + // If we should hide unaired episodes and the episode has no files, then hide it. + // Or if we should only show unaired episodes and the episode has files, the hide it. + var shouldHideUnaired = includeUnaired == IncludeOnlyFilter.False; + var isUnaired = shoko is not null && shoko.VideoLocals.Count == 0 && !anidb.HasAired; + if (shouldHideUnaired == isUnaired) return false; } @@ -1350,7 +2094,7 @@ public ParallelQuery GetEpisodesInternal( if (hasSearch) { var languages = SettingsProvider.GetSettings() - .LanguagePreference + .Language.SeriesTitleLanguageOrder .Select(lang => lang.GetTitleLanguage()) .Concat(new TitleLanguage[] { TitleLanguage.English, TitleLanguage.Romaji }) .ToHashSet(); @@ -1384,8 +2128,9 @@ public ParallelQuery GetEpisodesInternal( /// Series ID /// Only show the next unwatched episode. /// Include specials in the search. + /// Include other type episodes in the search. /// Include missing episodes in the list. - /// Include hidden episodes in the list. + /// Include unaired episodes in the list. /// Include already watched episodes in the /// search if we determine the user is "re-watching" the series. /// Include files with the episodes. @@ -1395,37 +2140,37 @@ public ParallelQuery GetEpisodesInternal( /// Include data from selected s. /// [HttpGet("{seriesID}/NextUpEpisode")] - public ActionResult GetNextUnwatchedEpisode([FromRoute] int seriesID, + public ActionResult GetNextUnwatchedEpisode([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool onlyUnwatched = true, [FromQuery] bool includeSpecials = true, + [FromQuery] bool includeOthers = false, [FromQuery] bool includeMissing = true, - [FromQuery] bool includeHidden = false, + [FromQuery] bool includeUnaired = false, [FromQuery] bool includeRewatching = false, [FromQuery] bool includeFiles = false, [FromQuery] bool includeMediaInfo = false, [FromQuery] bool includeAbsolutePaths = false, [FromQuery] bool includeXRefs = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null) { - var user = User; - if (seriesID == 0) return BadRequest(SeriesWithZeroID); - var series = RepoFactory.AnimeSeries.GetByID(seriesID); - if (series == null) + if (RepoFactory.AnimeSeries.GetByID(seriesID) is not { } series) return NotFound(SeriesNotFoundWithSeriesID); + var user = User; if (!user.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - var episode = _seriesService.GetNextEpisode(series, user.JMMUserID, new() + var episode = _seriesService.GetNextUpEpisode(series, user.JMMUserID, new() { IncludeCurrentlyWatching = !onlyUnwatched, - IncludeHidden = includeHidden, IncludeMissing = includeMissing, + IncludeUnaired = includeUnaired, IncludeRewatching = includeRewatching, IncludeSpecials = includeSpecials, + IncludeOthers = includeOthers, }); - if (episode == null) - return null; + if (episode is null) + return NoContent(); return new Episode(HttpContext, episode, includeDataFrom, includeFiles, includeMediaInfo, includeAbsolutePaths, includeXRefs); } @@ -1441,9 +2186,8 @@ public ActionResult GetNextUnwatchedEpisode([FromRoute] int seriesID, /// [Authorize("admin")] [HttpPost("{seriesID}/File/Rescan")] - public async Task RescanSeriesFiles([FromRoute] int seriesID, [FromQuery] bool priority = false) + public async Task RescanSeriesFiles([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool priority = false) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -1480,9 +2224,8 @@ await scheduler.StartJob(c => /// [Authorize("admin")] [HttpPost("{seriesID}/File/Rehash")] - public async Task RehashSeriesFiles([FromRoute] int seriesID) + public async Task RehashSeriesFiles([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -1513,15 +2256,14 @@ await scheduler.StartJobNow(c => #region Vote /// - /// Add a permanent or temprary user-submitted rating for the series. + /// Add a permanent or temporary user-submitted rating for the series. /// /// /// /// [HttpPost("{seriesID}/Vote")] - public async Task PostSeriesUserVote([FromRoute] int seriesID, [FromBody] Vote vote) + public async Task PostSeriesUserVote([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromBody] Vote vote) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -1532,7 +2274,13 @@ public async Task PostSeriesUserVote([FromRoute] int seriesID, [Fr if (vote.Value > vote.MaxValue) return ValidationProblem($"Value must be less than or equal to the set max value ({vote.MaxValue}).", nameof(vote.Value)); - await SeriesFactory.AddSeriesVote(_schedulerFactory, series, User.JMMUserID, vote); + var voteType = (vote.Type?.ToLowerInvariant() ?? "") switch + { + "temporary" => AniDBVoteType.AnimeTemp, + "permanent" => AniDBVoteType.Anime, + _ => series.AniDB_Anime?.GetFinishedAiring() ?? false ? AniDBVoteType.Anime : AniDBVoteType.AnimeTemp, + }; + await _seriesService.AddSeriesVote(series, voteType, vote.GetRating()); return NoContent(); } @@ -1543,18 +2291,16 @@ public async Task PostSeriesUserVote([FromRoute] int seriesID, [Fr #region All images - private static HashSet AllowedImageTypes = - new() { Image.ImageType.Poster, Image.ImageType.Banner, Image.ImageType.Fanart }; + private static readonly HashSet _allowedImageTypes = [Image.ImageType.Poster, Image.ImageType.Banner, Image.ImageType.Backdrop, Image.ImageType.Logo]; - private readonly CrossRef_File_EpisodeRepository _crossRefFileEpisode; - private readonly WatchedStatusService _watchedService; + private const string InvalidImageTypeForSeries = "Invalid image type for series."; private const string InvalidIDForSource = "Invalid image id for selected source."; - private const string InvalidImageTypeForSeries = "Invalid image type for series images."; - private const string InvalidImageIsDisabled = "Image is disabled."; + private const string NoDefaultImageForType = "No default image for type."; + /// /// Get all images for series with ID, optionally with Disabled images, as well. /// @@ -1562,9 +2308,8 @@ public async Task PostSeriesUserVote([FromRoute] int seriesID, [Fr /// /// [HttpGet("{seriesID}/Images")] - public ActionResult GetSeriesImages([FromRoute] int seriesID, [FromQuery] bool includeDisabled) + public ActionResult GetSeriesImages([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool includeDisabled) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -1576,7 +2321,7 @@ public ActionResult GetSeriesImages([FromRoute] int seriesID, [FromQuery return Forbid(SeriesForbiddenForUser); } - return SeriesFactory.GetArt(series.AniDB_ID, includeDisabled); + return series.GetImages().ToDto(includeDisabled: includeDisabled); } #endregion @@ -1590,12 +2335,11 @@ public ActionResult GetSeriesImages([FromRoute] int seriesID, [FromQuery /// Poster, Banner, Fanart /// [HttpGet("{seriesID}/Images/{imageType}")] - public ActionResult GetSeriesDefaultImageForType([FromRoute] int seriesID, + public ActionResult GetSeriesDefaultImageForType([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromRoute] Image.ImageType imageType) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); - if (!AllowedImageTypes.Contains(imageType)) - return NotFound(); + if (!_allowedImageTypes.Contains(imageType)) + return BadRequest(InvalidImageTypeForSeries); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) @@ -1604,21 +2348,25 @@ public ActionResult GetSeriesDefaultImageForType([FromRoute] int seriesID if (!User.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - var imageSizeType = Image.GetImageSizeTypeFromType(imageType); - var defaultBanner = SeriesFactory.GetDefaultImage(series.AniDB_ID, imageSizeType); - if (defaultBanner != null) - { - return defaultBanner; - } + var imageEntityType = imageType.ToServer(); + var preferredImage = series.GetPreferredImageForType(imageEntityType); + if (preferredImage != null) + return new Image(preferredImage); - var images = SeriesFactory.GetArt(series.AniDB_ID); - return imageSizeType switch + var images = series.GetImages(imageEntityType).ToDto(); + var image = imageEntityType switch { - ImageSizeType.Poster => images.Posters.FirstOrDefault(), - ImageSizeType.WideBanner => images.Banners.FirstOrDefault(), - ImageSizeType.Fanart => images.Fanarts.FirstOrDefault(), + ImageEntityType.Poster => images.Posters.FirstOrDefault(), + ImageEntityType.Banner => images.Banners.FirstOrDefault(), + ImageEntityType.Backdrop => images.Backdrops.FirstOrDefault(), + ImageEntityType.Logo => images.Logos.FirstOrDefault(), _ => null }; + + if (image is null) + return NotFound(NoDefaultImageForType); + + return image; } @@ -1630,12 +2378,11 @@ public ActionResult GetSeriesDefaultImageForType([FromRoute] int seriesID /// The body containing the source and id used to set. /// [HttpPut("{seriesID}/Images/{imageType}")] - public ActionResult SetSeriesDefaultImageForType([FromRoute] int seriesID, + public ActionResult SetSeriesDefaultImageForType([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromRoute] Image.ImageType imageType, [FromBody] Image.Input.DefaultImageBody body) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); - if (!AllowedImageTypes.Contains(imageType)) - return NotFound(); + if (!_allowedImageTypes.Contains(imageType)) + return BadRequest(InvalidImageTypeForSeries); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) @@ -1644,119 +2391,26 @@ public ActionResult SetSeriesDefaultImageForType([FromRoute] int seriesID if (!User.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - var imageEntityType = Image.GetImageTypeFromSourceAndType(body.Source, imageType); - if (!imageEntityType.HasValue) - return ValidationProblem("Invalid body source"); - - // All dynamic ids are stringified ints, so extract the image id from the body. - if (!int.TryParse(body.ID, out var imageID)) - return ValidationProblem("Invalid body id. Id must be a stringified int."); - // Check if the id is valid for the given type and source. - - switch (imageEntityType.Value) - { - // Posters - case ImageEntityType.AniDB_Cover: - if (imageID != series.AniDB_ID) - { - return ValidationProblem(InvalidIDForSource); - } - - break; - case ImageEntityType.TvDB_Cover: - { - var tvdbPoster = RepoFactory.TvDB_ImagePoster.GetByID(imageID); - if (tvdbPoster == null) - { - return ValidationProblem(InvalidIDForSource); - } - - if (tvdbPoster.Enabled != 1) - { - return ValidationProblem(InvalidImageIsDisabled); - } - - break; - } - case ImageEntityType.MovieDB_Poster: - var tmdbPoster = RepoFactory.MovieDB_Poster.GetByID(imageID); - if (tmdbPoster == null) - { - return ValidationProblem(InvalidIDForSource); - } - - if (tmdbPoster.Enabled != 1) - { - return ValidationProblem(InvalidImageIsDisabled); - } - - break; - - // Banners - case ImageEntityType.TvDB_Banner: - var tvdbBanner = RepoFactory.TvDB_ImageWideBanner.GetByID(imageID); - if (tvdbBanner == null) - { - return ValidationProblem(InvalidIDForSource); - } - - if (tvdbBanner.Enabled != 1) - { - return ValidationProblem(InvalidImageIsDisabled); - } - - break; - - // Fanart - case ImageEntityType.TvDB_FanArt: - var tvdbFanart = RepoFactory.TvDB_ImageFanart.GetByID(imageID); - if (tvdbFanart == null) - { - return ValidationProblem(InvalidIDForSource); - } - - if (tvdbFanart.Enabled != 1) - { - return ValidationProblem(InvalidImageIsDisabled); - } - - break; - case ImageEntityType.MovieDB_FanArt: - var tmdbFanart = RepoFactory.MovieDB_Fanart.GetByID(imageID); - if (tmdbFanart == null) - { - return ValidationProblem(InvalidIDForSource); - } - - if (tmdbFanart.Enabled != 1) - { - return ValidationProblem(InvalidImageIsDisabled); - } - - break; - - // Not allowed. - default: - return ValidationProblem("Invalid source and/or type."); - } - - var imageSizeType = Image.GetImageSizeTypeFromType(imageType); - var defaultImage = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(series.AniDB_ID, imageSizeType) ?? - new AniDB_Anime_DefaultImage { AnimeID = series.AniDB_ID, ImageType = (int)imageSizeType }; - defaultImage.ImageParentID = imageID; - defaultImage.ImageParentType = (int)imageEntityType.Value; + var dataSource = body.Source.ToServer(); + var imageEntityType = imageType.ToServer(); + var image = ImageUtils.GetImageMetadata(dataSource, imageEntityType, body.ID); + if (image is null) + return ValidationProblem(InvalidIDForSource); + if (!image.IsEnabled) + return ValidationProblem(InvalidImageIsDisabled); // Create or update the entry. - RepoFactory.AniDB_Anime_DefaultImage.Save(defaultImage); + var defaultImage = RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(series.AniDB_ID, imageEntityType) ?? + new() { AnidbAnimeID = series.AniDB_ID, ImageType = imageEntityType }; + defaultImage.ImageID = body.ID; + defaultImage.ImageSource = dataSource; + RepoFactory.AniDB_Anime_PreferredImage.Save(defaultImage); // Update the contract data (used by Shoko Desktop). RepoFactory.AnimeSeries.Save(series, false); - ShokoEventHandler.Instance.OnSeriesUpdated(series, UpdateReason.Updated); - - return new Image(imageID, imageEntityType.Value, true); + return new Image(body.ID, imageEntityType, dataSource, true); } /// @@ -1766,11 +2420,10 @@ public ActionResult SetSeriesDefaultImageForType([FromRoute] int seriesID /// Poster, Banner, Fanart /// [HttpDelete("{seriesID}/Images/{imageType}")] - public ActionResult DeleteSeriesDefaultImageForType([FromRoute] int seriesID, [FromRoute] Image.ImageType imageType) + public ActionResult DeleteSeriesDefaultImageForType([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromRoute] Image.ImageType imageType) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); - if (!AllowedImageTypes.Contains(imageType)) - return NotFound(); + if (!_allowedImageTypes.Contains(imageType)) + return BadRequest(InvalidImageTypeForSeries); // Check if the series exists and if the user can access the series. var series = RepoFactory.AnimeSeries.GetByID(seriesID); @@ -1781,14 +2434,13 @@ public ActionResult DeleteSeriesDefaultImageForType([FromRoute] int seriesID, [F return Forbid(SeriesForbiddenForUser); // Check if a default image is set. - var imageSizeType = Image.GetImageSizeTypeFromType(imageType); - var defaultImage = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(series.AniDB_ID, imageSizeType); + var imageEntityType = imageType.ToServer(); + var defaultImage = RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(series.AniDB_ID, imageEntityType); if (defaultImage == null) return ValidationProblem("No default banner."); // Delete the entry. - RepoFactory.AniDB_Anime_DefaultImage.Delete(defaultImage); + RepoFactory.AniDB_Anime_PreferredImage.Delete(defaultImage); // Update the contract data (used by Shoko Desktop). RepoFactory.AnimeSeries.Save(series, false); @@ -1813,11 +2465,10 @@ public ActionResult DeleteSeriesDefaultImageForType([FromRoute] int seriesID, [F /// Only show verified tags. /// [HttpGet("{seriesID}/Tags")] - public ActionResult> GetSeriesTags([FromRoute] int seriesID, [FromQuery] TagFilter.Filter filter = 0, + public ActionResult> GetSeriesTags([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] TagFilter.Filter filter = 0, [FromQuery] bool excludeDescriptions = false, [FromQuery] bool orderByName = false, [FromQuery] bool onlyVerified = true) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -1835,7 +2486,96 @@ public ActionResult> GetSeriesTags([FromRoute] int seriesID, [FromQuer return new List(); } - return _seriesFactory.GetTags(anidb, filter, excludeDescriptions, orderByName, onlyVerified); + return Series.GetTags(anidb, filter, excludeDescriptions, orderByName, onlyVerified); + } + + /// + /// Get user tags for Series with ID. + /// + /// Shoko ID + /// Exclude tag descriptions. + /// + [HttpGet("{seriesID}/Tags/User")] + public ActionResult> GetSeriesUserTags( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery] bool excludeDescriptions = false) + => GetSeriesTags(seriesID, TagFilter.Filter.User | TagFilter.Filter.Invert, excludeDescriptions, true, true); + + /// + /// Add user tags for Series with ID. + /// + /// Shoko ID. + /// Body containing the user tag ids to add. + /// No content if nothing was added, Created if any cross-references were added, otherwise an error action result. + [HttpPost("{seriesID}/Tags/User")] + [Authorize("admin")] + public ActionResult AddSeriesUserTags( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.AddOrRemoveUserTagsBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + { + return NotFound(SeriesNotFoundWithSeriesID); + } + + if (!User.AllowedSeries(series)) + { + return Forbid(SeriesForbiddenForUser); + } + + var existingTagIds = RepoFactory.CrossRef_CustomTag.GetByAnimeID(seriesID); + var toAdd = body.IDs + .Except(existingTagIds.Select(xref => xref.CustomTagID)) + .Select(id => new CrossRef_CustomTag + { + CrossRefID = seriesID, + CrossRefType = (int)CustomTagCrossRefType.Anime, + CustomTagID = id, + }) + .ToList(); + if (toAdd.Count is 0) + return NoContent(); + + RepoFactory.CrossRef_CustomTag.Save(toAdd); + + return Created(); + } + + /// + /// Remove user tags for Series with ID. + /// + /// Shoko ID. + /// Body containing the user tag ids to remove. + /// No content if nothing was removed, Ok if any cross-references were removed, otherwise an error action result. + [HttpDelete("{seriesID}/Tags/User")] + [Authorize("admin")] + public ActionResult RemoveSeriesUserTags( + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Series.Input.AddOrRemoveUserTagsBody body + ) + { + var series = RepoFactory.AnimeSeries.GetByID(seriesID); + if (series == null) + { + return NotFound(SeriesNotFoundWithSeriesID); + } + + if (!User.AllowedSeries(series)) + { + return Forbid(SeriesForbiddenForUser); + } + + var existingTagIds = RepoFactory.CrossRef_CustomTag.GetByAnimeID(seriesID); + var toRemove = existingTagIds + .IntersectBy(body.IDs, xref => xref.CustomTagID) + .ToList(); + if (toRemove.Count is 0) + return NoContent(); + + RepoFactory.CrossRef_CustomTag.Delete(toRemove); + return Ok(); } /// @@ -1847,11 +2587,10 @@ public ActionResult> GetSeriesTags([FromRoute] int seriesID, [FromQuer /// /// [HttpGet("{seriesID}/Tags/{filter}")] - [Obsolete] - public ActionResult> GetSeriesTagsFromPath([FromRoute] int seriesID, [FromRoute] TagFilter.Filter filter, + [Obsolete("Use Tags with query parameter instead.")] + public ActionResult> GetSeriesTagsFromPath([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromRoute] TagFilter.Filter filter, [FromQuery] bool excludeDescriptions = false) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); return GetSeriesTags(seriesID, filter, excludeDescriptions); } @@ -1866,10 +2605,9 @@ public ActionResult> GetSeriesTagsFromPath([FromRoute] int seriesID, [ /// Filter by role type /// [HttpGet("{seriesID}/Cast")] - public ActionResult> GetSeriesCast([FromRoute] int seriesID, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet roleType = null) + public ActionResult> GetSeriesCast([FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? roleType = null) { - if (seriesID == 0) return BadRequest(SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) { @@ -1881,7 +2619,7 @@ public ActionResult> GetSeriesCast([FromRoute] int seriesID, return Forbid(SeriesForbiddenForUser); } - return _seriesFactory.GetCast(series.AniDB_ID, roleType); + return Series.GetCast(series.AniDB_ID, roleType); } #endregion @@ -1896,14 +2634,8 @@ public ActionResult> GetSeriesCast([FromRoute] int seriesID, /// [Authorize("admin")] [HttpPatch("{seriesID}/Move/{groupID}")] - public ActionResult MoveSeries([FromRoute] int seriesID, [FromRoute] int groupID) + public ActionResult MoveSeries([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromRoute, Range(1, int.MaxValue)] int groupID) { - if (seriesID == 0) - return BadRequest(SeriesWithZeroID); - - if (groupID == 0) - return BadRequest(GroupController.GroupWithZeroID); - var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) return NotFound(SeriesNotFoundWithSeriesID); @@ -1930,9 +2662,9 @@ public ActionResult MoveSeries([FromRoute] int seriesID, [FromRoute] int groupID /// target string /// whether or not to use fuzzy search /// number of return items - /// List + /// List [HttpGet("Search")] - public ActionResult> SearchQuery([FromQuery] string query, [FromQuery] bool fuzzy = true, + public ActionResult> SearchQuery([FromQuery] string query, [FromQuery] bool fuzzy = true, [FromQuery, Range(0, 1000)] int limit = 50) => SearchInternal(query, fuzzy, limit); @@ -1943,22 +2675,22 @@ public ActionResult> SearchQuery([FromQuery] str /// whether or not to use fuzzy search /// number of return items /// Enable search by anidb anime id. - /// List + /// List [Obsolete("Use the other endpoint instead.")] [HttpGet("Search/{query}")] - public ActionResult> SearchPath([FromRoute] string query, [FromQuery] bool fuzzy = true, + public ActionResult> SearchPath([FromRoute] string query, [FromQuery] bool fuzzy = true, [FromQuery, Range(0, 1000)] int limit = 50, [FromQuery] bool searchById = false) => SearchInternal(HttpUtility.UrlDecode(query), fuzzy, limit, searchById); [NonAction] - internal ActionResult> SearchInternal(string query, bool fuzzy = true, int limit = 50, bool searchById = false) + internal ActionResult> SearchInternal(string query, bool fuzzy = true, int limit = 50, bool searchById = false) { var flags = SeriesSearch.SearchFlags.Titles; if (fuzzy) flags |= SeriesSearch.SearchFlags.Fuzzy; return SeriesSearch.SearchSeries(User, query, limit, flags, searchById: searchById) - .Select(result => _seriesFactory.GetSeriesSearchResult(result)) + .Select(result => new Series.SearchResult(result, User.JMMUserID)) .ToList(); } @@ -1975,8 +2707,8 @@ internal ActionResult> SearchInternal(string que [HttpGet("AniDB/Search")] public ActionResult> AnidbSearchQuery([FromQuery] string query, [FromQuery] bool fuzzy = true, [FromQuery] bool? local = null, - [FromQuery] bool includeTitles = true, [FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1) + [FromQuery] bool includeTitles = true, [FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1) => AnidbSearchInternal(query, fuzzy, local, includeTitles, pageSize, page); /// @@ -1993,8 +2725,8 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) [HttpGet("AniDB/Search/{query}")] public ActionResult> AnidbSearchPath([FromRoute] string query, [FromQuery] bool fuzzy = true, [FromQuery] bool? local = null, - [FromQuery] bool includeTitles = true, [FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1) + [FromQuery] bool includeTitles = true, [FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1) => AnidbSearchInternal(HttpUtility.UrlDecode(query), fuzzy, local, includeTitles, pageSize, page); [NonAction] @@ -2007,19 +2739,17 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); if (anime != null) { - return new ListResult(1, - new List { _seriesFactory.GetAniDB(anime, includeTitles: includeTitles) }); + return new(1, [new(anime, includeTitles: includeTitles)]); } // Check the title cache for a match. var result = _titleHelper.SearchAnimeID(animeID); if (result != null) { - return new ListResult(1, - new List { _seriesFactory.GetAniDB(result, includeTitles: includeTitles) }); + return new(1, [new(result, includeTitles: includeTitles)]); } - return new ListResult(); + return new(); } // Return all known entries on anidb if no query is given. @@ -2029,14 +2759,12 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) .Select(result => { var series = RepoFactory.AnimeSeries.GetByAnimeID(result.AnimeID); - if (local.HasValue && series == null == local.Value) - { + if (local.HasValue && series is null == local.Value) return null; - } - return _seriesFactory.GetAniDB(result, series, includeTitles); + return new Series.AniDB(result, series, includeTitles); }) - .Where(result => result != null) + .WhereNotNull() .ToListResult(page, pageSize); // Search the title cache for anime matching the query. @@ -2044,14 +2772,12 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) .Select(result => { var series = RepoFactory.AnimeSeries.GetByAnimeID(result.AnimeID); - if (local.HasValue && series == null == local.Value) - { + if (local.HasValue && series is null == local.Value) return null; - } - return _seriesFactory.GetAniDB(result, series, includeTitles); + return new Series.AniDB(result, series, includeTitles); }) - .Where(result => result != null) + .WhereNotNull() .ToListResult(page, pageSize); } @@ -2062,16 +2788,16 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1) /// /// [HttpGet("StartsWith/{query}")] - public ActionResult> StartsWith([FromRoute] string query, + public ActionResult> StartsWith([FromRoute] string query, [FromQuery] int limit = int.MaxValue) { var user = User; query = query.ToLowerInvariant(); - var seriesList = new List(); + var seriesList = new List(); var tempSeries = new ConcurrentDictionary(); var allSeries = RepoFactory.AnimeSeries.GetAll() - .Where(series => user.AllowedSeries(series)) + .Where(user.AllowedSeries) .AsParallel(); #region Search_TitlesOnly @@ -2082,7 +2808,7 @@ public ActionResult> StartsWith([FromRoute] string quer foreach (var (ser, match) in series) { - seriesList.Add(_seriesFactory.GetSeriesSearchResult(new() { Result = ser, Match = match })); + seriesList.Add(new Series.SearchResult(new() { Result = ser, Match = match }, User.JMMUserID)); if (seriesList.Count >= limit) { break; @@ -2103,12 +2829,12 @@ public ActionResult> PathEndsWith([FromRoute] string path) { var user = User; var query = path; - if (query.Contains("%") || query.Contains("+")) + if (query.Contains('%') || query.Contains('+')) { query = Uri.UnescapeDataString(query); } - if (query.Contains("%")) + if (query.Contains('%')) { query = Uri.UnescapeDataString(query); } @@ -2116,16 +2842,20 @@ public ActionResult> PathEndsWith([FromRoute] string path) query = query.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar) .TrimEnd(Path.DirectorySeparatorChar); // There should be no circumstance where FullServerPath has no Directory Name, unless you have missing import folders - return RepoFactory.VideoLocalPlace.GetAll().AsParallel() + return RepoFactory.VideoLocalPlace.GetAll() .Where(a => { if (a.FullServerPath == null) return false; var dir = Path.GetDirectoryName(a.FullServerPath); return dir != null && dir.EndsWith(query, StringComparison.OrdinalIgnoreCase); }) - .SelectMany(a => a.VideoLocal?.AnimeEpisodes ?? Enumerable.Empty()).Select(a => a.AnimeSeries) - .Distinct() - .Where(ser => ser != null && user.AllowedSeries(ser)).Select(a => _seriesFactory.GetSeries(a)).ToList(); + .SelectMany(a => a.VideoLocal?.AnimeEpisodes ?? Enumerable.Empty()) + .DistinctBy(a => a.AnimeSeriesID) + .Select(a => a.AnimeSeries) + .WhereNotNull() + .Where(user.AllowedSeries) + .Select(a => new Series(a, User.JMMUserID)) + .ToList(); } #region Helpers @@ -2140,7 +2870,7 @@ private static void CheckTitlesStartsWith(SVR_AnimeSeries a, string query, } var titles = a.AniDB_Anime.GetAllTitles(); - if ((titles?.Count ?? 0) == 0) + if (titles is null || titles.Count == 0) { return; } @@ -2176,51 +2906,13 @@ private static void CheckTitlesStartsWith(SVR_AnimeSeries a, string query, /// [HttpGet("Years")] public ActionResult> GetAllYears() - { - return RepoFactory.AnimeSeries.GetAllYears().ToList(); - } + => RepoFactory.AnimeSeries.GetAllYears().ToList(); /// /// Get a list of all years and seasons (2024 Winter) that series that you have aired in. One Piece would return every Season from 1999 Fall to preset (assuming it's still airing *today*) /// /// [HttpGet("Seasons")] - public ActionResult> GetAllSeasons() - { - return RepoFactory.AnimeSeries.GetAllSeasons().Select(a => new Season(a.Year, a.Season)).OrderBy(a => a, Season.SeasonComparer).ToList(); - } - - public record Season(int Year, AnimeSeason AnimeSeason) - { - private sealed class SeasonRelationalComparer : IComparer - { - public int Compare(Season x, Season y) - { - if (ReferenceEquals(x, y)) - { - return 0; - } - - if (ReferenceEquals(null, y)) - { - return 1; - } - - if (ReferenceEquals(null, x)) - { - return -1; - } - - var yearComparison = x.Year.CompareTo(y.Year); - if (yearComparison != 0) - { - return yearComparison; - } - - return x.AnimeSeason.CompareTo(y.AnimeSeason); - } - } - - public static IComparer SeasonComparer { get; } = new SeasonRelationalComparer(); - } + public ActionResult> GetAllSeasons() + => RepoFactory.AnimeSeries.GetAllSeasons().Select(a => new YearlySeason(a.Year, a.Season)).Order().ToList(); } diff --git a/Shoko.Server/API/v3/Controllers/SettingsController.cs b/Shoko.Server/API/v3/Controllers/SettingsController.cs index 8d94d8493..7e3204d85 100644 --- a/Shoko.Server/API/v3/Controllers/SettingsController.cs +++ b/Shoko.Server/API/v3/Controllers/SettingsController.cs @@ -1,16 +1,18 @@ +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.Annotations; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.Providers.AniDB.Interfaces; using Shoko.Server.Settings; +using Shoko.Server.Utilities; +#pragma warning disable CA1822 namespace Shoko.Server.API.v3.Controllers; [ApiController] @@ -21,9 +23,7 @@ namespace Shoko.Server.API.v3.Controllers; [InitFriendly] public class SettingsController : BaseController { - private readonly IConnectivityService _connectivityService; private readonly IUDPConnectionHandler _udpHandler; - private readonly IHttpConnectionHandler _httpHandler; private readonly ILogger _logger; // As far as I can tell, only GET and PATCH should be supported, as we don't support unset settings. @@ -101,41 +101,17 @@ public async Task TestAniDB([FromBody] Credentials credentials) return Ok(); } - /// - /// Gets the current network connectivity details for the server. - /// - /// - [HttpGet("Connectivity")] - public ActionResult GetNetworkAvailability() + public SettingsController(ISettingsProvider settingsProvider, ILogger logger, IUDPConnectionHandler udpHandler) : base(settingsProvider) { - return new ConnectivityDetails - { - NetworkAvailability = _connectivityService.NetworkAvailability, - LastChangedAt = _connectivityService.LastChangedAt, - IsAniDBUdpReachable = _udpHandler.IsAlive && _udpHandler.IsNetworkAvailable, - IsAniDBUdpBanned = _udpHandler.IsBanned, - IsAniDBHttpBanned = _httpHandler.IsBanned - }; + _logger = logger; + _udpHandler = udpHandler; } /// - /// Forcefully re-checks the current network connectivity, then returns the - /// updated details for the server. + /// Get a list of all supported languages. /// - /// - [HttpPost("Connectivity")] - public async Task> CheckNetworkAvailability() - { - await _connectivityService.CheckAvailability(); - - return GetNetworkAvailability(); - } - - public SettingsController(ISettingsProvider settingsProvider, IConnectivityService connectivityService, ILogger logger, IUDPConnectionHandler udpHandler, IHttpConnectionHandler httpHandler) : base(settingsProvider) - { - _connectivityService = connectivityService; - _logger = logger; - _udpHandler = udpHandler; - _httpHandler = httpHandler; - } + /// A list of all supported languages. + [HttpGet("SupportedLanguages")] + public ActionResult> GetAllSupportedLanguages() => + Languages.AllNamingLanguages.Select(a => new LanguageDetails(a.Language)).ToList(); } diff --git a/Shoko.Server/API/v3/Controllers/TagController.cs b/Shoko.Server/API/v3/Controllers/TagController.cs index f9422fec4..41a1071c1 100644 --- a/Shoko.Server/API/v3/Controllers/TagController.cs +++ b/Shoko.Server/API/v3/Controllers/TagController.cs @@ -2,7 +2,9 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Shoko.Models.Server; using Shoko.Server.API.Annotations; using Shoko.Server.API.v3.Helpers; @@ -29,17 +31,23 @@ public class TagController : BaseController /// Only show verified tags. /// [HttpGet("AniDB")] - public ActionResult> GetAllAnidbTags([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] TagFilter.Filter filter = 0, - [FromQuery] bool excludeDescriptions = false, [FromQuery] bool onlyVerified = true) + public ActionResult> GetAllAnidbTags( + [FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] TagFilter.Filter filter = 0, + [FromQuery] bool excludeDescriptions = false, + [FromQuery] bool onlyVerified = true + ) { var user = User; var selectedTags = RepoFactory.AniDB_Tag.GetAll() .Where(tag => !onlyVerified || tag.Verified) .DistinctBy(a => a.TagName) .ToList(); - var tagFilter = new TagFilter(name => RepoFactory.AniDB_Tag.GetByName(name).FirstOrDefault(), tag => tag.TagName, - name => new AniDB_Tag { TagNameSource = name }); + var tagFilter = new TagFilter( + name => RepoFactory.AniDB_Tag.GetByName(name).FirstOrDefault(), tag => tag.TagName, + name => new AniDB_Tag { TagNameSource = name } + ); return tagFilter .ProcessTags(filter, selectedTags) .Where(tag => user.IsAdmin == 1 || user.AllowedTag(tag)) @@ -56,7 +64,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] TagFilter.Filter [HttpGet("AniDB/{tagID}")] public ActionResult GetAnidbTag([FromRoute] int tagID, [FromQuery] bool excludeDescription = false) { - var tag = RepoFactory.AniDB_Tag.GetByTagID(tagID); + var tag = tagID <= 0 ? null : RepoFactory.AniDB_Tag.GetByTagID(tagID); if (tag == null) return NotFound("No AniDB Tag entry for the given tagID"); @@ -75,14 +83,35 @@ public ActionResult GetAnidbTag([FromRoute] int tagID, [FromQuery] bool exc /// Exclude tag descriptions from response. /// [HttpGet("User")] - public ActionResult> GetAllUserTags([FromQuery] [Range(0, 100)] int pageSize = 50, - [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool excludeDescriptions = false) + public ActionResult> GetAllUserTags( + [FromQuery, Range(0, 100)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] bool excludeDescriptions = false) { return RepoFactory.CustomTag.GetAll() .OrderBy(tag => tag.TagName) .ToListResult(tag => new Tag(tag, excludeDescriptions), page, pageSize); } + /// + /// Add a new user tag. + /// + /// Details for the new user tag. + /// The new user tag, or an error action result. + [HttpPost("User")] + [Authorize("admin")] + public ActionResult AddUserTag([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Tag.Input.CreateOrUpdateCustomTagBody body) + { + if (string.IsNullOrEmpty(body.Name?.Trim())) + return ValidationProblem("Name must be set for new tags.", nameof(body.Name)); + + var tag = body.MergeWithExisting(new(), ModelState); + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + return tag; + } + /// /// Get an user tag by it's . /// @@ -90,7 +119,7 @@ [FromQuery] [Range(1, int.MaxValue)] int page = 1, [FromQuery] bool excludeDescr /// Exclude tag description from response. /// [HttpGet("User/{tagID}")] - public ActionResult GetUserTag([FromRoute] int tagID, [FromQuery] bool excludeDescription = false) + public ActionResult GetUserTag([FromRoute, Range(1, int.MaxValue)] int tagID, [FromQuery] bool excludeDescription = false) { var tag = RepoFactory.CustomTag.GetByID(tagID); if (tag == null) @@ -99,6 +128,73 @@ public ActionResult GetUserTag([FromRoute] int tagID, [FromQuery] bool excl return new Tag(tag, excludeDescription); } + /// + /// Update an existing user tag by directly replacing it's fields. + /// + /// User Tag ID. + /// Details about what to update for the existing tag. + /// The updated user tag, or an error action result. + [HttpPut("User/{tagID}")] + [Authorize("admin")] + public ActionResult EditUserTag([FromRoute, Range(1, int.MaxValue)] int tagID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Tag.Input.CreateOrUpdateCustomTagBody body) + { + var tag = RepoFactory.CustomTag.GetByID(tagID); + if (tag == null) + return NotFound("No User Tag entry for the given tagID"); + + var result = body.MergeWithExisting(tag, ModelState); + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + return result!; + } + + /// + /// Update an existing user tag using JSON patch. + /// + /// User Tag ID. + /// The JSON patch document containing the + /// details about what to update for the existing tag. + /// The updated user tag, or an error action result. + [HttpPatch("User/{tagID}")] + [Authorize("admin")] + public ActionResult PatchUserTag([FromRoute, Range(1, int.MaxValue)] int tagID, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] JsonPatchDocument patchDocument) + { + var tag = RepoFactory.CustomTag.GetByID(tagID); + if (tag == null) + return NotFound("No User Tag entry for the given tagID"); + var body = new Tag.Input.CreateOrUpdateCustomTagBody(); + patchDocument.ApplyTo(body, ModelState); + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + var result = body.MergeWithExisting(tag, ModelState); + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + return result!; + } + + /// + /// Remove an existing user tag. + /// + /// User Tag ID. + /// No content or an error action result. + [HttpDelete("User/{tagID}")] + [Authorize("admin")] + public ActionResult RemoveUserTag([FromRoute, Range(1, int.MaxValue)] int tagID) + { + var tag = RepoFactory.CustomTag.GetByID(tagID); + if (tag == null) + return NotFound("No User Tag entry for the given tagID"); + + var xrefs = RepoFactory.CrossRef_CustomTag.GetByCustomTagID(tagID); + RepoFactory.CrossRef_CustomTag.Delete(xrefs); + RepoFactory.CustomTag.Delete(tag); + + return NoContent(); + } + public TagController(ISettingsProvider settingsProvider) : base(settingsProvider) { } diff --git a/Shoko.Server/API/v3/Controllers/TmdbController.cs b/Shoko.Server/API/v3/Controllers/TmdbController.cs new file mode 100644 index 000000000..c79dbdc56 --- /dev/null +++ b/Shoko.Server/API/v3/Controllers/TmdbController.cs @@ -0,0 +1,2594 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.API.Annotations; +using Shoko.Server.API.ModelBinders; +using Shoko.Server.API.v3.Helpers; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.API.v3.Models.Shoko; +using Shoko.Server.API.v3.Models.TMDB.Input; +using Shoko.Server.Extensions; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Settings; +using Shoko.Server.Utilities; + +using InternalEpisodeType = Shoko.Models.Enums.EpisodeType; +using CrossRefSource = Shoko.Models.Enums.CrossRefSource; +using MatchRating = Shoko.Models.Enums.MatchRating; +using DataSource = Shoko.Server.API.v3.Models.Common.DataSource; +using TmdbEpisode = Shoko.Server.API.v3.Models.TMDB.Episode; +using TmdbMovie = Shoko.Server.API.v3.Models.TMDB.Movie; +using TmdbSearch = Shoko.Server.API.v3.Models.TMDB.Search; +using TmdbSeason = Shoko.Server.API.v3.Models.TMDB.Season; +using TmdbShow = Shoko.Server.API.v3.Models.TMDB.Show; + +#pragma warning disable CA1822 +#nullable enable +namespace Shoko.Server.API.v3.Controllers; + +[ApiController] +[Route("/api/v{version:apiVersion}/[controller]")] +[ApiV3] +[Authorize] +public partial class TmdbController : BaseController +{ + private readonly ILogger _logger; + + private readonly TmdbSearchService _tmdbSearchService; + + private readonly TmdbMetadataService _tmdbMetadataService; + + public TmdbController(ISettingsProvider settingsProvider, ILogger logger, TmdbSearchService tmdbSearchService, TmdbMetadataService tmdbService) : base(settingsProvider) + { + _logger = logger; + _tmdbSearchService = tmdbSearchService; + _tmdbMetadataService = tmdbService; + } + + #region Movies + + #region Constants + + internal const string MovieNotFound = "A TMDB.Movie by the given `movieID` was not found."; + + #endregion + + #region Basics + + /// + /// List all locally available tmdb movies. + /// + /// + /// + /// + /// + /// + /// + /// + /// + [HttpGet("Movie")] + public ActionResult> GetTmdbMovies( + [FromQuery] string? search = null, + [FromQuery] bool fuzzy = true, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery] IncludeOnlyFilter restricted = IncludeOnlyFilter.True, + [FromQuery] IncludeOnlyFilter video = IncludeOnlyFilter.True, + [FromQuery, Range(0, 1000)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var hasSearch = !string.IsNullOrWhiteSpace(search); + var movies = RepoFactory.TMDB_Movie.GetAll() + .AsParallel() + .Where(movie => + { + if (restricted != IncludeOnlyFilter.True) + { + var includeRestricted = restricted == IncludeOnlyFilter.Only; + var isRestricted = movie.IsRestricted; + if (isRestricted != includeRestricted) + return false; + } + + if (video != IncludeOnlyFilter.True) + { + var includeVideo = video == IncludeOnlyFilter.Only; + var isVideo = movie.IsVideo; + if (isVideo != includeVideo) + return false; + } + + return true; + }); + if (hasSearch) + { + var languages = SettingsProvider.GetSettings() + .Language.DescriptionLanguageOrder + .Select(lang => lang.GetTitleLanguage()) + .Concat(new TitleLanguage[] { TitleLanguage.English }) + .ToHashSet(); + return movies + .Search( + search, + movie => movie.GetAllTitles() + .WhereInLanguages(languages) + .Select(title => title.Value) + .Append(movie.EnglishTitle) + .Append(movie.OriginalTitle) + .Distinct() + .ToList(), + fuzzy + ) + .ToListResult(a => new TmdbMovie(a.Result, include?.CombineFlags()), page, pageSize); + } + + return movies + .OrderBy(movie => movie.EnglishTitle) + .ThenBy(movie => movie.TmdbMovieID) + .ToListResult(m => new TmdbMovie(m, include?.CombineFlags()), page, pageSize); + } + + [HttpPost("Movie/Bulk")] + public ActionResult> BulkGetTmdbMoviesByMovieIDs([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbBulkFetchBody body) => + body.IDs + .Select(episodeID => episodeID <= 0 ? null : RepoFactory.TMDB_Movie.GetByTmdbMovieID(episodeID)) + .WhereNotNull() + .Select(episode => new TmdbMovie(episode, body.Include?.CombineFlags(), body.Language)) + .ToList(); + + /// + /// Get the local metadata for a TMDB movie. + /// + /// TMDB Movie ID. + /// + /// + /// + [HttpGet("Movie/{movieID}")] + public ActionResult GetTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return new TmdbMovie(movie, include?.CombineFlags(), language); + } + + /// + /// Remove the local copy of the metadata for a TMDB movie. + /// + /// TMDB Movie ID. + /// Also remove images related to the show. + /// + [Authorize("admin")] + [HttpDelete("Movie/{movieID}")] + public async Task RemoveTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromQuery] bool removeImageFiles = true + ) + { + await _tmdbMetadataService.SchedulePurgeOfMovie(movieID, removeImageFiles); + + return NoContent(); + } + + [HttpGet("Movie/{movieID}/Titles")] + public ActionResult> GetTitlesForTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + var preferredTitle = movie.GetPreferredTitle(); + return new(movie.GetAllTitles().ToDto(movie.EnglishTitle, preferredTitle, language)); + } + + [HttpGet("Movie/{movieID}/Overviews")] + public ActionResult> GetOverviewsForTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + var preferredOverview = movie.GetPreferredOverview(); + return new(movie.GetAllOverviews().ToDto(movie.EnglishTitle, preferredOverview, language)); + } + + [HttpGet("Movie/{movieID}/Images")] + public ActionResult GetImagesForTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.GetImages() + .ToDto(language); + } + + [HttpGet("Movie/{movieID}/Cast")] + public ActionResult> GetCastForTmdbMovieByMovieID( + [FromRoute] int movieID + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.Cast + .Select(cast => new Role(cast)) + .ToList(); + } + + [HttpGet("Movie/{movieID}/Crew")] + public ActionResult> GetCrewForTmdbMovieByMovieID( + [FromRoute] int movieID + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.Crew + .Select(cast => new Role(cast)) + .ToList(); + } + + [HttpGet("Movie/{movieID}/CrossReferences")] + public ActionResult> GetCrossReferencesForTmdbMovieByMovieID( + [FromRoute] int movieID + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.CrossReferences + .Select(xref => new TmdbMovie.CrossReference(xref)) + .ToList(); + } + + [HttpGet("Movie/{movieID}/FileCrossReferences")] + public ActionResult> GetFileCrossReferencesForTmdbMovieByMovieID( + [FromRoute] int movieID + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return FileCrossReference.From(movie.FileCrossReferences); + } + + [HttpGet("Movie/{movieID}/Studios")] + public ActionResult> GetStudiosForTmdbMovieByMovieID( + [FromRoute] int movieID + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.GetTmdbCompanies() + .Select(company => new Studio(company)) + .ToList(); + } + + [HttpGet("Movie/{movieID}/ContentRatings")] + public ActionResult> GetContentRatingsForTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return new(movie.ContentRatings.ToDto(language)); + } + + #endregion + + #region Same-Source Linked Entries + + [HttpGet("Movie/{movieID}/Collection")] + public ActionResult GetTmdbMovieCollectionByMovieID( + [FromRoute] int movieID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + var movieCollection = movie.TmdbCollection; + if (movieCollection is null) + return NotFound(MovieCollectionByMovieIDNotFound); + + return new TmdbMovie.Collection(movieCollection, include?.CombineFlags()); + } + + #endregion + + #region Cross-Source Linked Entries + + /// + /// Get all AniDB series linked to a TMDB movie. + /// + /// TMDB Movie ID. + /// + [HttpGet("Movie/{movieID}/AniDB/Anime")] + public ActionResult> GetAniDBAnimeByTmdbMovieID( + [FromRoute] int movieID + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.CrossReferences + .Select(xref => xref.AnidbAnime) + .WhereNotNull() + .Select(anime => new Series.AniDB(anime)) + .ToList(); + } + + /// + /// Get all AniDB episodes linked to a TMDB movie. + /// + /// TMDB Movie ID. + /// + [HttpGet("Movie/{movieID}/AniDB/Episodes")] + public ActionResult> GetAniDBEpisodesByTmdbMovieID( + [FromRoute] int movieID + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.CrossReferences + .Select(xref => xref.AnidbEpisode) + .WhereNotNull() + .Select(episode => new Episode.AniDB(episode)) + .ToList(); + } + + /// + /// Get all Shoko series linked to a TMDB movie. + /// + /// TMDB Movie ID. + /// Randomize images shown for the . + /// Include data from selected s. + /// + [HttpGet("Movie/{movieID}/Shoko/Series")] + public ActionResult> GetShokoSeriesByTmdbMovieID( + [FromRoute] int movieID, + [FromQuery] bool randomImages = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.CrossReferences + .Select(xref => xref.AnimeSeries) + .WhereNotNull() + .Select(series => new Series(series, User.JMMUserID, randomImages, includeDataFrom)) + .ToList(); + } + + /// + /// Get all Shoko episodes linked to a TMDB movie. + /// + /// TMDB Movie ID. + /// Include data from selected s. + /// + [HttpGet("Movie/{movieID}/Shoko/Episodes")] + public ActionResult> GetShokoEpisodesByTmdbMovieID( + [FromRoute] int movieID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + return movie.CrossReferences + .Select(xref => xref.AnimeEpisode) + .WhereNotNull() + .Select(episode => new Episode(HttpContext, episode, includeDataFrom)) + .ToList(); + } + + #endregion + + #region Actions + + /// + /// Refresh or download the metadata for a TMDB movie. + /// + /// TMDB Movie ID. + /// Body containing options for refreshing or downloading metadata. + /// + /// If is , returns an , + /// otherwise returns a . + /// + [Authorize("admin")] + [HttpPost("Movie/{movieID}/Action/Refresh")] + public async Task RefreshTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbRefreshMovieBody body + ) + { + if (body.SkipIfExists) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is not null) + return Ok(); + } + + if (body.Immediate) + { + await _tmdbMetadataService.UpdateMovie(movieID, body.Force, body.DownloadImages, body.DownloadCrewAndCast ?? SettingsProvider.GetSettings().TMDB.AutoDownloadCrewAndCast, body.DownloadCollections ?? SettingsProvider.GetSettings().TMDB.AutoDownloadCollections); + return Ok(); + } + + await _tmdbMetadataService.ScheduleUpdateOfMovie(movieID, body.Force, body.DownloadImages, body.DownloadCrewAndCast, body.DownloadCollections); + return NoContent(); + } + + /// + /// Download images for a TMDB movie. + /// + /// TMDB Movie ID. + /// Body containing options for downloading images. + /// + /// If is , returns an , + /// otherwise returns a . + /// + [Authorize("admin")] + [HttpPost("Movie/{movieID}/Action/DownloadImages")] + public async Task DownloadImagesForTmdbMovieByMovieID( + [FromRoute] int movieID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbDownloadImagesBody body + ) + { + var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID); + if (movie is null) + return NotFound(MovieNotFound); + + if (body.Immediate) + { + await _tmdbMetadataService.DownloadAllMovieImages(movieID, body.Force); + return Ok(); + } + + await _tmdbMetadataService.ScheduleDownloadAllMovieImages(movieID, body.Force); + return NoContent(); + } + + #endregion + + #region Online (Search / Bulk / Single) + + /// + /// Search TMDB for movies using the offline or online search. + /// + /// Query to search for. + /// Include restricted movies. + /// First aired year. + /// The page size. Set to 0 to only grab the total. + /// The page index. + /// + [Authorize("admin")] + [HttpGet("Movie/Online/Search")] + public ListResult SearchOnlineForTmdbMovies( + [FromQuery] string query, + [FromQuery] bool includeRestricted = false, + [FromQuery, Range(0, int.MaxValue)] int year = 0, + [FromQuery, Range(0, 100)] int pageSize = 6, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var (pageView, totalMovies) = _tmdbSearchService.SearchMovies(query, includeRestricted, year, page, pageSize) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + return new ListResult(totalMovies, pageView.Select(a => new TmdbSearch.RemoteSearchMovie(a))); + } + + /// + /// Search for multiple TMDB movies by their IDs. + /// + /// + /// If any of the IDs are not found, a is returned. + /// + /// Body containing the IDs of the movies to search for. + /// + /// A list of containing the search results. + /// The order of the returned movies is determined by the order of the IDs in . + /// + [HttpPost("Movie/Online/Bulk")] + public async Task>> SearchBulkForTmdbMovies( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbBulkSearchBody body + ) + { + // We don't care if the inputs are non-unique, but we don't want to double fetch, + // so we do a distinct here, then at the end we map back to the original order. + var uniqueIds = body.IDs.Distinct().ToList(); + var movieDict = uniqueIds + .Select(id => id <= 0 ? null : RepoFactory.TMDB_Movie.GetByTmdbMovieID(id)) + .WhereNotNull() + .Select(movie => new TmdbSearch.RemoteSearchMovie(movie)) + .ToDictionary(movie => movie.ID); + foreach (var id in uniqueIds.Except(movieDict.Keys)) + { + var movie = id <= 0 ? null : await _tmdbMetadataService.UseClient(c => c.GetMovieAsync(id), $"Get movie {id}"); + if (movie is null) + continue; + + movieDict[movie.Id] = new TmdbSearch.RemoteSearchMovie(movie); + } + + var unknownMovies = uniqueIds.Except(movieDict.Keys).ToList(); + if (unknownMovies.Count > 0) + { + foreach (var id in unknownMovies) + ModelState.AddModelError(nameof(body.IDs), $"Movie with id '{id}' not found."); + + return ValidationProblem(ModelState); + } + + return body.IDs + .Select(id => movieDict[id]) + .ToList(); + } + + /// + /// Search TMDB for a movie. + /// + /// TMDB Movie ID. + /// + /// If the movie is already in the database, returns the local copy. + /// Otherwise, returns the remote copy from TMDB. + /// If the movie is not found on TMDB, returns 404. + /// + [HttpGet("Movie/Online/{movieID}")] + public async Task> SearchOnlineForTmdbMovieByMovieID( + [FromRoute] int movieID + ) + { + if (RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieID) is { } localMovie) + return new TmdbSearch.RemoteSearchMovie(localMovie); + + if (await _tmdbMetadataService.UseClient(c => c.GetMovieAsync(movieID), $"Get movie {movieID}") is not { } remoteMovie) + return NotFound("Movie not found on TMDB."); + + return new TmdbSearch.RemoteSearchMovie(remoteMovie); + } + + #endregion + + #endregion + + #region Movie Collection + + #region Constants + + internal const string MovieCollectionNotFound = "A TMDB.MovieCollection by the given `collectionID` was not found."; + + internal const string MovieCollectionByMovieIDNotFound = "A TMDB.MovieCollection by the given `movieID` was not found."; + + #endregion + + #region Basics + + [HttpGet("Movie/Collection")] + public ActionResult> GetMovieCollections( + [FromRoute] string search, + [FromQuery] bool fuzzy = true, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null, + [FromQuery, Range(0, 1000)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + if (!string.IsNullOrWhiteSpace(search)) + { + var languages = SettingsProvider.GetSettings() + .Language.DescriptionLanguageOrder + .Select(lang => lang.GetTitleLanguage()) + .Concat(new TitleLanguage[] { TitleLanguage.English }) + .ToHashSet(); + return RepoFactory.TMDB_Collection.GetAll() + .Search( + search, + collection => collection.GetAllTitles() + .WhereInLanguages(languages) + .Select(title => title.Value) + .Append(collection.EnglishTitle) + .Distinct() + .ToList(), + fuzzy + ) + .ToListResult(a => new TmdbMovie.Collection(a.Result, include?.CombineFlags(), language), page, pageSize); + } + + return RepoFactory.TMDB_Collection.GetAll() + .ToListResult(a => new TmdbMovie.Collection(a, include?.CombineFlags(), language), page, pageSize); + } + + [HttpGet("Movie/Collection/{collectionID}")] + public ActionResult GetMovieCollectionByCollectionID( + [FromRoute] int collectionID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var collection = RepoFactory.TMDB_Collection.GetByTmdbCollectionID(collectionID); + if (collection is null) + return NotFound(MovieCollectionNotFound); + + return new TmdbMovie.Collection(collection, include?.CombineFlags(), language); + } + + [HttpGet("Movie/Collection/{collectionID}/Titles")] + public ActionResult> GetTitlesForMovieCollectionByCollectionID( + [FromRoute] int collectionID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var collection = RepoFactory.TMDB_Collection.GetByTmdbCollectionID(collectionID); + if (collection is null) + return NotFound(MovieCollectionNotFound); + + var preferredTitle = collection.GetPreferredTitle(); + return new(collection.GetAllTitles().ToDto(collection.EnglishTitle, preferredTitle, language)); + } + + [HttpGet("Movie/Collection/{collectionID}/Overviews")] + public ActionResult> GetOverviewsForMovieCollectionByCollectionID( + [FromRoute] int collectionID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var collection = RepoFactory.TMDB_Collection.GetByTmdbCollectionID(collectionID); + if (collection is null) + return NotFound(MovieCollectionNotFound); + + var preferredOverview = collection.GetPreferredOverview(); + return new(collection.GetAllOverviews().ToDto(collection.EnglishTitle, preferredOverview, language)); + } + + [HttpGet("Movie/Collection/{collectionID}/Images")] + public ActionResult GetImagesForMovieCollectionByCollectionID( + [FromRoute] int collectionID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var collection = RepoFactory.TMDB_Collection.GetByTmdbCollectionID(collectionID); + if (collection is null) + return NotFound(MovieCollectionNotFound); + + return collection.GetImages() + .ToDto(language); + } + + #endregion + + #region Same-Source Linked Entries + + [HttpGet("Movie/Collection/{collectionID}/Movie")] + public ActionResult> GetMoviesForMovieCollectionByCollectionID( + [FromRoute] int collectionID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var collection = RepoFactory.TMDB_Collection.GetByTmdbCollectionID(collectionID); + if (collection is null) + return NotFound(MovieCollectionNotFound); + + return collection.GetTmdbMovies() + .Select(movie => new TmdbMovie(movie, include?.CombineFlags(), language)) + .ToList(); + } + + #endregion + + #endregion + + #region Shows + + #region Constants + + internal const string AlternateOrderingIdRegex = @"^(?:[0-9]{1,23}|[a-f0-9]{24})$"; + + internal const string ShowNotFound = "A TMDB.Show by the given `showID` was not found."; + + internal const string ShowNotFoundBySeasonID = "A TMDB.Show by the given `seasonID` was not found"; + + internal const string ShowNotFoundByOrderingID = "A TMDB.Show by the given `orderingID` was not found"; + + internal const string ShowNotFoundByEpisodeID = "A TMDB.Show by the given `episodeID` was not found"; + + #endregion + + #region Basics + + /// + /// List all locally available tmdb shows. + /// + /// + /// + /// + /// + /// + /// + /// + /// + [HttpGet("Show")] + public ActionResult> GetTmdbShows( + [FromQuery] string? search = null, + [FromQuery] bool fuzzy = true, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null, + [FromQuery] IncludeOnlyFilter restricted = IncludeOnlyFilter.True, + [FromQuery, Range(0, 1000)] int pageSize = 50, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var hasSearch = !string.IsNullOrWhiteSpace(search); + var shows = RepoFactory.TMDB_Show.GetAll() + .AsParallel() + .Where(show => + { + if (restricted != IncludeOnlyFilter.True) + { + var includeRestricted = restricted == IncludeOnlyFilter.Only; + var isRestricted = show.IsRestricted; + if (isRestricted != includeRestricted) + return false; + } + + return true; + }); + if (hasSearch) + { + var languages = SettingsProvider.GetSettings() + .Language.DescriptionLanguageOrder + .Select(lang => lang.GetTitleLanguage()) + .Concat(new TitleLanguage[] { TitleLanguage.English }) + .ToHashSet(); + return shows + .Search( + search, + show => show.GetAllTitles() + .WhereInLanguages(languages) + .Select(title => title.Value) + .Append(show.EnglishTitle) + .Append(show.OriginalTitle) + .Distinct() + .ToList(), + fuzzy + ) + .ToListResult(a => new TmdbShow(a.Result, include?.CombineFlags(), language), page, pageSize); + } + + return shows + .OrderBy(show => show.EnglishTitle) + .ThenBy(show => show.TmdbShowID) + .ToListResult(m => new TmdbShow(m, include?.CombineFlags()), page, pageSize); + } + + [HttpPost("Show/Bulk")] + public ActionResult> BulkGetTmdbShowsByShowIDs([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbBulkFetchBody body) => + body.IDs + .Select(episodeID => episodeID <= 0 ? null : RepoFactory.TMDB_Show.GetByTmdbShowID(episodeID)) + .WhereNotNull() + .Select(episode => new TmdbShow(episode, body.Include?.CombineFlags(), body.Language)) + .ToList(); + + /// + /// Get the local metadata for a TMDB show. + /// + /// + [HttpGet("Show/{showID}")] + public ActionResult GetTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrdering = RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(alternateOrderingID); + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + return new TmdbShow(show, alternateOrdering, include?.CombineFlags(), language); + } + + if (alternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + return new TmdbShow(show, include?.CombineFlags()); + } + + /// + /// Remove the local copy of the metadata for a TMDB show. + /// + /// TMDB Movie ID. + /// Also remove images related to the show. + /// + [Authorize("admin")] + [HttpDelete("Show/{showID}")] + public async Task RemoveTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery] bool removeImageFiles = true + ) + { + await _tmdbMetadataService.SchedulePurgeOfShow(showID, removeImageFiles); + + return NoContent(); + } + + [HttpGet("Show/{showID}/Titles")] + public ActionResult> GetTitlesForTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + var preferredTitle = show.GetPreferredTitle(); + return new(show.GetAllTitles().ToDto(show.EnglishTitle, preferredTitle, language)); + } + + [HttpGet("Show/{showID}/Overviews")] + public ActionResult> GetOverviewsForTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + var preferredOverview = show.GetPreferredOverview(); + return new(show.GetAllOverviews().ToDto(show.EnglishOverview, preferredOverview, language)); + } + + [HttpGet("Show/{showID}/Images")] + public ActionResult GetImagesForTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.GetImages() + .ToDto(language); + } + + [HttpGet("Show/{showID}/Ordering")] + public ActionResult> GetOrderingForTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID) && alternateOrderingID.Length != SeasonIdHexLength && alternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + var alternateOrdering = !string.IsNullOrWhiteSpace(alternateOrderingID) ? RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(alternateOrderingID) : null; + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + var ordering = new List + { + new(show, alternateOrdering), + }; + foreach (var altOrder in show.TmdbAlternateOrdering) + ordering.Add(new(show, altOrder, alternateOrdering)); + return ordering + .OrderByDescending(o => o.InUse) + .ThenByDescending(o => string.IsNullOrEmpty(o.OrderingID)) + .ThenBy(o => o.OrderingName) + .ToList(); + } + + [HttpPost("Show/{showID}/Ordering/SetPreferred")] + public ActionResult SetPreferredTmdbShowOrdering( + [FromRoute] int showID, + [FromBody] TmdbSetPreferredOrderingBody body + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (!string.IsNullOrWhiteSpace(body.AlternateOrderingID) && body.AlternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrdering = RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(body.AlternateOrderingID); + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid Alternate Ordering ID for show.", nameof(body.AlternateOrderingID)); + + show.PreferredAlternateOrderingID = body.AlternateOrderingID; + } + else + { + if (string.IsNullOrWhiteSpace(body.AlternateOrderingID) && body.AlternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid Alternate Ordering ID for show.", nameof(body.AlternateOrderingID)); + + show.PreferredAlternateOrderingID = null; + } + + RepoFactory.TMDB_Show.Save(show); + return Ok(); + } + + [HttpGet("Show/{showID}/CrossReferences")] + public ActionResult> GetCrossReferencesForTmdbShowByShowID( + [FromRoute] int showID + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.CrossReferences + .Select(xref => new TmdbShow.CrossReference(xref)) + .OrderBy(xref => xref.AnidbAnimeID) + .ToList(); + } + + [HttpGet("Show/{showID}/Cast")] + public ActionResult> GetCastForTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrdering = RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(alternateOrderingID); + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + return alternateOrdering.Cast + .Select(cast => new Role(cast)) + .ToList(); + } + + if (alternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + return show.Cast + .Select(cast => new Role(cast)) + .ToList(); + } + + [HttpGet("Show/{showID}/Crew")] + public ActionResult> GetCrewForTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrdering = RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(alternateOrderingID); + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + return alternateOrdering.Crew + .Select(cast => new Role(cast)) + .ToList(); + } + + if (alternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + return show.Crew + .Select(cast => new Role(cast)) + .ToList(); + } + + [HttpGet("Show/{showID}/Studios")] + public ActionResult> GetStudiosForTmdbShowByShowID( + [FromRoute] int showID + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.TmdbCompanies + .Select(company => new Studio(company)) + .ToList(); + } + + [HttpGet("Show/{showID}/Networks")] + public ActionResult> GetNetworksForTmdbShowByShowID( + [FromRoute] int showID + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.TmdbNetworks + .Select(network => new Network(network)) + .ToList(); + } + + [HttpGet("Show/{showID}/ContentRatings")] + public ActionResult> GetContentRatingsForTmdbShowByShowID( + [FromRoute] int showID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return new(show.ContentRatings.ToDto(language)); + } + + #endregion + + #region Same-Source Linked Entries + + [HttpGet("Show/{showID}/Season")] + public ActionResult> GetTmdbSeasonsByTmdbShowID( + [FromRoute] int showID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null, + [FromQuery, Range(0, 100)] int pageSize = 25, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrdering = RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(alternateOrderingID); + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + return alternateOrdering.TmdbAlternateOrderingSeasons + .ToListResult(season => new TmdbSeason(season, include?.CombineFlags()), page, pageSize); + } + + if (alternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + return show.TmdbSeasons + .ToListResult(season => new TmdbSeason(season, include?.CombineFlags(), language), page, pageSize); + } + + + [GeneratedRegex(@"^\s*(?=[SsEe])(?:(?[Ss]pecial(?:s|\s*(?\d+))?)|(?:[Ss](?\d+))?((?=[Ee])\s+)?(?:[Ee](?\d+))?)", RegexOptions.Compiled)] + private static partial Regex SeasonEpisodeRegex(); + + /// + /// Get the episodes for a TMDB show. + /// + /// The ID of the show. + /// The optional details to include in the response. + /// The optional language to use for the episode titles. + /// The optional ID of an alternate ordering. + /// The number of entries to return per page. + /// The page of entries to return. + /// The optional search string to filter the results by. + /// Whether or not to search fuzzily. + /// The list of episodes. + [HttpGet("Show/{showID}/Episode")] + public ActionResult> GetTmdbEpisodesByTmdbShowID( + [FromRoute] int showID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null, + [FromQuery, Range(0, 1000)] int pageSize = 100, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery] string? search = null, + [FromQuery] bool fuzzy = false + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + int? seasonNumber = null; + int? episodeNumber = null; + if (!string.IsNullOrWhiteSpace(search)) + { + var match = SeasonEpisodeRegex().Match(search); + if (match.Success) + { + if (match.Groups["isSpecial"].Success) + { + seasonNumber = 0; + if (match.Groups["specialNumber"].Success) + episodeNumber = int.Parse(match.Groups["specialNumber"].Value); + } + else + { + if (match.Groups["seasonNumber"].Success) + seasonNumber = int.Parse(match.Groups["seasonNumber"].Value); + if (match.Groups["episodeNumber"].Success) + episodeNumber = int.Parse(match.Groups["episodeNumber"].Value); + } + search = search[match.Length..].Trim(); + } + } + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrdering = RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(alternateOrderingID); + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + var altEpisodes = alternateOrdering.TmdbAlternateOrderingEpisodes + .Select(ordering => (ordering, episode: ordering.TmdbEpisode)) + .Where(tuple => tuple.episode is not null) + .OfType<(TMDB_AlternateOrdering_Episode ordering, TMDB_Episode episode)>(); + if (seasonNumber is not null && episodeNumber is not null) + altEpisodes = altEpisodes.Where(t => t.episode.SeasonNumber == seasonNumber && t.episode.EpisodeNumber == episodeNumber); + else if (seasonNumber is not null) + altEpisodes = altEpisodes.Where(t => t.episode.SeasonNumber == seasonNumber); + else if (episodeNumber is not null) + altEpisodes = altEpisodes.Where(t => t.episode.EpisodeNumber == episodeNumber); + if (!string.IsNullOrWhiteSpace(search)) + altEpisodes = altEpisodes.Search(search, t => t.episode.GetAllPreferredTitles().Select(t => t.Value), fuzzy).Select(r => r.Result); + return altEpisodes + .ToListResult(t => new TmdbEpisode(show, t.episode, t.ordering, include?.CombineFlags(), language), page, pageSize); + } + + if (alternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + IEnumerable episodes = show.TmdbEpisodes; + if (seasonNumber is not null && episodeNumber is not null) + episodes = episodes.Where(e => e.SeasonNumber == seasonNumber && e.EpisodeNumber == episodeNumber); + else if (seasonNumber is not null) + episodes = episodes.Where(e => e.SeasonNumber == seasonNumber); + else if (episodeNumber is not null) + episodes = episodes.Where(e => e.EpisodeNumber == episodeNumber); + if (!string.IsNullOrWhiteSpace(search)) + episodes = episodes.Search(search, ep => ep.GetAllPreferredTitles().Select(t => t.Value), fuzzy).Select(r => r.Result); + return episodes.ToListResult(e => new TmdbEpisode(show, e, include?.CombineFlags(), language), page, pageSize); + } + + /// + /// Get all episode cross-references for the specified TMDB show. + /// + /// The TMDB show ID. + /// The page size. + /// The page index. + /// The list of episode cross-references. + [HttpGet("Show/{showID}/Episode/CrossReferences")] + public ActionResult> GetTmdbEpisodeCrossReferencesByTmdbShowID( + [FromRoute] int showID, + [FromQuery, Range(0, 1000)] int pageSize = 100, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.EpisodeCrossReferences + .ToListResult(x => new TmdbEpisode.CrossReference(x), page, pageSize); + } + + /// + /// Shows all existing episode cross-references for a TMDB Show grouped by + /// their corresponding cross-reference groups. + /// + /// The TMDB Show ID. + /// The page size. + /// The page index. + /// The list of grouped episode cross-references. + [HttpGet("Show/{showID}/Episode/CrossReferences/EpisodeGroups")] + public ActionResult>> GetGroupedTmdbEpisodeCrossReferencesByTmdbShowID( + [FromRoute] int showID, + [FromQuery, Range(0, 1000)] int pageSize = 100, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.EpisodeCrossReferences + .GroupByCrossReferenceType() + .ToListResult(list => list.Select((xref, index) => new TmdbEpisode.CrossReference(xref, index)).ToList(), page, pageSize); + } + + #endregion + + #region Cross-Source Linked Entries + + /// + /// Get all AniDB series linked to a TMDB show. + /// + /// TMDB Show ID. + /// + [HttpGet("Show/{showID}/AniDB/Anime")] + public ActionResult> GetAnidbAnimeByTmdbShowID( + [FromRoute] int showID + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.CrossReferences + .Select(xref => xref.AnidbAnime) + .WhereNotNull() + .Select(anime => new Series.AniDB(anime)) + .ToList(); + } + + /// + /// Get all Shoko series linked to a TMDB show. + /// + /// TMDB Show ID. + /// Randomize images shown for the . + /// Include data from selected s. + /// + [HttpGet("Show/{showID}/Shoko/Series")] + public ActionResult> GetShokoSeriesByTmdbShowID( + [FromRoute] int showID, + [FromQuery] bool randomImages = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + return show.CrossReferences + .Select(xref => xref.AnimeSeries) + .WhereNotNull() + .Select(series => new Series(series, User.JMMUserID, randomImages, includeDataFrom)) + .ToList(); + } + + #endregion + + #region Actions + + /// + /// Refresh or download the metadata for a TMDB show. + /// + /// TMDB Show ID. + /// Body containing options for refreshing or downloading metadata. + /// + [Authorize("admin")] + [HttpPost("Show/{showID}/Action/Refresh")] + public async Task RefreshTmdbShowByShowID( + [FromRoute] int showID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbRefreshShowBody body + ) + { + if (body.Immediate) + { + // If we want quick results, we're already running an update, and we already have episodes to use, then just return early. + if (body.QuickRefresh && _tmdbMetadataService.IsShowUpdating(showID) && RepoFactory.TMDB_Episode.GetByTmdbShowID(showID).Count > 0) + return Ok(); + + var settings = SettingsProvider.GetSettings(); + await _tmdbMetadataService.UpdateShow(showID, !body.QuickRefresh && body.Force, body.DownloadImages, body.DownloadCrewAndCast ?? settings.TMDB.AutoDownloadCrewAndCast, body.DownloadAlternateOrdering ?? settings.TMDB.AutoDownloadAlternateOrdering, body.QuickRefresh); + return Ok(); + } + + await _tmdbMetadataService.ScheduleUpdateOfShow(showID, body.Force, body.DownloadImages, body.DownloadCrewAndCast, body.DownloadAlternateOrdering); + return NoContent(); + } + + /// + /// Download images for a TMDB show. + /// + /// TMDB Show ID. + /// Body containing options for downloading images. + /// + /// If is , returns an , + /// otherwise returns a . + /// + [Authorize("admin")] + [HttpPost("Show/{showID}/Action/DownloadImages")] + public async Task DownloadImagesForTmdbShowByShowID( + [FromRoute] int showID, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbDownloadImagesBody body + ) + { + var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showID); + if (show is null) + return NotFound(ShowNotFound); + + if (body.Immediate) + { + await _tmdbMetadataService.DownloadAllShowImages(showID, body.Force); + return Ok(); + } + + await _tmdbMetadataService.ScheduleDownloadAllShowImages(showID, body.Force); + return NoContent(); + } + + #endregion + + #region Online (Search / Bulk / Single) + + /// + /// Search TMDB for shows using the online search. + /// + /// Query to search for. + /// Include restricted shows. + /// First aired year. + /// The page size. Set to 0 to only grab the total. + /// The page index. + /// + [Authorize("admin")] + [HttpGet("Show/Online/Search")] + public ListResult SearchOnlineForTmdbShows( + [FromQuery] string query, + [FromQuery] bool includeRestricted = false, + [FromQuery, Range(0, int.MaxValue)] int year = 0, + [FromQuery, Range(0, 100)] int pageSize = 6, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + var (pageView, totalShows) = _tmdbSearchService.SearchShows(query, includeRestricted, year, page, pageSize) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + return new ListResult(totalShows, pageView.Select(a => new TmdbSearch.RemoteSearchShow(a))); + } + + /// + /// Search for multiple TMDB shows by their IDs. + /// + /// + /// If any of the IDs are not found, a is returned. + /// + /// Body containing the IDs of the shows to search for. + /// + /// A list of containing the search results. + /// The order of the returned shows is determined by the order of the IDs in . + /// + [HttpPost("Show/Online/Bulk")] + public async Task>> SearchBulkForTmdbShows( + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbBulkSearchBody body + ) + { + // We don't care if the inputs are non-unique, but we don't want to double fetch, + // so we do a distinct here, then at the end we map back to the original order. + var uniqueIds = body.IDs.Distinct().ToList(); + var showDict = uniqueIds + .Select(id => id <= 0 ? null : RepoFactory.TMDB_Show.GetByTmdbShowID(id)) + .WhereNotNull() + .Select(show => new TmdbSearch.RemoteSearchShow(show)) + .ToDictionary(show => show.ID); + foreach (var id in uniqueIds.Except(showDict.Keys)) + { + var show = id <= 0 ? null : await _tmdbMetadataService.UseClient(c => c.GetTvShowAsync(id), $"Get show {id}"); + if (show is null) + continue; + + showDict[show.Id] = new TmdbSearch.RemoteSearchShow(show); + } + + var unknownShows = uniqueIds.Except(showDict.Keys).ToList(); + if (unknownShows.Count > 0) + { + foreach (var id in unknownShows) + ModelState.AddModelError(nameof(body.IDs), $"Show with id '{id}' not found."); + + return ValidationProblem(ModelState); + } + + return body.IDs + .Select(id => showDict[id]) + .ToList(); + } + + /// + /// Search TMDB for a show. + /// + /// TMDB Show ID. + /// + /// If the show is already in the database, returns the local copy. + /// Otherwise, returns the remote copy from TMDB. + /// If the show is not found on TMDB, returns 404. + /// + [HttpGet("Show/Online/{showID}")] + public async Task> SearchOnlineForTmdbShowByShowID( + [FromRoute] int showID + ) + { + if (RepoFactory.TMDB_Show.GetByTmdbShowID(showID) is { } localShow) + return new TmdbSearch.RemoteSearchShow(localShow); + + if (await _tmdbMetadataService.UseClient(c => c.GetTvShowAsync(showID), $"Get show {showID}") is not { } remoteShow) + return NotFound("Show not found on TMDB."); + + return new TmdbSearch.RemoteSearchShow(remoteShow); + } + + #endregion + + #endregion + + #region Seasons + + #region Constants + + internal const int SeasonIdHexLength = 24; + + internal const string SeasonIdRegex = @"^(?:[0-9]{1,23}|[a-f0-9]{24})$"; + + internal const string SeasonNotFound = "A TMDB.Season by the given `seasonID` was not found."; + + internal const string SeasonNotFoundByEpisodeID = "A TMDB.Season by the given `episodeID` was not found."; + + #endregion + + #region Basics + + [HttpGet("Season/{seasonID}")] + public ActionResult GetTmdbSeasonBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return new TmdbSeason(altOrderSeason, include?.CombineFlags()); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + return new TmdbSeason(season, include?.CombineFlags(), language); + } + + [HttpGet("Season/{seasonID}/Titles")] + public ActionResult> GetTitlesForTmdbSeasonBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return new List(); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + var preferredTitle = season.GetPreferredTitle(); + return new(season.GetAllTitles().ToDto(season.EnglishTitle, preferredTitle, language)); + } + + [HttpGet("Season/{seasonID}/Overviews")] + public ActionResult<IReadOnlyList<Overview>> GetOverviewsForTmdbSeasonBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return new List<Overview>(); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + var preferredOverview = season.GetPreferredOverview(); + return new(season.GetAllOverviews().ToDto(season.EnglishOverview, preferredOverview, language)); + } + + [HttpGet("Season/{seasonID}/Images")] + public ActionResult<Images> GetImagesForTmdbSeasonBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return new Images(); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + return season.GetImages().ToDto(language); + } + + [HttpGet("Season/{seasonID}/Cast")] + public ActionResult<IReadOnlyList<Role>> GetCastForTmdbSeasonBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return altOrderSeason.Cast + .Select(cast => new Role(cast)) + .ToList(); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + return season.Cast + .Select(cast => new Role(cast)) + .ToList(); + } + + [HttpGet("Season/{seasonID}/Crew")] + public ActionResult<IReadOnlyList<Role>> GetCrewForTmdbSeasonBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return altOrderSeason.Crew + .Select(crew => new Role(crew)) + .ToList(); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + return season.Crew + .Select(crew => new Role(crew)) + .ToList(); + } + + #endregion + + #region Same-Source Linked Entries + + [HttpGet("Season/{seasonID}/Show")] + public ActionResult<TmdbShow> GetTmdbShowBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TmdbShow.IncludeDetails>? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + var altOrder = altOrderSeason.TmdbAlternateOrdering; + var altShow = altOrder?.TmdbShow; + if (altShow is null) + return NotFound(ShowNotFoundBySeasonID); + + return new TmdbShow(altShow, altOrder, include?.CombineFlags(), language); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + var show = season.TmdbShow; + if (show is null) + return NotFound(ShowNotFoundBySeasonID); + + return new TmdbShow(show, include?.CombineFlags(), language); + } + + [HttpGet("Season/{seasonID}/Episode")] + public ActionResult<ListResult<TmdbEpisode>> GetTmdbEpisodesBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TmdbEpisode.IncludeDetails>? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null, + [FromQuery, Range(0, 1000)] int pageSize = 100, + [FromQuery, Range(1, int.MaxValue)] int page = 1 + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + var altShow = altOrderSeason.TmdbShow; + if (altShow is null) + return NotFound(ShowNotFoundBySeasonID); + + return altOrderSeason.TmdbAlternateOrderingEpisodes + .ToListResult(e => new TmdbEpisode(altShow, e.TmdbEpisode!, e, include?.CombineFlags(), language), page, pageSize); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + var show = season.TmdbShow; + if (show is null) + return NotFound(ShowNotFoundBySeasonID); + + return season.TmdbEpisodes + .ToListResult(e => new TmdbEpisode(show, e, include?.CombineFlags(), language), page, pageSize); + } + + #endregion + + #region Cross-Source Linked Entries + + [HttpGet("Season/{seasonID}/AniDB/Anime")] + public ActionResult<List<Series.AniDB>> GetAniDBAnimeBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return new List<Series.AniDB>(); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + return season.TmdbEpisodes + .SelectMany(episode => episode.CrossReferences) + .DistinctBy(xref => xref.AnidbAnimeID) + .Select(xref => xref.AnidbAnime) + .WhereNotNull() + .Select(anime => new Series.AniDB(anime)) + .ToList(); + } + + [HttpGet("Season/{seasonID}/Shoko/Series")] + public ActionResult<List<Series>> GetShokoSeriesBySeasonID( + [FromRoute, RegularExpression(SeasonIdRegex)] string seasonID, + [FromQuery] bool randomImages = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource>? includeDataFrom = null + ) + { + if (seasonID.Length == SeasonIdHexLength) + { + var altOrderSeason = RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(seasonID); + if (altOrderSeason is null) + return NotFound(SeasonNotFound); + + return new List<Series>(); + } + + var seasonId = int.Parse(seasonID); + var season = RepoFactory.TMDB_Season.GetByTmdbSeasonID(seasonId); + if (season is null) + return NotFound(SeasonNotFound); + + return season.TmdbEpisodes + .SelectMany(episode => episode.CrossReferences) + .DistinctBy(xref => xref.AnidbAnimeID) + .Select(xref => xref.AnimeSeries) + .WhereNotNull() + .Select(series => new Series(series, User.JMMUserID, randomImages, includeDataFrom)) + .ToList(); + } + + #endregion + + #endregion + + #region Episodes + + #region Constants + + internal const string EpisodeNotFound = "A TMDB.Episode by the given `episodeID` was not found."; + + #endregion + + #region Basics + + [HttpPost("Episode/Bulk")] + public ActionResult<List<TmdbEpisode>> BulkGetTmdbEpisodesByEpisodeIDs([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] TmdbBulkFetchBody<TmdbEpisode.IncludeDetails> body) => + body.IDs + .Select(episodeID => episodeID <= 0 ? null : RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID)) + .WhereNotNull() + .Select(episode => new TmdbEpisode(episode.TmdbShow ?? throw new Exception(ShowNotFoundByEpisodeID), episode, body.Include?.CombineFlags(), body.Language)) + .ToList(); + + [HttpGet("Episode/{episodeID}")] + public ActionResult<TmdbEpisode> GetTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TmdbEpisode.IncludeDetails>? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + var show = episode.TmdbShow; + if (show is null) + return NotFound(ShowNotFoundByEpisodeID); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrderingEpisode = RepoFactory.TMDB_AlternateOrdering_Episode.GetByEpisodeGroupCollectionAndEpisodeIDs(alternateOrderingID, episodeID); + if (alternateOrderingEpisode is null) + return ValidationProblem("Invalid alternateOrderingID for episode.", "alternateOrderingID"); + + return new TmdbEpisode(show, episode, alternateOrderingEpisode, include?.CombineFlags(), language); + } + + if (alternateOrderingID != episode.TmdbShowID.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + return new TmdbEpisode(show, episode, include?.CombineFlags(), language); + } + + [HttpGet("Episode/{episodeID}/Titles")] + public ActionResult<IReadOnlyList<Title>> GetTitlesForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + var preferredTitle = episode.GetPreferredTitle(); + return new(episode.GetAllTitles().ToDto(episode.EnglishTitle, preferredTitle, language)); + } + + [HttpGet("Episode/{episodeID}/Overviews")] + public ActionResult<IReadOnlyList<Overview>> GetOverviewsForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + var preferredOverview = episode.GetPreferredOverview(); + return new(episode.GetAllOverviews().ToDto(episode.EnglishTitle, preferredOverview, language)); + } + + [HttpGet("Episode/{episodeID}/Ordering")] + public ActionResult<IReadOnlyList<TmdbEpisode.OrderingInformation>> GetOrderingForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + var show = episode.TmdbShow; + if (show is null) + return NotFound(ShowNotFoundByEpisodeID); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID) && alternateOrderingID.Length != SeasonIdHexLength && alternateOrderingID != show.Id.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + var alternateOrderingEpisode = !string.IsNullOrWhiteSpace(alternateOrderingID) + ? RepoFactory.TMDB_AlternateOrdering_Episode.GetByEpisodeGroupCollectionAndEpisodeIDs(alternateOrderingID, episodeID) : null; + if (!string.IsNullOrWhiteSpace(alternateOrderingID) && alternateOrderingEpisode is null) + return ValidationProblem("Invalid alternateOrderingID for episode.", "alternateOrderingID"); + + var ordering = new List<TmdbEpisode.OrderingInformation> + { + new(show, episode, alternateOrderingEpisode), + }; + foreach (var altOrderEp in episode.TmdbAlternateOrderingEpisodes) + ordering.Add(new(show, altOrderEp, alternateOrderingEpisode)); + + return ordering + .OrderByDescending(o => o.InUse) + .ThenByDescending(o => string.IsNullOrEmpty(o.OrderingID)) + .ThenBy(o => o.OrderingName) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/Images")] + public ActionResult<IReadOnlyList<Image>> GetImagesForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.GetImages() + .InLanguage(language) + .Select(image => new Image(image)) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/Cast")] + public ActionResult<IReadOnlyList<Role>> GetCastForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.Cast + .Select(cast => new Role(cast)) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/Crew")] + public ActionResult<IReadOnlyList<Role>> GetCrewForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.Crew + .Select(cast => new Role(cast)) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/CrossReferences")] + public ActionResult<IReadOnlyList<TmdbEpisode.CrossReference>> GetCrossReferencesForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.CrossReferences + .Select(xref => new TmdbEpisode.CrossReference(xref)) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/FileCrossReferences")] + public ActionResult<IReadOnlyList<FileCrossReference>> GetFileCrossReferencesForTmdbEpisodeByEpisodeID( + [FromRoute] int episodeID + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return FileCrossReference.From(episode.FileCrossReferences); + } + + #endregion + + #region Same-Source Linked Entries + + [HttpGet("Episode/{episodeID}/Show")] + public ActionResult<TmdbShow> GetTmdbShowByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TmdbShow.IncludeDetails>? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + var show = episode.TmdbShow; + if (show is null) + return NotFound(ShowNotFoundByEpisodeID); + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrdering = RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(alternateOrderingID); + if (alternateOrdering is null || alternateOrdering.TmdbShowID != show.TmdbShowID) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + + return new TmdbShow(show, alternateOrdering, include?.CombineFlags(), language); + } + + if (alternateOrderingID != episode.TmdbShowID.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + return new TmdbShow(show, include?.CombineFlags(), language); + } + + [HttpGet("Episode/{episodeID}/Season")] + public ActionResult<TmdbSeason> GetTmdbSeasonByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TmdbSeason.IncludeDetails>? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<TitleLanguage>? language = null, + [FromQuery, RegularExpression(AlternateOrderingIdRegex)] string? alternateOrderingID = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + var show = episode.TmdbShow; + if (show is null) + return NotFound(ShowNotFoundByEpisodeID); + + if (string.IsNullOrEmpty(alternateOrderingID) && !string.IsNullOrWhiteSpace(show.PreferredAlternateOrderingID)) + alternateOrderingID = show.PreferredAlternateOrderingID; + + if (!string.IsNullOrWhiteSpace(alternateOrderingID)) + { + if (alternateOrderingID.Length == SeasonIdHexLength) + { + var alternateOrderingEpisode = RepoFactory.TMDB_AlternateOrdering_Episode.GetByEpisodeGroupCollectionAndEpisodeIDs(alternateOrderingID, episodeID); + var altOrderSeason = alternateOrderingEpisode?.TmdbAlternateOrderingSeason; + if (altOrderSeason is null) + return NotFound(SeasonNotFoundByEpisodeID); + + return new TmdbSeason(altOrderSeason, include?.CombineFlags()); + } + + if (alternateOrderingID != episode.TmdbShowID.ToString()) + return ValidationProblem("Invalid alternateOrderingID for show.", "alternateOrderingID"); + } + + var season = episode.TmdbSeason; + if (season is null) + return NotFound(SeasonNotFoundByEpisodeID); + + return new TmdbSeason(season, include?.CombineFlags(), language); + } + + #endregion + + #region Cross-Source Linked Entries + + [HttpGet("Episode/{episodeID}/AniDB/Anime")] + public ActionResult<List<Series.AniDB>> GetAniDBAnimeByEpisodeID( + [FromRoute] int episodeID + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.CrossReferences + .DistinctBy(xref => xref.AnidbAnimeID) + .Select(xref => xref.AnidbAnime) + .WhereNotNull() + .Select(anime => new Series.AniDB(anime)) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/Anidb/Episode")] + public ActionResult<List<Episode.AniDB>> GetAniDBEpisodeByEpisodeID( + [FromRoute] int episodeID + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.CrossReferences + .DistinctBy(xref => xref.AnidbAnimeID) + .Select(xref => xref.AnidbEpisode) + .WhereNotNull() + .Select(anidbEpisode => new Episode.AniDB(anidbEpisode)) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/Shoko/Series")] + public ActionResult<List<Series>> GetShokoSeriesByEpisodeID( + [FromRoute] int episodeID, + [FromQuery] bool randomImages = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource>? includeDataFrom = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.CrossReferences + .DistinctBy(xref => xref.AnidbAnimeID) + .Select(xref => xref.AnimeSeries) + .WhereNotNull() + .Select(shokoSeries => new Series(shokoSeries, User.JMMUserID, randomImages, includeDataFrom)) + .ToList(); + } + + [HttpGet("Episode/{episodeID}/Shoko/Episode")] + public ActionResult<List<Episode>> GetShokoEpisodesByEpisodeID( + [FromRoute] int episodeID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource>? includeDataFrom = null + ) + { + var episode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(episodeID); + if (episode is null) + return NotFound(EpisodeNotFound); + + return episode.CrossReferences + .DistinctBy(xref => xref.AnidbEpisodeID) + .Select(xref => xref.AnimeEpisode) + .WhereNotNull() + .Select(shokoEpisode => new Episode(HttpContext, shokoEpisode, includeDataFrom)) + .ToList(); + } + + #endregion + + #endregion + + #region Export / Import + + [Flags] + [JsonConverter(typeof(StringEnumConverter))] + public enum CrossReferenceExportType + { + None = 0, + Movie = 1, + Show = 2, + } + + private const string MovieCrossReferenceWithIdHeader = "AnidbAnimeId,AnidbEpisodeId,TmdbMovieId,IsAutomatic"; + + private const string EpisodeCrossReferenceWithIdHeader = "AnidbAnimeId,AnidbEpisodeType,AnidbEpisodeId,TmdbShowId,TmdbEpisodeId,Rating"; + + private string MapAnimeType(AnimeType? type) => + type switch + { + AnimeType.Movie => "MV", + AnimeType.OVA => "VA", + AnimeType.TVSeries => "TV", + AnimeType.TVSpecial => "SP", + AnimeType.Web => "WB", + AnimeType.Other => "OT", + _ => "??", + }; + + /// <summary> + /// Export all or selected AniDB/TMDB cross-references in the specified sections. + /// </summary> + /// <param name="body">Optional. Export options.</param> + /// <returns></returns> + [HttpPost("Export")] + public ActionResult ExportCrossReferences( + [FromBody] TmdbExportBody? body + ) + { + body ??= new(); + var sections = body.SectionSet?.CombineFlags() ?? default; + var stringBuilder = new StringBuilder(); + if (sections.HasFlag(CrossReferenceExportType.Movie)) + { + var crossReferences = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetAll() + .Where(xref => + { + if (body.Automatic != IncludeOnlyFilter.True) + { + var includeAutomatic = body.Automatic == IncludeOnlyFilter.Only; + var isAutomatic = xref.Source == CrossRefSource.Automatic; + if (isAutomatic != includeAutomatic) + return false; + } + return body.ShouldKeep(xref); + }) + .OrderBy(xref => xref.AnidbAnimeID) + .ThenBy(xref => xref.AnidbEpisodeID) + .ThenBy(xref => xref.TmdbMovieID) + .SelectMany(xref => + { + var entry = $"{xref.AnidbAnimeID},{xref.AnidbEpisodeID},{xref.TmdbMovieID},{xref.Source == CrossRefSource.Automatic}"; + if (!body.IncludeComments) + return new string[1] { entry }; + + var anime = xref.AnidbAnime; + var animeTitle = anime?.MainTitle ?? "<missing title>"; + var movie = xref.TmdbMovie; + var movieTitle = movie?.EnglishTitle ?? "<missing title>"; + var episodeNumber = "---"; + var anidbEpisode = xref.AnidbEpisode; + if (anidbEpisode is null) + episodeNumber = "???"; + else if (anidbEpisode.EpisodeType == (int)InternalEpisodeType.Episode) + episodeNumber = anidbEpisode.EpisodeNumber.ToString().PadLeft(3, '0'); + else + episodeNumber = $"{((InternalEpisodeType)anidbEpisode.EpisodeType).ToString()[0]}{anidbEpisode.EpisodeNumber.ToString().PadLeft(2, '0')}"; + episodeNumber += $" (e{xref.AnidbEpisodeID})"; + var episodeTitle = anidbEpisode?.DefaultTitle is { AniDB_Episode_TitleID: > 0 } defaultTile ? defaultTile.Title : "<missing title>"; + return + [ + "", + $"# AniDB: {MapAnimeType((AnimeType?)anime?.AnimeType)} ``{animeTitle}`` (a{xref.AnidbAnimeID}) {episodeNumber} ``{episodeTitle}`` (e{xref.AnidbEpisodeID}) → TMDB: ``{movieTitle}`` (m{xref.TmdbMovieID})", + entry, + ]; + }) + .ToList(); + if (crossReferences.Count > 0) + { + if (body.IncludeComments) + stringBuilder.AppendLine("#".PadRight(MovieCrossReferenceWithIdHeader.Length, '-')) + .AppendLine("# AniDB/TMDB Movie Cross-References"); + stringBuilder.AppendLine(MovieCrossReferenceWithIdHeader); + if (body.IncludeComments) + stringBuilder.AppendLine("#".PadRight(MovieCrossReferenceWithIdHeader.Length, '-')) + .AppendLine(); + foreach (var line in crossReferences) + stringBuilder.AppendLine(line); + } + } + + if (body.IncludeComments && sections.HasFlag(CrossReferenceExportType.Movie) && sections.HasFlag(CrossReferenceExportType.Show)) + stringBuilder + .AppendLine() + .AppendLine(); + + if (sections.HasFlag(CrossReferenceExportType.Show)) + { + var crossReferences = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetAll() + .Where(xref => + { + if (body.Automatic != IncludeOnlyFilter.True) + { + var includeAutomatic = body.Automatic == IncludeOnlyFilter.Only; + var isAutomatic = xref.MatchRating != MatchRating.UserVerified; + if (isAutomatic != includeAutomatic) + return false; + } + if (body.WithEpisodes != IncludeOnlyFilter.True) + { + var includeWithEpisode = body.WithEpisodes == IncludeOnlyFilter.Only; + var hasEpisode = xref.TmdbEpisodeID != 0; + if (hasEpisode != includeWithEpisode) + return false; + } + return body.ShouldKeep(xref); + }) + .SelectMany(xref => + { + // NOTE: Internal easter eggs should stay internally. + var rating = xref.MatchRating is MatchRating.SarahJessicaParker ? "None" : xref.MatchRating.ToString(); + var entry = $"{xref.AnidbAnimeID},{xref.AnidbEpisodeID},{xref.TmdbShowID},{xref.TmdbEpisodeID},{rating}"; + if (!body.IncludeComments) + return new string[1] { entry }; + + var anidbAnime = xref.AnidbAnime; + var anidbAnimeTitle = anidbAnime?.MainTitle ?? "<missing title>"; + var anidbEpisode = xref.AnidbEpisode; + var anidbEpisodeNumber = "???"; + if (anidbEpisode is not null) + if (anidbEpisode.EpisodeTypeEnum == InternalEpisodeType.Episode) + anidbEpisodeNumber = anidbEpisode.EpisodeNumber.ToString().PadLeft(3, '0'); + else + anidbEpisodeNumber = $"{anidbEpisode.EpisodeTypeEnum.ToString()[0]}{anidbEpisode.EpisodeNumber.ToString().PadLeft(2, '0')}"; + var anidbEpisodeTitle = anidbEpisode?.DefaultTitle is { AniDB_Episode_TitleID: > 0 } defaultTile ? defaultTile.Title : "<missing title>"; + var tmdbShow = xref.TmdbShow; + var tmdbShowTitle = tmdbShow?.EnglishTitle ?? "<missing title>"; + var tmdbEpisode = xref.TmdbEpisode; + var tmdbEpisodeNumber = "??? ????"; + if (tmdbEpisode is not null) + tmdbEpisodeNumber = $"S{tmdbEpisode.SeasonNumber.ToString().PadLeft(2, '0')} E{tmdbEpisode.EpisodeNumber.ToString().PadLeft(3, '0')}"; + var tmdbEpisodeTitle = tmdbEpisode?.EnglishTitle ?? "<missing title>"; + return + [ + "", + $"# AniDB: {MapAnimeType((AnimeType?)anidbAnime?.AnimeType)} ``{anidbAnimeTitle}`` (a{xref.AnidbAnimeID}) {anidbEpisodeNumber} ``{anidbEpisodeTitle}`` (e{xref.AnidbEpisodeID}) → TMDB: ``{tmdbShowTitle}`` (s{xref.TmdbShowID}) {tmdbEpisodeNumber} ``{tmdbEpisodeTitle}`` (e{xref.TmdbEpisodeID})", + entry, + ]; + }) + .ToList(); + if (crossReferences.Count > 0) + { + if (body.IncludeComments) + stringBuilder.AppendLine("#".PadRight(EpisodeCrossReferenceWithIdHeader.Length, '-')) + .AppendLine("# AniDB/TMDB Show/Episode Cross-References"); + stringBuilder.AppendLine(EpisodeCrossReferenceWithIdHeader); + if (body.IncludeComments) + stringBuilder.AppendLine("#".PadRight(EpisodeCrossReferenceWithIdHeader.Length, '-')) + .AppendLine(); + foreach (var line in crossReferences) + stringBuilder.AppendLine(line); + } + } + + var bytes = Encoding.UTF8.GetBytes(stringBuilder.ToString()); + return File(bytes, "text/csv", "anidb_tmdb_xrefs.csv"); + } + + /// <summary> + /// Import a cross-reference CSV file in the same format we export. + /// </summary> + /// <remarks> + /// This will take care of creating/updating all cross-reference entries + /// for everything we can export, be it movie cross-references, episode + /// cross-references, or anything we might add in the future. If we can + /// export it then we can import it! + /// </remarks> + /// <param name="file">The CSV file to import.</param> + /// <returns>Void.</returns> + [HttpPost("Import")] + public async Task<ActionResult> ImportMovieCrossReferences( + IFormFile file + ) + { + if (file is null || file.Length == 0) + ModelState.AddModelError("Body", "Body cannot be empty."); + + var allowedTypes = new HashSet<string>() { "text/plain", "text/csv" }; + if (file is not null && !allowedTypes.Contains(file.ContentType)) + ModelState.AddModelError("Body", "Invalid content-type for endpoint."); + + if (file is not null && file.Name != "file") + ModelState.AddModelError("Body", "Invalid field name for import file"); + + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + using var stream = new StreamReader(file!.OpenReadStream(), Encoding.UTF8, true); + + string? line; + var lineNumber = 0; + var currentHeader = ""; + var movieIdXrefs = new List<(int anidbAnime, int anidbEpisode, int tmdbMovie, bool isAutomatic)>(); + var episodeIdXrefs = new List<(int anidbAnime, int anidbEpisode, int tmdbShow, int tmdbEpisode, MatchRating rating)>(); + while (!string.IsNullOrEmpty(line = stream.ReadLine())) + { + lineNumber++; + if (line.Length == 0 || line[0] == '#') + continue; + + switch (line) + { + case MovieCrossReferenceWithIdHeader: + case EpisodeCrossReferenceWithIdHeader: + currentHeader = line; + continue; + } + + if (string.IsNullOrEmpty(currentHeader) && ModelState.IsValid) + { + ModelState.AddModelError("Body", "Invalid or missing CSV header for import file."); + continue; + } + + switch (currentHeader) + { + default: + case "": + ModelState.AddModelError("Body", $"Unable to parse cross-reference at line {lineNumber}."); + break; + + case MovieCrossReferenceWithIdHeader: + { + var (animeId, episodeId, movieId, automatic) = line.Split(","); + if ( + !int.TryParse(animeId, out var anidbAnimeId) || anidbAnimeId <= 0 || + !int.TryParse(episodeId, out var anidbEpisodeId) || anidbEpisodeId <= 0 || + !int.TryParse(movieId, out var tmdbMovieId) || tmdbMovieId <= 0 || + !bool.TryParse(automatic, out var isAutomatic) + ) + { + ModelState.AddModelError("Body", $"Unable to parse cross-reference at line {lineNumber}."); + continue; + } + + movieIdXrefs.Add((anidbAnimeId, anidbEpisodeId, tmdbMovieId, isAutomatic)); + + break; + } + case EpisodeCrossReferenceWithIdHeader: + { + var (anime, anidbEpisode, show, tmdbEpisode, rating) = line.Split(","); + if ( + !int.TryParse(anime, out var anidbAnimeId) || anidbAnimeId <= 0 || + !int.TryParse(anidbEpisode, out var anidbEpisodeId) || anidbEpisodeId <= 0 || + !int.TryParse(show, out var tmdbShowId) || tmdbShowId < 0 || + !int.TryParse(tmdbEpisode, out var tmdbEpisodeId) || tmdbEpisodeId < 0 || + // NOTE: Internal easter eggs should stay internally. + !( + (Enum.TryParse<MatchRating>(rating, true, out var matchRating) && matchRating != MatchRating.SarahJessicaParker) || + (string.Equals(rating, "None", StringComparison.InvariantCultureIgnoreCase) && (matchRating = MatchRating.SarahJessicaParker) == matchRating) + ) + ) + { + ModelState.AddModelError("Body", $"Unable to parse cross-reference at line {lineNumber}."); + continue; + } + + episodeIdXrefs.Add((anidbAnimeId, anidbEpisodeId, tmdbShowId, tmdbEpisodeId, matchRating)); + break; + } + } + } + + if (ModelState.IsValid && movieIdXrefs.Count == 0) + ModelState.AddModelError("Body", "File contained no lines to import."); + + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + var moviesToPull = new HashSet<int>(); + var exitingMovieXrefs = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetAll() + .ToDictionary(xref => $"{xref.AnidbAnimeID}:{xref.AnidbEpisodeID}:{xref.TmdbMovieID}"); + var movieXrefsToAdd = 0; + var movieXrefsToSave = new List<CrossRef_AniDB_TMDB_Movie>(); + foreach (var (animeId, episodeId, movieId, isAutomatic) in movieIdXrefs) + { + var id = $"{animeId}:{episodeId}:{movieId}"; + var updated = false; + var isNew = false; + var source = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; + if (!exitingMovieXrefs.TryGetValue(id, out var xref)) + { + // Make sure an xref exists. + movieXrefsToAdd++; + updated = true; + isNew = true; + xref = new() + { + AnidbAnimeID = animeId, + AnidbEpisodeID = episodeId, + TmdbMovieID = movieId, + Source = source, + }; + } + + if (!isNew && xref.Source is not CrossRefSource.User && source is CrossRefSource.User) + { + xref.Source = source; + updated = true; + } + + if (updated) + movieXrefsToSave.Add(xref); + + var seriesExists = xref.AnimeSeries is not null; + var tmdbMovieExists = xref.TmdbMovie is not null; + if (seriesExists && !tmdbMovieExists) + moviesToPull.Add(xref.TmdbMovieID); + } + + var showsToPull = new HashSet<int>(); + var usedEpisodeIdsWithZeroSet = new HashSet<string>(); + var existingShowXrefs = RepoFactory.CrossRef_AniDB_TMDB_Show.GetAll() + .Select(xref => $"{xref.AnidbAnimeID}:{xref.TmdbShowID}") + .ToHashSet(); + var exitingEpisodeXrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetAll() + .ToDictionary(xref => $"{xref.AnidbAnimeID}:{xref.AnidbEpisodeID}:{xref.TmdbShowID}:{xref.TmdbEpisodeID}"); + var episodeXrefsToAdd = 0; + var episodeXrefsToSave = new List<CrossRef_AniDB_TMDB_Episode>(); + var showXrefsToSave = new Dictionary<string, CrossRef_AniDB_TMDB_Show>(); + foreach (var (animeId, anidbEpisodeId, showId, tmdbEpisodeId, matchRating) in episodeIdXrefs) + { + var idWithZero = $"{animeId}:{anidbEpisodeId}:{showId}:0"; + var id = $"{animeId}:{anidbEpisodeId}:{showId}:{tmdbEpisodeId}"; + var updated = false; + if (!exitingEpisodeXrefs.TryGetValue(id, out var xref)) + { + // Also check the zero id if we haven't already. + if (!usedEpisodeIdsWithZeroSet.Contains(idWithZero) && (id == idWithZero || !exitingEpisodeXrefs.TryGetValue(idWithZero, out xref) || true)) + usedEpisodeIdsWithZeroSet.Add(idWithZero); + + // Make sure an xref exists. + if (xref is null) + { + episodeXrefsToAdd++; + updated = true; + xref = new() + { + AnidbAnimeID = animeId, + AnidbEpisodeID = anidbEpisodeId, + TmdbShowID = showId, + TmdbEpisodeID = tmdbEpisodeId, + Ordering = 0, + MatchRating = matchRating, + }; + } + } + + if (xref.TmdbEpisodeID != tmdbEpisodeId) + { + xref.TmdbEpisodeID = tmdbEpisodeId; + updated = true; + } + + if (xref.MatchRating != matchRating) + { + xref.MatchRating = matchRating; + updated = true; + } + + if (updated) + episodeXrefsToSave.Add(xref); + + var seriesExists = xref.AnimeSeries is not null; + var tmdbEpisodeExists = xref.TmdbEpisode is not null; + if (seriesExists && !tmdbEpisodeExists) + showsToPull.Add(xref.TmdbShowID); + + if (!existingShowXrefs.Contains($"{animeId}:{showId}")) + showXrefsToSave.TryAdd($"{animeId}:{showId}", new(animeId, showId, CrossRefSource.User)); + } + + if (movieXrefsToSave.Count > 0 || moviesToPull.Count > 0) + { + _logger.LogDebug( + "Inserted {InsertedCount} and updated {UpdatedCount} out of {TotalCount} movie cross-references in the imported file, and scheduling {MovieCount} movies for update.", + movieXrefsToAdd, + movieXrefsToSave.Count - movieXrefsToAdd, + movieIdXrefs.Count, + moviesToPull.Count + ); + + RepoFactory.CrossRef_AniDB_TMDB_Movie.Save(movieXrefsToSave); + + foreach (var movieId in moviesToPull) + await _tmdbMetadataService.ScheduleUpdateOfMovie(movieId); + } + + if (episodeXrefsToSave.Count > 0 || showXrefsToSave.Count > 0 || showsToPull.Count > 0) + { + _logger.LogDebug( + "Inserted {InsertedCount} and updated {UpdatedCount} out of {TotalCount} episode cross-references in the imported file, inserted {TotalCount} show cross-references and scheduling {ShowCount} shows for update.", + episodeXrefsToAdd, + episodeXrefsToSave.Count - episodeXrefsToAdd, + episodeIdXrefs.Count, + showXrefsToSave.Count, + showsToPull.Count + ); + + RepoFactory.CrossRef_AniDB_TMDB_Show.Save(showXrefsToSave.Values.ToList()); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(episodeXrefsToSave); + + foreach (var showId in showsToPull) + await _tmdbMetadataService.ScheduleUpdateOfShow(showId); + } + + return NoContent(); + } + + #endregion +} diff --git a/Shoko.Server/API/v3/Controllers/TreeController.cs b/Shoko.Server/API/v3/Controllers/TreeController.cs index 9a7afd089..70d739465 100644 --- a/Shoko.Server/API/v3/Controllers/TreeController.cs +++ b/Shoko.Server/API/v3/Controllers/TreeController.cs @@ -29,7 +29,6 @@ namespace Shoko.Server.API.v3.Controllers; public class TreeController : BaseController { private readonly FilterFactory _filterFactory; - private readonly SeriesFactory _seriesFactory; private readonly FilterEvaluator _filterEvaluator; #region Import Folder @@ -44,16 +43,16 @@ public class TreeController : BaseController /// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param> /// <returns></returns> [HttpGet("ImportFolder/{folderID}/File")] - public ActionResult<ListResult<File>> GetFilesInImportFolder([FromRoute] int folderID, + public ActionResult<ListResult<File>> GetFilesInImportFolder([FromRoute, Range(1, int.MaxValue)] int folderID, [FromQuery, Range(0, 10000)] int pageSize = 200, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] string folderPath = null, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileNonDefaultIncludeType[] include = default, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource> includeDataFrom = null) { - include ??= Array.Empty<FileNonDefaultIncludeType>(); + include ??= []; - var importFolder = folderID > 0 ? RepoFactory.ImportFolder.GetByID(folderID) : null; + var importFolder = RepoFactory.ImportFolder.GetByID(folderID); if (importFolder == null) return NotFound("Import folder not found: " + folderID); @@ -66,11 +65,11 @@ public ActionResult<ListResult<File>> GetFilesInImportFolder([FromRoute] int fol .Replace('\\', System.IO.Path.DirectorySeparatorChar) .Replace('/', System.IO.Path.DirectorySeparatorChar); - // Remove leading seperator. + // Remove leading separator. if (folderPath.Length > 0 && folderPath[0] == System.IO.Path.DirectorySeparatorChar) folderPath = folderPath[1..]; - // Append tailing seperator if the string is not empty, since we're searching for the folder path. + // Append tailing separator if the string is not empty, since we're searching for the folder path. if (folderPath.Length > 0 && folderPath[^1] != System.IO.Path.DirectorySeparatorChar) folderPath += System.IO.Path.DirectorySeparatorChar; @@ -105,8 +104,8 @@ public ActionResult<ListResult<File>> GetFilesInImportFolder([FromRoute] int fol /// <param name="showHidden">Show hidden filters</param> /// <returns></returns> [HttpGet("Filter/{filterID}/Filter")] - public ActionResult<ListResult<Filter>> GetSubFilters([FromRoute] int filterID, - [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValue)] int page = 1, + public ActionResult<ListResult<Filter>> GetSubFilters([FromRoute, Range(1, int.MaxValue)] int filterID, + [FromQuery, Range(0, 100)] int pageSize = 50, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] bool showHidden = false) { var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); @@ -134,12 +133,12 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu /// <param name="pageSize">The page size. Set to <code>0</code> to disable pagination.</param> /// <param name="page">The page index.</param> /// <param name="includeEmpty">Include <see cref="Series"/> with missing <see cref="Episode"/>s in the search.</param> - /// <param name="randomImages">Randomise images shown for the <see cref="Group"/>.</param> - /// <param name="orderByName">Ignore the group filter sort critaria and always order the returned list by name.</param> + /// <param name="randomImages">Randomize images shown for the <see cref="Group"/>.</param> + /// <param name="orderByName">Ignore the group filter sort criteria and always order the returned list by name.</param> /// <returns></returns> [HttpGet("Filter/{filterID}/Group")] - public ActionResult<ListResult<Group>> GetFilteredGroups([FromRoute] int filterID, - [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValue)] int page = 1, + public ActionResult<ListResult<Group>> GetFilteredGroups([FromRoute, Range(0, int.MaxValue)] int filterID, + [FromQuery, Range(0, 100)] int pageSize = 50, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] bool includeEmpty = false, [FromQuery] bool randomImages = false, [FromQuery] bool orderByName = false) { // Return the top level groups with no filter. @@ -176,20 +175,17 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu if (!results.Any()) return new ListResult<Group>(); groups = results - .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)) - .Where(group => - { - // not top level groups - if (group == null || group.AnimeGroupParentID.HasValue) - return false; - - return includeEmpty || group.AllSeries - .Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0)); - }); + .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)?.TopLevelAnimeGroup) + .WhereNotNull() + .DistinctBy(group => group.AnimeGroupID) + .Where(group => includeEmpty || group.AllSeries.Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0))); } + if (orderByName) + groups = groups.OrderBy(group => group.SortName); + return groups - .ToListResult(group => new Group(HttpContext, group, randomImages), page, pageSize); + .ToListResult(group => new Group(group, User.JMMUserID, randomImages), page, pageSize); } /// <summary> @@ -206,12 +202,12 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu /// <see cref="Episode"/>s in the count.</param> /// <returns></returns> [HttpGet("Filter/{filterID}/Group/Letters")] - public ActionResult<Dictionary<char, int>> GetGroupNameLettersInFilter([FromRoute] int? filterID = null, [FromQuery] bool includeEmpty = false) + public ActionResult<Dictionary<char, int>> GetGroupNameLettersInFilter([FromRoute, Range(0, int.MaxValue)] int filterID, [FromQuery] bool includeEmpty = false) { var user = User; - if (filterID.HasValue && filterID > 0) + if (filterID > 0) { - var filterPreset = RepoFactory.FilterPreset.GetByID(filterID.Value); + var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); if (filterPreset == null) return NotFound(FilterController.FilterNotFound); @@ -225,15 +221,10 @@ public ActionResult<Dictionary<char, int>> GetGroupNameLettersInFilter([FromRout return new Dictionary<char, int>(); return results - .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)) - .Where(group => - { - if (group is not { AnimeGroupParentID: null }) - return false; - - return includeEmpty || group.AllSeries - .Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0)); - }) + .Select(group => RepoFactory.AnimeGroup.GetByID(group.Key)?.TopLevelAnimeGroup) + .WhereNotNull() + .DistinctBy(group => group.AnimeGroupID) + .Where(group => includeEmpty || group.AllSeries.Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0))) .GroupBy(group => group.SortName[0]) .OrderBy(groupList => groupList.Key) .ToDictionary(groupList => groupList.Key, groupList => groupList.Count()); @@ -268,13 +259,13 @@ public ActionResult<Dictionary<char, int>> GetGroupNameLettersInFilter([FromRout /// <param name="filterID"><see cref="Filter"/> ID</param> /// <param name="pageSize">The page size. Set to <code>0</code> to disable pagination.</param> /// <param name="page">The page index.</param> - /// <param name="randomImages">Randomise images shown for each <see cref="Series"/>.</param> + /// <param name="randomImages">Randomize images shown for each <see cref="Series"/>.</param> /// <param name="includeMissing">Include <see cref="Series"/> with missing /// <see cref="Episode"/>s in the count.</param> /// <returns></returns> [HttpGet("Filter/{filterID}/Series")] - public ActionResult<ListResult<Series>> GetSeriesInFilteredGroup([FromRoute] int filterID, - [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValue)] int page = 1, + public ActionResult<ListResult<Series>> GetSeriesInFilteredGroup([FromRoute, Range(0, int.MaxValue)] int filterID, + [FromQuery, Range(0, 100)] int pageSize = 50, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery] bool randomImages = false, [FromQuery] bool includeMissing = false) { // Return the series with no group filter applied. @@ -282,8 +273,8 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu if (filterID == 0) return RepoFactory.AnimeSeries.GetAll() .Where(series => user.AllowedSeries(series) && (includeMissing || series.VideoLocals.Count > 0)) - .OrderBy(series => series.SeriesName.ToLowerInvariant()) - .ToListResult(series => _seriesFactory.GetSeries(series, randomImages), page, pageSize); + .OrderBy(series => series.PreferredTitle.ToLowerInvariant()) + .ToListResult(series => new Series(series, User.JMMUserID, randomImages), page, pageSize); // Check if the group filter exists. var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); @@ -301,8 +292,44 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu // We don't need separate logic for ApplyAtSeriesLevel, as the FilterEvaluator handles that return results.SelectMany(a => a.Select(id => RepoFactory.AnimeSeries.GetByID(id))) .Where(series => series != null && (includeMissing || series.VideoLocals.Count > 0)) - .OrderBy(series => series.SeriesName.ToLowerInvariant()) - .ToListResult(series => _seriesFactory.GetSeries(series, randomImages), page, pageSize); + .OrderBy(series => series.PreferredTitle.ToLowerInvariant()) + .ToListResult(series => new Series(series, User.JMMUserID, randomImages), page, pageSize); + } + + /// <summary> + /// Get a raw list of <see cref="Series"/> IDs for the <see cref="Filter"/> + /// with the given <paramref name="filterID"/> for client-side filtering. + /// </summary> + /// <remarks> + /// The <see cref="Filter"/> must have <see cref="Filter.IsDirectory"/> set to false to use + /// this endpoint. + /// </remarks> + /// <param name="filterID"><see cref="Filter"/> ID</param> + /// <returns></returns> + [HttpGet("Filter/{filterID}/Series/OnlyIDs")] + public ActionResult<List<int>> GetFilteredSeriesIDs([FromRoute, Range(0, int.MaxValue)] int filterID) + { + if (filterID == 0) + { + var user = User; + return RepoFactory.AnimeSeries.GetAll() + .Where(user.AllowedSeries) + .Select(group => group.AnimeSeriesID) + .ToList(); + } + + var filterPreset = RepoFactory.FilterPreset.GetByID(filterID); + if (filterPreset == null) + return NotFound(FilterController.FilterNotFound); + + // Directories should only contain sub-filters, not groups and series. + if (filterPreset.IsDirectory()) + return new List<int>(); + + // Gets Series and Series IDs in a filter, already sorted by the filter + var results = _filterEvaluator.EvaluateFilter(filterPreset, User.JMMUserID); + return results.SelectMany(groupBy => groupBy) + .ToList(); } /// <summary> @@ -314,11 +341,11 @@ [FromQuery] [Range(0, 100)] int pageSize = 50, [FromQuery] [Range(1, int.MaxValu /// </remarks> /// <param name="filterID"><see cref="Filter"/> ID</param> /// <param name="groupID"><see cref="Group"/> ID</param> - /// <param name="randomImages">Randomise images shown for the <see cref="Group"/>.</param> + /// <param name="randomImages">Randomize images shown for the <see cref="Group"/>.</param> /// <param name="includeEmpty">Include <see cref="Series"/> with missing <see cref="Episode"/>s in the search.</param> /// <returns></returns> [HttpGet("Filter/{filterID}/Group/{groupID}/Group")] - public ActionResult<List<Group>> GetFilteredSubGroups([FromRoute] int filterID, [FromRoute] int groupID, + public ActionResult<List<Group>> GetFilteredSubGroups([FromRoute, Range(0, int.MaxValue)] int filterID, [FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool randomImages = false, [FromQuery] bool includeEmpty = false) { // Return sub-groups with no group filter applied. @@ -330,7 +357,6 @@ public ActionResult<List<Group>> GetFilteredSubGroups([FromRoute] int filterID, return NotFound(FilterController.FilterNotFound); // Check if the group exists. - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) return NotFound(GroupController.GroupNotFound); @@ -347,12 +373,12 @@ public ActionResult<List<Group>> GetFilteredSubGroups([FromRoute] int filterID, var results = _filterEvaluator.EvaluateFilter(filterPreset, User.JMMUserID).ToArray(); if (results.Length == 0) return new List<Group>(); - + // Subgroups are weird. We'll take the group, build a set of all subgroup IDs, and use that to determine if a group should be included // This should maintain the order of results, but have every group in the tree for those results var orderedGroups = results.SelectMany(a => RepoFactory.AnimeGroup.GetByID(a.Key).TopLevelAnimeGroup.AllChildren.Select(b => b.AnimeGroupID)).ToArray(); var groups = orderedGroups.ToHashSet(); - + return group.Children .Where(subGroup => { @@ -369,7 +395,7 @@ public ActionResult<List<Group>> GetFilteredSubGroups([FromRoute] int filterID, return groups.Contains(subGroup.AnimeGroupID); }) .OrderBy(a => Array.IndexOf(orderedGroups, a.AnimeGroupID)) - .Select(g => new Group(HttpContext, g, randomImages)) + .Select(g => new Group(g, User.JMMUserID, randomImages)) .ToList(); } @@ -386,15 +412,14 @@ public ActionResult<List<Group>> GetFilteredSubGroups([FromRoute] int filterID, /// <param name="groupID"><see cref="Group"/> ID</param> /// <param name="recursive">Show all the <see cref="Series"/> within the <see cref="Group"/>. Even the <see cref="Series"/> within the sub-<see cref="Group"/>s.</param> /// <param name="includeMissing">Include <see cref="Series"/> with missing <see cref="Episode"/>s in the list.</param> - /// <param name="randomImages">Randomise images shown for each <see cref="Series"/> within the <see cref="Group"/>.</param> + /// <param name="randomImages">Randomize images shown for each <see cref="Series"/> within the <see cref="Group"/>.</param> /// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param> /// /// <returns></returns> [HttpGet("Filter/{filterID}/Group/{groupID}/Series")] - public ActionResult<List<Series>> GetSeriesInFilteredGroup([FromRoute] int filterID, [FromRoute] int groupID, + public ActionResult<List<Series>> GetSeriesInFilteredGroup([FromRoute, Range(0, int.MaxValue)] int filterID, [FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool recursive = false, [FromQuery] bool includeMissing = false, [FromQuery] bool randomImages = false, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource> includeDataFrom = null) { - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); // Return the groups with no group filter applied. if (filterID == 0) return GetSeriesInGroup(groupID, recursive, includeMissing, randomImages, includeDataFrom); @@ -429,11 +454,11 @@ public ActionResult<List<Series>> GetSeriesInFilteredGroup([FromRoute] int filte ? group.AllChildren.SelectMany(a => results.FirstOrDefault(b => b.Key == a.AnimeGroupID)) : results.FirstOrDefault(a => a.Key == groupID); - var series = seriesIDs?.Select(a => RepoFactory.AnimeSeries.GetByID(a)).Where(a => a.VideoLocals.Any() || includeMissing) ?? + var series = seriesIDs?.Select(a => RepoFactory.AnimeSeries.GetByID(a)).Where(a => includeMissing || a.VideoLocals.Count != 0) ?? Array.Empty<SVR_AnimeSeries>(); return series - .Select(a => _seriesFactory.GetSeries(a, randomImages, includeDataFrom)) + .Select(a => new Series(a, User.JMMUserID, randomImages, includeDataFrom)) .ToList(); } @@ -445,45 +470,36 @@ public ActionResult<List<Series>> GetSeriesInFilteredGroup([FromRoute] int filte /// Get a list of sub-<see cref="Group"/>s a the <see cref="Group"/>. /// </summary> /// <param name="groupID"></param> - /// <param name="randomImages">Randomise images shown for the <see cref="Group"/>.</param> + /// <param name="randomImages">Randomize images shown for the <see cref="Group"/>.</param> /// <param name="includeEmpty">Include <see cref="Series"/> with missing <see cref="Episode"/>s in the search.</param> /// <returns></returns> [HttpGet("Group/{groupID}/Group")] - public ActionResult<List<Group>> GetSubGroups([FromRoute] int groupID, [FromQuery] bool randomImages = false, + public ActionResult<List<Group>> GetSubGroups([FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool randomImages = false, [FromQuery] bool includeEmpty = false) { // Check if the group exists. - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) - { return NotFound(GroupController.GroupNotFound); - } var user = User; if (!user.AllowedGroup(group)) - { return Forbid(GroupController.GroupForbiddenForUser); - } return group.Children .Where(subGroup => { if (subGroup == null) - { return false; - } if (!user.AllowedGroup(subGroup)) - { return false; - } return includeEmpty || subGroup.AllSeries .Any(s => s.AnimeEpisodes.Any(e => e.VideoLocals.Count > 0)); }) .OrderBy(g => g.GroupName) - .Select(g => new Group(HttpContext, g, randomImages)) + .Select(g => new Group(g, User.JMMUserID, randomImages)) .ToList(); } @@ -497,28 +513,25 @@ public ActionResult<List<Group>> GetSubGroups([FromRoute] int groupID, [FromQuer /// <param name="groupID"><see cref="Group"/> ID</param> /// <param name="recursive">Show all the <see cref="Series"/> within the <see cref="Group"/></param> /// <param name="includeMissing">Include <see cref="Series"/> with missing <see cref="Episode"/>s in the list.</param> - /// <param name="randomImages">Randomise images shown for each <see cref="Series"/> within the <see cref="Group"/>.</param> + /// <param name="randomImages">Randomize images shown for each <see cref="Series"/> within the <see cref="Group"/>.</param> /// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param> /// <returns></returns> [HttpGet("Group/{groupID}/Series")] - public ActionResult<List<Series>> GetSeriesInGroup([FromRoute] int groupID, [FromQuery] bool recursive = false, + public ActionResult<List<Series>> GetSeriesInGroup([FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool recursive = false, [FromQuery] bool includeMissing = false, [FromQuery] bool randomImages = false, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource> includeDataFrom = null) { // Check if the group exists. - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) - { return NotFound(GroupController.GroupNotFound); - } var user = User; return (recursive ? group.AllSeries : group.Series) .Where(a => user.AllowedSeries(a)) - .Select(series => _seriesFactory.GetSeries(series, randomImages, includeDataFrom)) + .Select(series => new Series(series, User.JMMUserID, randomImages, includeDataFrom)) .Where(series => series.Size > 0 || includeMissing) - .OrderBy(a => a._AniDB?.AirDate ?? a._TvDB?.Select(b => b.AirDate ?? DateTime.MaxValue).DefaultIfEmpty(DateTime.MaxValue).Min() ?? DateTime.MaxValue) + .OrderBy(a => a._AniDB?.AirDate ?? DateTime.MaxValue) .ToList(); } @@ -531,39 +544,32 @@ public ActionResult<List<Series>> GetSeriesInGroup([FromRoute] int groupID, [Fro /// empty. /// </remarks> /// <param name="groupID"><see cref="Group"/> ID</param> - /// <param name="randomImages">Randomise images shown for the <see cref="Series"/>.</param> + /// <param name="randomImages">Randomize images shown for the <see cref="Series"/>.</param> /// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param> /// <returns></returns> [HttpGet("Group/{groupID}/MainSeries")] - public ActionResult<Series> GetMainSeriesInGroup([FromRoute] int groupID, [FromQuery] bool randomImages = false, + public ActionResult<Series> GetMainSeriesInGroup([FromRoute, Range(1, int.MaxValue)] int groupID, [FromQuery] bool randomImages = false, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource> includeDataFrom = null) { // Check if the group exists. - if (groupID == 0) return BadRequest(GroupController.GroupWithZeroID); var group = RepoFactory.AnimeGroup.GetByID(groupID); if (group == null) - { return NotFound(GroupController.GroupNotFound); - } var user = User; if (!user.AllowedGroup(group)) - { return Forbid(GroupController.GroupForbiddenForUser); - } var mainSeries = group.MainSeries ?? group.AllSeries.FirstOrDefault(); if (mainSeries == null) - { return InternalError("Unable to find main series for group."); - } - return _seriesFactory.GetSeries(mainSeries, randomImages, includeDataFrom); + return new Series(mainSeries, User.JMMUserID, randomImages, includeDataFrom); } #endregion - - + + /// <summary> /// Get the <see cref="File"/>s for the <see cref="Series"/> with the given <paramref name="seriesID"/>. /// </summary> @@ -577,7 +583,7 @@ public ActionResult<Series> GetMainSeriesInGroup([FromRoute] int groupID, [FromQ /// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param> /// <returns></returns> [HttpGet("Series/{seriesID}/File")] - public ActionResult<ListResult<File>> GetFilesForSeries([FromRoute] int seriesID, + public ActionResult<ListResult<File>> GetFilesForSeries([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery, Range(0, 1000)] int pageSize = 100, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileNonDefaultIncludeType[] include = default, @@ -586,22 +592,17 @@ public ActionResult<ListResult<File>> GetFilesForSeries([FromRoute] int seriesID [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] List<string> sortOrder = null, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource> includeDataFrom = null) { - if (seriesID == 0) return BadRequest(SeriesController.SeriesWithZeroID); var user = User; var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) - { return NotFound(SeriesController.SeriesNotFoundWithSeriesID); - } if (!user.AllowedSeries(series)) - { return Forbid(SeriesController.SeriesForbiddenForUser); - } return ModelHelper.FilterFiles(series.VideoLocals, user, pageSize, page, include, exclude, include_only, sortOrder, includeDataFrom); } - + #region Episode /// <summary> @@ -617,7 +618,7 @@ public ActionResult<ListResult<File>> GetFilesForSeries([FromRoute] int seriesID /// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param> /// <returns></returns> [HttpGet("Episode/{episodeID}/File")] - public ActionResult<ListResult<File>> GetFilesForEpisode([FromRoute] int episodeID, + public ActionResult<ListResult<File>> GetFilesForEpisode([FromRoute, Range(1, int.MaxValue)] int episodeID, [FromQuery, Range(0, 1000)] int pageSize = 100, [FromQuery, Range(1, int.MaxValue)] int page = 1, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileNonDefaultIncludeType[] include = default, @@ -628,30 +629,23 @@ public ActionResult<ListResult<File>> GetFilesForEpisode([FromRoute] int episode { var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) - { return NotFound(EpisodeController.EpisodeNotFoundWithEpisodeID); - } var series = episode.AnimeSeries; if (series == null) - { return InternalError("No Series entry for given Episode"); - } if (!User.AllowedSeries(series)) - { return Forbid(EpisodeController.EpisodeForbiddenForUser); - } return ModelHelper.FilterFiles(episode.VideoLocals, User, pageSize, page, include, exclude, include_only, sortOrder, includeDataFrom); } #endregion - public TreeController(ISettingsProvider settingsProvider, FilterFactory filterFactory, FilterEvaluator filterEvaluator, SeriesFactory seriesFactory) : base(settingsProvider) + public TreeController(ISettingsProvider settingsProvider, FilterFactory filterFactory, FilterEvaluator filterEvaluator) : base(settingsProvider) { _filterFactory = filterFactory; _filterEvaluator = filterEvaluator; - _seriesFactory = seriesFactory; } } diff --git a/Shoko.Server/API/v3/Controllers/UserController.cs b/Shoko.Server/API/v3/Controllers/UserController.cs index b37ee930c..a406ea232 100644 --- a/Shoko.Server/API/v3/Controllers/UserController.cs +++ b/Shoko.Server/API/v3/Controllers/UserController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.JsonPatch; @@ -116,7 +117,7 @@ public ActionResult ChangePasswordForCurrentUser([FromBody] User.Input.ChangePas /// <returns>The user.</returns> [Authorize("admin")] [HttpGet("{userID}")] - public ActionResult<User> GetUserByUserID([FromRoute] int userID) + public ActionResult<User> GetUserByUserID([FromRoute, Range(1, int.MaxValue)] int userID) { var user = RepoFactory.JMMUser.GetByID(userID); if (user == null) @@ -136,7 +137,7 @@ public ActionResult<User> GetUserByUserID([FromRoute] int userID) /// <returns>The updated user.</returns> [Authorize("admin")] [HttpPatch("{userID}")] - public ActionResult<User> PatchUserByUserID([FromRoute] int userID, [FromBody] JsonPatchDocument<User.Input.CreateOrUpdateUserBody> document) + public ActionResult<User> PatchUserByUserID([FromRoute, Range(1, int.MaxValue)] int userID, [FromBody] JsonPatchDocument<User.Input.CreateOrUpdateUserBody> document) { var user = RepoFactory.JMMUser.GetByID(userID); if (user == null) @@ -166,7 +167,7 @@ public ActionResult<User> PatchUserByUserID([FromRoute] int userID, [FromBody] J /// <returns>The updated user.</returns> [Authorize("admin")] [HttpPut("{userID}")] - public ActionResult<User> PutUserByUserID([FromRoute] int userID, [FromBody] User.Input.CreateOrUpdateUserBody body) + public ActionResult<User> PutUserByUserID([FromRoute, Range(1, int.MaxValue)] int userID, [FromBody] User.Input.CreateOrUpdateUserBody body) { var user = RepoFactory.JMMUser.GetByID(userID); if (user == null) @@ -189,7 +190,7 @@ public ActionResult<User> PutUserByUserID([FromRoute] int userID, [FromBody] Use /// <returns>Void.</returns> [Authorize("admin")] [HttpDelete("{userID}")] - public ActionResult DeleteUser([FromRoute] int userID) + public ActionResult DeleteUser([FromRoute, Range(1, int.MaxValue)] int userID) { var user = RepoFactory.JMMUser.GetByID(userID); if (user == null) @@ -215,7 +216,7 @@ public ActionResult DeleteUser([FromRoute] int userID) /// <returns></returns> [Authorize("admin")] [HttpPost("{userID}/ChangePassword")] - public ActionResult ChangePasswordForUserByUserID([FromRoute] int userID, [FromBody] User.Input.ChangePasswordBody body) => + public ActionResult ChangePasswordForUserByUserID([FromRoute, Range(1, int.MaxValue)] int userID, [FromBody] User.Input.ChangePasswordBody body) => ChangePassword(RepoFactory.JMMUser.GetByID(userID), body); [NonAction] diff --git a/Shoko.Server/API/v3/Controllers/WebUIController.cs b/Shoko.Server/API/v3/Controllers/WebUIController.cs index c2c5e3a16..9bf970b92 100644 --- a/Shoko.Server/API/v3/Controllers/WebUIController.cs +++ b/Shoko.Server/API/v3/Controllers/WebUIController.cs @@ -5,11 +5,15 @@ using System.Linq; using System.Net.Http; using System.Net; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Shoko.Commons.Extensions; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; @@ -27,6 +31,8 @@ using FileSummaryGroupByCriteria = Shoko.Server.API.v3.Models.Shoko.WebUI.WebUISeriesFileSummary.FileSummaryGroupByCriteria; using Input = Shoko.Server.API.v3.Models.Shoko.WebUI.Input; +#pragma warning disable CA1822 +#nullable enable namespace Shoko.Server.API.v3.Controllers; /// <summary> @@ -37,13 +43,14 @@ namespace Shoko.Server.API.v3.Controllers; [ApiController] [Route("/api/v{version:apiVersion}/[controller]")] [ApiV3] -public class WebUIController : BaseController +public partial class WebUIController : BaseController { - private static IMemoryCache Cache = new MemoryCache(new MemoryCacheOptions() { + private static readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions() + { ExpirationScanFrequency = TimeSpan.FromMinutes(50), }); - private static readonly TimeSpan CacheTTL = TimeSpan.FromHours(1); + private static readonly TimeSpan _cacheTTL = TimeSpan.FromHours(1); private readonly ILogger<WebUIController> _logger; @@ -79,18 +86,18 @@ public ActionResult<string> GetThemesCSS([FromQuery] bool forceRefresh = false) } /// <summary> - /// Adds a new theme to the application. + /// Adds a new theme to the application from a theme URL. /// </summary> /// <param name="body">The body of the request containing the theme URL and preview flag.</param> /// <returns>The added theme.</returns> [Authorize("admin")] - [HttpPost("Theme")] - public async Task<ActionResult<WebUITheme>> AddTheme([FromBody] Input.WebUIAddThemeBody body) + [HttpPost("Theme/AddFromURL")] + public async Task<ActionResult<WebUITheme>> AddThemeFromUrl([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Input.WebUIAddThemeBody body) { try { - var theme = await WebUIThemeProvider.InstallTheme(body.URL, body.Preview); - return new WebUITheme(theme); + var theme = await WebUIThemeProvider.InstallThemeFromUrl(body.URL, body.Preview); + return new WebUITheme(theme, true); } catch (ValidationException valEx) { @@ -102,11 +109,58 @@ public async Task<ActionResult<WebUITheme>> AddTheme([FromBody] Input.WebUIAddTh } } + /// <summary> + /// Adds a new theme to the application by uploading a theme file. + /// </summary> + /// <param name="file">The theme file to add.</param> + /// <param name="preview">Flag indicating whether to enable preview mode, which just validates the file contents without installing the theme.</param> + /// <returns>The added or previewed theme.</returns> + [Authorize("admin")] + [HttpPost("Theme/AddFromFile")] + public async Task<ActionResult<WebUITheme>> AddThemeFromFile(IFormFile file, [FromForm] bool preview = false) + { + var fileName = Path.GetFileName(file.FileName); + if (string.IsNullOrEmpty(fileName)) + return ValidationProblem("File name cannot be empty or omitted.", nameof(file)); + + try + { + // Check if the file name conforms to our specified format. + switch (Path.GetExtension(fileName)) + { + case ".css": + { + using var fileReader = new StreamReader(file.OpenReadStream()); + var content = await fileReader.ReadToEndAsync(); + var theme = await WebUIThemeProvider.CreateOrUpdateThemeFromCss(content, Path.GetFileNameWithoutExtension(fileName), preview); + return new WebUITheme(theme, true); + } + case ".json": + { + using var fileReader = new StreamReader(file.OpenReadStream()); + var content = await fileReader.ReadToEndAsync(); + var theme = await WebUIThemeProvider.InstallOrUpdateThemeFromJson(content, Path.GetFileNameWithoutExtension(fileName), preview); + return new WebUITheme(theme, true); + } + default: + return ValidationProblem("Unsupported file extension.", nameof(file)); + } + } + catch (ValidationException valEx) + { + return ValidationProblem(valEx.Message); + } + catch (HttpRequestException httpEx) + { + return InternalError(httpEx.Message); + } + } + /// <summary> /// Retrieves a specific theme by its ID. /// </summary> /// <param name="themeID">The ID of the theme to retrieve.</param> - /// <param name="forceRefresh">Flag indicating whether to force a refresh of the themes before retriving the specific theme.</param> + /// <param name="forceRefresh">Flag indicating whether to force a refresh of the themes before retrieving the specific theme.</param> /// <returns>The retrieved theme.</returns> [AllowAnonymous] [DatabaseBlockedExempt] @@ -115,17 +169,17 @@ public async Task<ActionResult<WebUITheme>> AddTheme([FromBody] Input.WebUIAddTh public ActionResult<WebUITheme> GetTheme([FromRoute] string themeID, [FromQuery] bool forceRefresh = false) { var theme = WebUIThemeProvider.GetTheme(themeID, forceRefresh); - if (theme == null) + if (theme is null) return NotFound("A theme with the given id was not found."); - return new WebUITheme(theme); + return new WebUITheme(theme, true); } /// <summary> /// Retrieves the CSS representation of a specific theme by its ID. /// </summary> /// <param name="themeID">The ID of the theme to retrieve.</param> - /// <param name="forceRefresh">Flag indicating whether to force a refresh of the themes before retriving the specific theme.</param> + /// <param name="forceRefresh">Flag indicating whether to force a refresh of the themes before retrieving the specific theme.</param> /// <returns>The retrieved theme.</returns> [AllowAnonymous] [DatabaseBlockedExempt] @@ -135,7 +189,7 @@ public ActionResult<WebUITheme> GetTheme([FromRoute] string themeID, [FromQuery] public ActionResult<string> GetThemeCSS([FromRoute] string themeID, [FromQuery] bool forceRefresh = false) { var theme = WebUIThemeProvider.GetTheme(themeID, forceRefresh); - if (theme == null) + if (theme is null) return NotFound("A theme with the given id was not found."); return Content(theme.ToCSS(), "text/css"); @@ -151,11 +205,9 @@ public ActionResult<string> GetThemeCSS([FromRoute] string themeID, [FromQuery] public ActionResult RemoveTheme([FromRoute] string themeID) { var theme = WebUIThemeProvider.GetTheme(themeID, true); - if (theme == null) + if (theme is null || !WebUIThemeProvider.RemoveTheme(theme)) return NotFound("A theme with the given id was not found."); - WebUIThemeProvider.RemoveTheme(theme); - return NoContent(); } /// <summary> @@ -168,17 +220,17 @@ public ActionResult RemoveTheme([FromRoute] string themeID) public async Task<ActionResult<WebUITheme>> PreviewUpdatedTheme([FromRoute] string themeID) { var theme = WebUIThemeProvider.GetTheme(themeID, true); - if (theme == null) + if (theme is null) return NotFound("A theme with the given id was not found."); try { - theme = await WebUIThemeProvider.UpdateTheme(theme, true); - return new WebUITheme(theme); + theme = await WebUIThemeProvider.UpdateThemeOnline(theme, true); + return new WebUITheme(theme, true); } catch (ValidationException valEx) { - return BadRequest(valEx.Message); + return ValidationProblem(valEx.Message); } catch (HttpRequestException httpEx) { @@ -196,13 +248,13 @@ public async Task<ActionResult<WebUITheme>> PreviewUpdatedTheme([FromRoute] stri public async Task<ActionResult<WebUITheme>> UpdateTheme([FromRoute] string themeID) { var theme = WebUIThemeProvider.GetTheme(themeID, true); - if (theme == null) + if (theme is null) return NotFound("A theme with the given id was not found."); try { - theme = await WebUIThemeProvider.UpdateTheme(theme); - return new WebUITheme(theme); + theme = await WebUIThemeProvider.UpdateThemeOnline(theme); + return new WebUITheme(theme, true); } catch (ValidationException valEx) { @@ -225,24 +277,26 @@ public ActionResult<List<WebUIGroupExtra>> GetGroupView([FromBody] Input.WebUIGr // Check user permissions for each requested group and return extra information. var user = User; return body.GroupIDs + .Distinct() .Select(groupID => { var group = RepoFactory.AnimeGroup.GetByID(groupID); - if (group == null || !user.AllowedGroup(group)) + if (group is null || !user.AllowedGroup(group)) { return null; } var series = group.MainSeries ?? group.AllSeries.FirstOrDefault(); var anime = series?.AniDB_Anime; - if (series == null || anime == null) + if (anime is null) { return null; } - return _webUIFactory.GetWebUIGroupExtra(group, series, anime, body.TagFilter, body.OrderByName, + return _webUIFactory.GetWebUIGroupExtra(group, anime, body.TagFilter, body.OrderByName, body.TagLimit); }) + .WhereNotNull() .ToList(); } @@ -252,13 +306,11 @@ public ActionResult<List<WebUIGroupExtra>> GetGroupView([FromBody] Input.WebUIGr /// <param name="seriesID">The ID of the series to retrieve information for.</param> /// <returns>A <c>WebUISeriesExtra</c> object containing extra information for the series.</returns> [HttpGet("Series/{seriesID}")] - public ActionResult<WebUISeriesExtra> GetSeries([FromRoute] int seriesID) + public ActionResult<WebUISeriesExtra> GetSeries([FromRoute, Range(1, int.MaxValue)] int seriesID) { - if (seriesID == 0) return BadRequest(SeriesController.SeriesWithZeroID); - // Retrieve extra information for the specified series if it exists and the user has permissions. var series = RepoFactory.AnimeSeries.GetByID(seriesID); - if (series == null) + if (series is null) { return NotFound(SeriesController.SeriesNotFoundWithSeriesID); } @@ -275,7 +327,7 @@ public ActionResult<WebUISeriesExtra> GetSeries([FromRoute] int seriesID) /// Returns a summary of file information for the series with the given ID. /// </summary> /// <param name="seriesID">The ID of the series to retrieve file information for.</param> - /// <param name="type">Filter the view to only the spesified <see cref="EpisodeType"/>s.</param> + /// <param name="type">Filter the view to only the specified <see cref="EpisodeType"/>s.</param> /// <param name="groupBy">Group the episodes in view into smaller groups based on <see cref="FileSummaryGroupByCriteria"/>s.</param> /// <param name="includeEpisodeDetails">Include episode details for each range.</param> /// <param name="includeMissingUnknownEpisodes">Include missing episodes that does not have an air date set.</param> @@ -283,17 +335,16 @@ public ActionResult<WebUISeriesExtra> GetSeries([FromRoute] int seriesID) /// <returns>A <c>WebUISeriesFileSummary</c> object containing a summary of file information for the series.</returns> [HttpGet("Series/{seriesID}/FileSummary")] public ActionResult<WebUISeriesFileSummary> GetSeriesFileSummary( - [FromRoute] int seriesID, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<EpisodeType> type = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<FileSummaryGroupByCriteria> groupBy = null, + [FromRoute, Range(1, int.MaxValue)] int seriesID, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<EpisodeType>? type = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<FileSummaryGroupByCriteria>? groupBy = null, [FromQuery] bool includeEpisodeDetails = false, [FromQuery] bool includeMissingUnknownEpisodes = false, [FromQuery] bool includeMissingFutureEpisodes = false) { // Retrieve a summary of file information for the specified series if it exists and the user has permissions. - if (seriesID == 0) return BadRequest(SeriesController.SeriesWithZeroID); var series = RepoFactory.AnimeSeries.GetByID(seriesID); - if (series == null) + if (series is null) { return NotFound(SeriesController.SeriesNotFoundWithSeriesID); } @@ -331,8 +382,11 @@ public ActionResult InstallWebUI([FromQuery] ReleaseChannel channel = ReleaseCha } var result = LatestWebUIVersion(channel); - if (result.Value == null) - return result.Result; + if (result.Value is null) + return result.Result!; + + if (result.Value.Tag is null) + return BadRequest("Unable to install web UI because a GitHub release was not found."); try { @@ -342,7 +396,7 @@ public ActionResult InstallWebUI([FromQuery] ReleaseChannel channel = ReleaseCha { if (ex.Status != WebExceptionStatus.Success) { - _logger.LogError(ex, "An error occured while trying to install the Web UI."); + _logger.LogError(ex, "An error occurred while trying to install the Web UI."); return Problem("Unable to use the GitHub API to check for an update. Check your connection and try again.", null, (int)HttpStatusCode.BadGateway, "Unable to connect to GitHub."); } throw; @@ -373,8 +427,11 @@ public ActionResult UpdateWebUI([FromQuery] ReleaseChannel channel = ReleaseChan if (channel == ReleaseChannel.Auto) channel = GetCurrentWebUIReleaseChannel(); var result = LatestWebUIVersion(channel); - if (result.Value == null) - return result.Result; + if (result.Value is null) + return result.Result!; + + if (result.Value.Tag is null) + return BadRequest("Unable to update web UI because a GitHub release was not found."); try { @@ -384,7 +441,7 @@ public ActionResult UpdateWebUI([FromQuery] ReleaseChannel channel = ReleaseChan { if (ex.Status != WebExceptionStatus.Success) { - _logger.LogError(ex, "An error occured while trying to update the Web UI."); + _logger.LogError(ex, "An error occurred while trying to update the Web UI."); return Problem("Unable to use the GitHub API to check for an update. Check your connection and try again.", null, (int)HttpStatusCode.BadGateway, "Unable to connect to GitHub."); } throw; @@ -417,10 +474,10 @@ public ActionResult<ComponentVersion> LatestWebUIVersion([FromQuery] ReleaseChan try { if (channel == ReleaseChannel.Auto) - channel = GetCurrentServerReleaseChannel(); + channel = GetCurrentWebUIReleaseChannel(); var key = $"webui:{channel}"; - if (!force && Cache.TryGetValue<ComponentVersion>(key, out var componentVersion)) - return componentVersion; + if (!force && _cache.TryGetValue<ComponentVersion>(key, out var componentVersion)) + return componentVersion!; switch (channel) { // Check for dev channel updates. @@ -430,21 +487,19 @@ public ActionResult<ComponentVersion> LatestWebUIVersion([FromQuery] ReleaseChan foreach (var release in releases) { string tagName = release.tag_name; - string version = tagName[0] == 'v' ? tagName[1..] : tagName; + var version = tagName[0] == 'v' ? tagName[1..] : tagName; foreach (var asset in release.assets) { // We don't care what the zip is named, only that it is attached. - // This is because we changed the signature from "latest.zip" to - // "Shoko-WebUI-{obj.tag_name}.zip" in the upgrade to web ui v2 string fileName = asset.name; - if (fileName == "latest.zip" || fileName == $"Shoko-WebUI-{tagName}.zip") + if (Path.GetExtension(fileName) is ".zip") { var tag = WebUIHelper.DownloadApiResponse($"git/ref/tags/{tagName}"); string commit = tag["object"].sha; DateTime releaseDate = release.published_at; releaseDate = releaseDate.ToUniversalTime(); string description = release.body; - return Cache.Set(key, new ComponentVersion + return _cache.Set(key, new ComponentVersion { Version = version, Commit = commit[..7], @@ -452,7 +507,7 @@ public ActionResult<ComponentVersion> LatestWebUIVersion([FromQuery] ReleaseChan ReleaseDate = releaseDate, Tag = tagName, Description = description.Trim(), - }, CacheTTL); + }, _cacheTTL); } } } @@ -466,13 +521,13 @@ public ActionResult<ComponentVersion> LatestWebUIVersion([FromQuery] ReleaseChan { var latestRelease = WebUIHelper.DownloadApiResponse("releases/latest"); string tagName = latestRelease.tag_name; - string version = tagName[0] == 'v' ? tagName[1..] : tagName; - var tag = WebUIHelper.DownloadApiResponse($"git/ref/tags/{version}"); + var version = tagName[0] == 'v' ? tagName[1..] : tagName; + var tag = WebUIHelper.DownloadApiResponse($"git/ref/tags/{tagName}"); string commit = tag["object"].sha; DateTime releaseDate = latestRelease.published_at; releaseDate = releaseDate.ToUniversalTime(); string description = latestRelease.body; - return Cache.Set<ComponentVersion>(key, new ComponentVersion + return _cache.Set(key, new ComponentVersion { Version = version, Commit = commit[0..7], @@ -480,7 +535,7 @@ public ActionResult<ComponentVersion> LatestWebUIVersion([FromQuery] ReleaseChan ReleaseDate = releaseDate, Tag = tagName, Description = description.Trim(), - }, CacheTTL); + }, _cacheTTL); } } } @@ -492,6 +547,9 @@ public ActionResult<ComponentVersion> LatestWebUIVersion([FromQuery] ReleaseChan } } + [GeneratedRegex(@"^[Vv]?(?<version>(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+))(?:-dev.(?<buildNumber>\d+))?$", RegexOptions.Compiled, "en-US")] + private static partial Regex ServerReleaseVersionRegex(); + /// <summary> /// Check for latest version for the selected <paramref name="channel"/> and /// return a <see cref="ComponentVersion"/> containing the version @@ -510,19 +568,40 @@ public ActionResult<ComponentVersion> LatestServerWebUIVersion([FromQuery] Relea if (channel == ReleaseChannel.Auto) channel = GetCurrentServerReleaseChannel(); var key = $"server:{channel}"; - if (!force && Cache.TryGetValue<ComponentVersion>(key, out var componentVersion)) - return componentVersion; + if (!force && _cache.TryGetValue<ComponentVersion>(key, out var componentVersion)) + return componentVersion!; switch (channel) { // Check for dev channel updates. case ReleaseChannel.Dev: { - var latestRelease = WebUIHelper.DownloadApiResponse("releases/latest", "shokoanime/shokoserver"); - var masterBranch = WebUIHelper.DownloadApiResponse("git/ref/heads/master", "shokoanime/shokoserver"); - string commitSha = masterBranch["object"].sha; - var latestCommit = WebUIHelper.DownloadApiResponse($"commits/{commitSha}", "shokoanime/shokoserver"); - string tagName = latestRelease.tag_name; - var version = tagName[1..] + ".0"; + var latestTags = WebUIHelper.DownloadApiResponse($"tags?per_page=100&page=1", WebUIHelper.ServerRepoName); + var version = string.Empty; + var tagName = string.Empty; + var commitSha = string.Empty; + var regex = ServerReleaseVersionRegex(); + foreach (var tagInfo in latestTags) + { + string localTagName = tagInfo.name; + if (regex.Match(localTagName) is { Success: true } regexResult) + { + tagName = localTagName; + commitSha = tagInfo.commit.sha; + version = regexResult.Groups["version"].Value; + if (regexResult.Groups["buildNumber"].Success) + version += "." + regexResult.Groups["buildNumber"].Value; + else + version += ".0"; + break; + } + } + + if (string.IsNullOrEmpty(commitSha)) + { + return BadRequest("Unable to locate the latest release to use."); + } + + var latestCommit = WebUIHelper.DownloadApiResponse($"commits/{commitSha}", WebUIHelper.ServerRepoName); DateTime releaseDate = latestCommit.commit.author.date; releaseDate = releaseDate.ToUniversalTime(); string description; @@ -534,23 +613,25 @@ public ActionResult<ComponentVersion> LatestServerWebUIVersion([FromQuery] Relea // We're not on the latest daily release. else if (!string.Equals(currentCommit, commitSha)) { - var diff = WebUIHelper.DownloadApiResponse($"compare/{commitSha}...{currentCommit}", "shokoanime/shokoserver"); + var diff = WebUIHelper.DownloadApiResponse($"compare/{commitSha}...{currentCommit}", WebUIHelper.ServerRepoName); var aheadBy = (int)diff.ahead_by; var behindBy = (int)diff.behind_by; description = $"You are currently {aheadBy} commits ahead and {behindBy} commits behind the latest daily release."; } // We're on the latest daily release. - else { + else + { description = "All caught up! You are running the latest daily release."; } - return Cache.Set(key, new ComponentVersion + return _cache.Set(key, new ComponentVersion { Version = version, Commit = commitSha, ReleaseChannel = ReleaseChannel.Dev, ReleaseDate = releaseDate, + Tag = tagName, Description = description, - }, CacheTTL); + }, _cacheTTL); } #if DEBUG @@ -570,30 +651,30 @@ public ActionResult<ComponentVersion> LatestServerWebUIVersion([FromQuery] Relea componentVersion.ReleaseChannel = ReleaseChannel.Debug; if (extraVersionDict.TryGetValue("date", out var dateText) && DateTime.TryParse(dateText, out var releaseDate)) componentVersion.ReleaseDate = releaseDate.ToUniversalTime(); - return Cache.Set<ComponentVersion>(key, componentVersion, CacheTTL); + return _cache.Set<ComponentVersion>(key, componentVersion, _cacheTTL); } #endif // Check for stable channel updates. default: { - var latestRelease = WebUIHelper.DownloadApiResponse("releases/latest", "shokoanime/shokoserver"); + var latestRelease = WebUIHelper.DownloadApiResponse("releases/latest", WebUIHelper.ServerRepoName); string tagName = latestRelease.tag_name; - var tagResponse = WebUIHelper.DownloadApiResponse($"git/ref/tags/{tagName}", "shokoanime/shokoserver"); + var tagResponse = WebUIHelper.DownloadApiResponse($"git/ref/tags/{tagName}", WebUIHelper.ServerRepoName); var version = tagName[1..] + ".0"; string commit = tagResponse["object"].sha; DateTime releaseDate = latestRelease.published_at; releaseDate = releaseDate.ToUniversalTime(); string description = latestRelease.body; - return Cache.Set(key, new ComponentVersion + return _cache.Set(key, new ComponentVersion { Version = version, Commit = commit, ReleaseChannel = ReleaseChannel.Stable, ReleaseDate = releaseDate, Tag = tagName, - Description = description.Trim() - }, CacheTTL); + Description = description.Trim(), + }, _cacheTTL); } } } @@ -609,7 +690,7 @@ private static ReleaseChannel GetCurrentWebUIReleaseChannel() { var webuiVersion = WebUIHelper.LoadWebUIVersionInfo(); if (webuiVersion != null) - return webuiVersion.debug ? ReleaseChannel.Debug : webuiVersion.package.Contains("-dev") ? ReleaseChannel.Dev : ReleaseChannel.Stable; + return webuiVersion.Debug ? ReleaseChannel.Debug : webuiVersion.Package.Contains("-dev") ? ReleaseChannel.Dev : ReleaseChannel.Stable; return GetCurrentServerReleaseChannel(); } diff --git a/Shoko.Server/API/v3/Helpers/APIv3_Extensions.cs b/Shoko.Server/API/v3/Helpers/APIv3_Extensions.cs new file mode 100644 index 000000000..0a6615e98 --- /dev/null +++ b/Shoko.Server/API/v3/Helpers/APIv3_Extensions.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; + +using ImageEntityType = Shoko.Plugin.Abstractions.Enums.ImageEntityType; +using TitleLanguage = Shoko.Plugin.Abstractions.DataModels.TitleLanguage; + +#nullable enable +namespace Shoko.Server.API.v3.Helpers; + +public static class APIv3_Extensions +{ + public static Role.CreatorRoleType ToCreatorRole(this TMDB_Movie_Crew crew) + => ToCreatorRole(crew.Department, crew.Job); + + public static Role.CreatorRoleType ToCreatorRole(this TMDB_Show_Crew crew) + => ToCreatorRole(crew.Department, crew.Job); + + public static Role.CreatorRoleType ToCreatorRole(this TMDB_Season_Crew crew) + => ToCreatorRole(crew.Department, crew.Job); + + public static Role.CreatorRoleType ToCreatorRole(this TMDB_Episode_Crew crew) + => ToCreatorRole(crew.Department, crew.Job); + + private static Role.CreatorRoleType ToCreatorRole(string department, string job) + => department switch + { + // TODO: Implement this. + _ => Role.CreatorRoleType.Staff, + }; + + public static IEnumerable<IImageMetadata> InLanguage(this IEnumerable<IImageMetadata> imageList, IReadOnlySet<TitleLanguage>? language = null) + => language != null && language.Count > 0 + ? imageList.Where(title => language.Contains(title.Language)) + : imageList; + + public static Images ToDto(this IEnumerable<IImageMetadata> imageList, IReadOnlySet<TitleLanguage>? language = null, bool includeDisabled = false, bool includeThumbnails = false, bool preferredImages = false, bool randomizeImages = false) + { + var images = new Images(); + if (includeThumbnails) + images.Thumbnails ??= []; + foreach (var image in imageList) + { + if (!includeDisabled && !image.IsEnabled) + continue; + + if (language != null && !language.Contains(image.Language)) + continue; + + var dto = new Image(image); + switch (image.ImageType) + { + case ImageEntityType.Poster: + images.Posters.Add(dto); + break; + case ImageEntityType.Banner: + images.Banners.Add(dto); + break; + case ImageEntityType.Backdrop: + images.Backdrops.Add(dto); + break; + case ImageEntityType.Logo: + images.Logos.Add(dto); + break; + case ImageEntityType.Thumbnail when includeThumbnails: + images.Thumbnails!.Add(dto); + break; + default: + break; + } + } + + if (preferredImages) + { + SetPreferredOrDefaultImage(images.Posters, randomizeImages); + SetPreferredOrDefaultImage(images.Backdrops, randomizeImages); + SetPreferredOrDefaultImage(images.Banners, randomizeImages); + SetPreferredOrDefaultImage(images.Logos, randomizeImages); + if (includeThumbnails) + SetPreferredOrDefaultImage(images.Thumbnails!, randomizeImages); + } + + return images; + } + + private static void SetPreferredOrDefaultImage(List<Image> images, bool randomizeImages = false) + { + var poster = randomizeImages + ? images.GetRandomElement() + : images.FirstOrDefault(i => i.Preferred) ?? images.FirstOrDefault(); + images.Clear(); + if (poster is not null) + images.Add(poster); + } + + public static IReadOnlyList<Title> ToDto(this IEnumerable<TMDB_Title> titles, string? mainTitle = null, TMDB_Title? preferredTitle = null, IReadOnlySet<TitleLanguage>? language = null) + { + if (language != null && language.Count > 0) + titles = titles.WhereInLanguages(language); + + return titles + .Select(title => new Title(title, mainTitle, preferredTitle)) + .OrderByDescending(title => title.Preferred) + .ThenByDescending(title => title.Default) + .ThenBy(title => title.Language) + .ToList(); + } + + public static IReadOnlyList<Overview> ToDto(this IEnumerable<TMDB_Overview> overviews, string? mainOverview = null, TMDB_Overview? preferredOverview = null, IReadOnlySet<TitleLanguage>? language = null) + { + if (language != null && language.Count > 0) + overviews = overviews.WhereInLanguages(language); + + return overviews + .Select(overview => new Overview(overview, mainOverview, preferredOverview)) + .OrderByDescending(overview => overview.Preferred) + .ThenByDescending(overview => overview.Default) + .ThenBy(overview => overview.Language) + .ToList(); + } + + public static IReadOnlyList<ContentRating> ToDto(this IEnumerable<TMDB_ContentRating> contentRatings, IReadOnlySet<TitleLanguage>? language = null) + { + if (language != null && language.Count > 0) + contentRatings = contentRatings.WhereInLanguages(language); + + return contentRatings + .Select(contentRating => new ContentRating(contentRating)) + .ToList(); + } +} diff --git a/Shoko.Server/API/v3/Helpers/FilterFactory.cs b/Shoko.Server/API/v3/Helpers/FilterFactory.cs index 47eb00e76..7340274d3 100644 --- a/Shoko.Server/API/v3/Helpers/FilterFactory.cs +++ b/Shoko.Server/API/v3/Helpers/FilterFactory.cs @@ -164,7 +164,7 @@ public Filter.FilterCondition GetExpressionTree(FilterExpression expression) return result; } - + public FilterExpression<T> GetExpressionTree<T>(Filter.FilterCondition condition) { if (condition is null) return null; @@ -261,7 +261,7 @@ public Filter.SortingCriteria GetSortingCriteria(SortingExpression expression) return result; } - + public SortingExpression GetSortingCriteria(Filter.SortingCriteria criteria) { if (!_sortingTypes.TryGetValue(criteria.Type, out var type)) @@ -273,7 +273,7 @@ public SortingExpression GetSortingCriteria(Filter.SortingCriteria criteria) return result; } - + public Filter.Input.CreateOrUpdateFilterBody GetPostModel(FilterPreset groupFilter) { var result = new Filter.Input.CreateOrUpdateFilterBody @@ -354,27 +354,4 @@ public FilterPreset GetFilterPreset(Filter.Input.CreateOrUpdateFilterBody filter return existing; } - - public Filter GetFirstAiringSeasonGroupFilter(SVR_AniDB_Anime anime) - { - var type = (AnimeType)anime.AnimeType; - if (type != AnimeType.TVSeries && type != AnimeType.Web) - return null; - - var (year, season) = anime.Seasons - .FirstOrDefault(); - if (year == 0) - return null; - - var seasonName = $"{season} {year}"; - var seasonsFilterID = RepoFactory.FilterPreset.GetTopLevel() - .FirstOrDefault(f => f.FilterType == (GroupFilterType.Directory | GroupFilterType.Season))?.FilterPresetID; - if (seasonsFilterID == null) return null; - var firstAirSeason = RepoFactory.FilterPreset.GetByParentID(seasonsFilterID.Value) - .FirstOrDefault(f => f.Name == seasonName); - if (firstAirSeason == null) - return null; - - return GetFilter(firstAirSeason); - } } diff --git a/Shoko.Server/API/v3/Helpers/ModelHelper.cs b/Shoko.Server/API/v3/Helpers/ModelHelper.cs index 4f44d3d03..8188ee6aa 100644 --- a/Shoko.Server/API/v3/Helpers/ModelHelper.cs +++ b/Shoko.Server/API/v3/Helpers/ModelHelper.cs @@ -6,17 +6,47 @@ using Shoko.Models.Enums; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.Models; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; using Shoko.Server.Repositories; + using File = Shoko.Server.API.v3.Models.Shoko.File; using FileSource = Shoko.Server.API.v3.Models.Shoko.FileSource; using GroupSizes = Shoko.Server.API.v3.Models.Shoko.GroupSizes; using SeriesSizes = Shoko.Server.API.v3.Models.Shoko.SeriesSizes; +using AniDBAnimeType = Shoko.Models.Enums.AnimeType; using SeriesType = Shoko.Server.API.v3.Models.Shoko.SeriesType; +#nullable enable namespace Shoko.Server.API.v3.Helpers; public static class ModelHelper { + public static T CombineFlags<T>(this IEnumerable<T> flags) where T : struct, Enum + { + T combinedFlags = default; + foreach (var flag in flags) + combinedFlags = CombineFlags(combinedFlags, flag); + return combinedFlags; + } + + private static T CombineFlags<T>(T a, T b) where T : Enum + => (T)Enum.ToObject(typeof(T), Convert.ToInt64(a) | Convert.ToInt64(b)); + + // Note: there is no `this` because if it's set then the compiler will + // complain that there is no `System.Enum` defined. + public static IEnumerable<T> UnCombineFlags<T>(T flags) where T : struct, Enum + { + var allValues = Enum.GetValues<T>(); + var flagLong = Convert.ToInt64(flags); + foreach (var value in allValues) + { + var valueLong = Convert.ToInt64(value); + if (valueLong != 0 && (flagLong & valueLong) == valueLong) + yield return value; + } + } + public static ListResult<T> ToListResult<T>(this IEnumerable<T> enumerable) { return new ListResult<T> @@ -55,7 +85,9 @@ public static ListResult<U> ToListResult<T, U>(this IEnumerable<T> enumerable, F return new ListResult<U> { Total = enumerable.Count(), - List = enumerable.ToList() + List = enumerable + .AsParallel() + .AsOrdered() .Select(mapper) .ToList() }; @@ -67,7 +99,8 @@ public static ListResult<U> ToListResult<T, U>(this IEnumerable<T> enumerable, F List = enumerable.AsQueryable() .Skip(pageSize * (page - 1)) .Take(pageSize) - .ToList() + .AsParallel() + .AsOrdered() .Select(mapper) .ToList() }; @@ -81,7 +114,9 @@ public static ListResult<U> ToListResult<T, U>(this IEnumerable<T> enumerable, F return new ListResult<U> { Total = total, - List = enumerable.ToList() + List = enumerable + .AsParallel() + .AsOrdered() .Select(mapper) .ToList() }; @@ -93,13 +128,90 @@ public static ListResult<U> ToListResult<T, U>(this IEnumerable<T> enumerable, F List = enumerable.AsQueryable() .Skip(pageSize * (page - 1)) .Take(pageSize) - .ToList() + .AsParallel() + .AsOrdered() .Select(mapper) .ToList() }; } - public static (int, EpisodeType?, string) GetEpisodeNumberAndTypeFromInput(string input) + public static List<List<CrossRef_AniDB_TMDB_Episode>> GroupByCrossReferenceType(this IEnumerable<CrossRef_AniDB_TMDB_Episode> episodes) + { + var episodeList = episodes is IReadOnlyList<CrossRef_AniDB_TMDB_Episode> readOnlyList ? readOnlyList : episodes.ToList(); + var remainingXrefs = new List<CrossRef_AniDB_TMDB_Episode>(); + var anidbEpisodeDictionary = episodeList + .DistinctBy(xref => xref.AnidbEpisodeID) + .Select(xref => (xref, anidb: xref.AnidbEpisode)) + .Where(tuple => tuple.anidb is not null) + .ToDictionary(tuple => tuple.xref.AnidbEpisodeID, tuple => tuple.anidb); + var anidbXrefs = episodeList + .Select(xref => (xref, anidb: anidbEpisodeDictionary.GetValueOrDefault(xref.AnidbEpisodeID))) + .Where(tuple => tuple.anidb is not null) + .OrderBy(tuple => tuple.anidb!.EpisodeTypeEnum) + .ThenBy(tuple => tuple.anidb!.EpisodeNumber) + .ThenBy(tuple => tuple.xref.Ordering) + .Select(tuple => tuple.xref) + .GroupBy(tuple => tuple.AnidbEpisodeID) + .Aggregate(new List<List<CrossRef_AniDB_TMDB_Episode>>(), (list, group) => + { + var grouped = group.ToList(); + if (grouped.Count > 1) + list.Add(grouped); + else + remainingXrefs.Add(grouped[0]); + return list; + }); + var tmdbXrefs = remainingXrefs + .Select(xref => (xref, tmdb: xref.TmdbEpisode, anidb: anidbEpisodeDictionary[xref.AnidbEpisodeID]!)) + .Where(tuple => tuple.tmdb is not null) + .OrderBy(tuple => tuple.tmdb!.SeasonNumber) + .ThenBy(tuple => tuple.tmdb!.EpisodeNumber) + .ThenBy(tuple => tuple.anidb.EpisodeTypeEnum) + .ThenBy(tuple => tuple.anidb.EpisodeNumber) + .Select(tuple => tuple.xref) + .GroupBy(tuple => tuple.TmdbEpisodeID) + .Aggregate(new List<List<CrossRef_AniDB_TMDB_Episode>>(), (list, group) => + { + var currentList = new List<CrossRef_AniDB_TMDB_Episode>(); + foreach (var xref in group) + { + if (currentList.Count > 0 && xref.Ordering == 0) + { + list.Add(currentList); + currentList = []; + } + currentList.Add(xref); + } + if (currentList.Count > 0) + list.Add(currentList); + return list; + }); + var allXrefs = anidbXrefs.Concat(tmdbXrefs) + .Select(xref => (xref, anidb: anidbEpisodeDictionary[xref[0].AnidbEpisodeID]!)) + .OrderBy(tuple => tuple.anidb.EpisodeTypeEnum) + .ThenBy(tuple => tuple.anidb.EpisodeNumber) + .ThenBy(tuple => tuple.xref[0].Ordering) + .Select(tuple => tuple.xref) + .ToList(); + return allXrefs; + } + + public static SeriesType ToAniDBSeriesType(this int animeType) + => ToAniDBSeriesType((AniDBAnimeType)animeType); + + public static SeriesType ToAniDBSeriesType(this AniDBAnimeType animeType) + => animeType switch + { + AniDBAnimeType.TVSeries => SeriesType.TV, + AniDBAnimeType.Movie => SeriesType.Movie, + AniDBAnimeType.OVA => SeriesType.OVA, + AniDBAnimeType.TVSpecial => SeriesType.TVSpecial, + AniDBAnimeType.Web => SeriesType.Web, + AniDBAnimeType.Other => SeriesType.Other, + _ => SeriesType.Unknown, + }; + + public static (int, EpisodeType?, string?) GetEpisodeNumberAndTypeFromInput(string input) { EpisodeType? episodeType = null; if (!int.TryParse(input, out var episodeNumber)) @@ -137,7 +249,7 @@ public static int GetTotalEpisodesForType(IEnumerable<SVR_AnimeEpisode> episodeL .Count(anidbEpisode => anidbEpisode != null && (EpisodeType)anidbEpisode.EpisodeType == episodeType); } - public static string ToDataURL(byte[] byteArray, string contentType, string fieldName = "ByteArrayToDataUrl", ModelStateDictionary modelState = null) + public static string? ToDataURL(byte[] byteArray, string contentType, string fieldName = "ByteArrayToDataUrl", ModelStateDictionary? modelState = null) { if (byteArray == null || string.IsNullOrEmpty(contentType)) { @@ -147,7 +259,7 @@ public static string ToDataURL(byte[] byteArray, string contentType, string fiel try { - string base64 = Convert.ToBase64String(byteArray); + var base64 = Convert.ToBase64String(byteArray); return $"data:{contentType};base64,{base64}"; } catch (Exception) @@ -157,9 +269,12 @@ public static string ToDataURL(byte[] byteArray, string contentType, string fiel } } - public static (byte[] byteArray, string contentType) FromDataURL(string dataUrl, string fieldName = "DataUrlToByteArray", ModelStateDictionary modelState = null) + private static readonly string[] _dataUrlSeparators = [":", ";", ","]; + + + public static (byte[]? byteArray, string? contentType) FromDataURL(string dataUrl, string fieldName = "DataUrlToByteArray", ModelStateDictionary? modelState = null) { - var parts = dataUrl.Split(new[] { ":", ";", "," }, StringSplitOptions.RemoveEmptyEntries); + var parts = dataUrl.Split(_dataUrlSeparators, StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 4 || parts[0] != "data") { modelState?.AddModelError(fieldName, $"Invalid data URL format for field '{fieldName}'."); @@ -364,7 +479,7 @@ public static GroupSizes GenerateGroupSizes(IEnumerable<SVR_AnimeSeries> seriesL foreach (var series in seriesList) { var anime = series.AniDB_Anime; - switch (SeriesFactory.GetAniDBSeriesType(anime?.AnimeType)) + switch (anime?.AnimeType.ToAniDBSeriesType()) { case SeriesType.Unknown: sizes.SeriesTypes.Unknown++; @@ -394,15 +509,16 @@ public static GroupSizes GenerateGroupSizes(IEnumerable<SVR_AnimeSeries> seriesL return sizes; } - public static ListResult<File> FilterFiles(IEnumerable<SVR_VideoLocal> input, SVR_JMMUser user, int pageSize, int page, FileNonDefaultIncludeType[] include, - FileExcludeTypes[] exclude, FileIncludeOnlyType[] include_only, List<string> sortOrder, HashSet<DataSource> includeDataFrom, bool skipSort = false) + public static ListResult<File> FilterFiles(IEnumerable<SVR_VideoLocal> input, SVR_JMMUser user, int pageSize, int page, FileNonDefaultIncludeType[]? include, + FileExcludeTypes[]? exclude, FileIncludeOnlyType[]? include_only, List<string>? sortOrder, HashSet<DataSource>? includeDataFrom, bool skipSort = false) { - include ??= Array.Empty<FileNonDefaultIncludeType>(); - exclude ??= Array.Empty<FileExcludeTypes>(); - include_only ??= Array.Empty<FileIncludeOnlyType>(); + include ??= []; + exclude ??= []; + include_only ??= []; var includeLocations = exclude.Contains(FileExcludeTypes.Duplicates) || - (sortOrder?.Any(criteria => criteria.Contains(File.FileSortCriteria.DuplicateCount.ToString())) ?? false); + include_only.Contains(FileIncludeOnlyType.Duplicates) || + (sortOrder?.Any(criteria => criteria.Contains(File.FileSortCriteria.DuplicateCount.ToString())) ?? false); var includeUserRecord = exclude.Contains(FileExcludeTypes.Watched) || (sortOrder?.Any(criteria => criteria.Contains(File.FileSortCriteria.ViewedAt.ToString()) || criteria.Contains(File.FileSortCriteria.WatchedAt.ToString())) ?? false); var enumerable = input @@ -417,9 +533,8 @@ public static ListResult<File> FilterFiles(IEnumerable<SVR_VideoLocal> input, SV var (video, _, locations, userRecord) = tuple; var xrefs = video.EpisodeCrossRefs; var isAnimeAllowed = xrefs - .Select(xref => xref.AnimeID) - .Distinct() - .Select(anidbID => RepoFactory.AniDB_Anime.GetByAnimeID(anidbID)) + .DistinctBy(xref => xref.AnimeID) + .Select(xref => xref.AniDBAnime) .WhereNotNull() .All(user.AllowedAnime); if (!isAnimeAllowed) @@ -429,13 +544,15 @@ public static ListResult<File> FilterFiles(IEnumerable<SVR_VideoLocal> input, SV if (!include_only.Contains(FileIncludeOnlyType.Ignored) && !include.Contains(FileNonDefaultIncludeType.Ignored) && video.IsIgnored) return false; if (include_only.Contains(FileIncludeOnlyType.Ignored) && !video.IsIgnored) return false; - if (exclude.Contains(FileExcludeTypes.Duplicates) && locations.Count > 1) return false; - if (include_only.Contains(FileIncludeOnlyType.Duplicates) && locations.Count <= 1) return false; + if (exclude.Contains(FileExcludeTypes.Duplicates) && locations!.Count > 1) return false; + if (include_only.Contains(FileIncludeOnlyType.Duplicates) && locations!.Count <= 1) return false; if (exclude.Contains(FileExcludeTypes.Unrecognized) && xrefs.Count == 0) return false; - if (include_only.Contains(FileIncludeOnlyType.Unrecognized) && xrefs.Count > 0 && xrefs.Any(x => - RepoFactory.AnimeSeries.GetByAnimeID(x.AnimeID) != null && - RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(x.EpisodeID) != null)) return false; + if (include_only.Contains(FileIncludeOnlyType.Unrecognized) && xrefs.Count > 0) return false; + + // this one is also special because files in import limbo are excluded by default + if (!include_only.Contains(FileIncludeOnlyType.ImportLimbo) && !include.Contains(FileNonDefaultIncludeType.ImportLimbo) && xrefs.Count > 0 && xrefs.Any(x => x.AniDBAnime is null || x.AniDBEpisode is null)) return false; + if (include_only.Contains(FileIncludeOnlyType.ImportLimbo) && !(xrefs.Count > 0 && xrefs.Any(x => x.AniDBAnime is null || x.AniDBEpisode is null))) return false; if (exclude.Contains(FileExcludeTypes.ManualLinks) && xrefs.Count > 0 && xrefs.All(xref => xref.CrossRefSource == (int)CrossRefSource.User)) return false; @@ -451,7 +568,7 @@ public static ListResult<File> FilterFiles(IEnumerable<SVR_VideoLocal> input, SV // Sorting. if (sortOrder != null && sortOrder.Count > 0) enumerable = File.OrderBy(enumerable, sortOrder); - else if (skipSort) + else if (!skipSort) enumerable = File.OrderBy(enumerable, new() { // First sort by import folder from A-Z. diff --git a/Shoko.Server/API/v3/Helpers/SeriesFactory.cs b/Shoko.Server/API/v3/Helpers/SeriesFactory.cs deleted file mode 100644 index 01aea39d4..000000000 --- a/Shoko.Server/API/v3/Helpers/SeriesFactory.cs +++ /dev/null @@ -1,922 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Quartz; -using Shoko.Commons.Extensions; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Plugin.Abstractions.DataModels; -using Shoko.Server.API.v3.Models.Common; -using Shoko.Server.API.v3.Models.Shoko; -using Shoko.Server.Models; -using Shoko.Server.Providers.AniDB.Titles; -using Shoko.Server.Repositories; -using Shoko.Server.Repositories.Cached; -using Shoko.Server.Scheduling; -using Shoko.Server.Scheduling.Jobs.AniDB; -using Shoko.Server.Scheduling.Jobs.TvDB; -using Shoko.Server.Utilities; -using AniDBAnimeType = Shoko.Models.Enums.AnimeType; -using AniDBEpisodeType = Shoko.Models.Enums.EpisodeType; -using Image = Shoko.Server.API.v3.Models.Common.Image; -using Series = Shoko.Server.API.v3.Models.Shoko.Series; - -namespace Shoko.Server.API.v3.Helpers; - -public class SeriesFactory -{ - private readonly HttpContext _context; - private readonly CrossRef_Anime_StaffRepository _crossRefAnimeStaffRepository; - private readonly AniDBTitleHelper _titleHelper; - private readonly AnimeCharacterRepository _animeCharacterRepository; - private readonly AnimeStaffRepository _animeStaffRepository; - private readonly CustomTagRepository _customTagRepository; - private readonly AniDB_TagRepository _aniDBTagRepository; - private readonly AniDB_Anime_TagRepository _aniDBAnimeTagRepository; - private readonly ISchedulerFactory _schedulerFactory; - private readonly JobFactory _jobFactory; - - public SeriesFactory(IHttpContextAccessor context, ISchedulerFactory schedulerFactory, JobFactory jobFactory, AniDBTitleHelper titleHelper) - { - _schedulerFactory = schedulerFactory; - _jobFactory = jobFactory; - _titleHelper = titleHelper; - _context = context.HttpContext; - _crossRefAnimeStaffRepository = RepoFactory.CrossRef_Anime_Staff; - _animeCharacterRepository = RepoFactory.AnimeCharacter; - _animeStaffRepository = RepoFactory.AnimeStaff; - _customTagRepository = RepoFactory.CustomTag; - _aniDBTagRepository = RepoFactory.AniDB_Tag; - _aniDBAnimeTagRepository = RepoFactory.AniDB_Anime_Tag; - } - - public Series GetSeries(SVR_AnimeSeries ser, bool randomiseImages = false, HashSet<DataSource> includeDataFrom = null) - { - var uid = _context.GetUser()?.JMMUserID ?? 0; - var anime = ser.AniDB_Anime; - var animeType = (AniDBAnimeType)anime.AnimeType; - - var result = new Series(); - AddBasicAniDBInfo(result, anime); - - var ael = ser.AnimeEpisodes; - - result.IDs = GetIDs(ser); - result.HasCustomName = !string.IsNullOrEmpty(ser.SeriesNameOverride); - result.Images = GetDefaultImages(ser, randomiseImages); - result.AirsOn = animeType == AniDBAnimeType.TVSeries || animeType == AniDBAnimeType.Web ? GetAirsOnDaysOfWeek(ael) : new(); - - result.Name = ser.SeriesName; - result.Sizes = ModelHelper.GenerateSeriesSizes(ael, uid); - result.Size = result.Sizes.Local.Credits + result.Sizes.Local.Episodes + result.Sizes.Local.Others + result.Sizes.Local.Parodies + - result.Sizes.Local.Specials + result.Sizes.Local.Trailers; - - result.Created = ser.DateTimeCreated.ToUniversalTime(); - result.Updated = ser.DateTimeUpdated.ToUniversalTime(); - - if (includeDataFrom?.Contains(DataSource.AniDB) ?? false) - { - result._AniDB = GetAniDB(anime, ser); - } - if (includeDataFrom?.Contains(DataSource.TvDB) ?? false) - result._TvDB = GetTvDBInfo(ser); - - return result; - } - - /// <summary> - /// Get the most recent days in the week the show airs on. - /// </summary> - /// <param name="animeEpisodes">Optionally pass in the episodes so we don't have to fetch them.</param> - /// <param name="includeThreshold">Threshold of episodes to include in the calculation.</param> - /// <returns></returns> - public List<DayOfWeek> GetAirsOnDaysOfWeek(IEnumerable<SVR_AnimeEpisode> animeEpisodes, int includeThreshold = 24) - { - var now = DateTime.Now; - var filteredEpisodes = animeEpisodes - .Select(episode => - { - var aniDB = episode.AniDB_Episode; - var airDate = aniDB.GetAirDateAsDate(); - return (episode, aniDB, airDate); - }) - .Where(tuple => - { - // We ignore all other types except the "normal" type. - if ((AniDBEpisodeType)tuple.aniDB.EpisodeType != AniDBEpisodeType.Episode) - return false; - - // We ignore any unknown air dates and dates in the future. - if (!tuple.airDate.HasValue || tuple.airDate.Value > now) - return false; - - return true; - }) - .ToList(); - - // Threshold used to filter out outliners, e.g. a weekday that only happens - // once or twice for whatever reason, or when a show gets an early preview, - // an episode moving, etc... - var outlierThreshold = Math.Min((int)Math.Ceiling(filteredEpisodes.Count / 12D), 4); - return filteredEpisodes - .OrderByDescending(tuple => tuple.aniDB.EpisodeNumber) - // We check up to the `x` last aired episodes to get a grasp on which days - // it airs on. This helps reduce variance in days for long-running - // shows, such as One Piece, etc... - .Take(includeThreshold) - .Select(tuple => tuple.airDate.Value.DayOfWeek) - .GroupBy(weekday => weekday) - .Where(list => list.Count() > outlierThreshold) - .Select(list => list.Key) - .OrderBy(weekday => weekday) - .ToList(); - } - - private void AddBasicAniDBInfo(Series result, SVR_AniDB_Anime anime) - { - if (anime == null) - { - return; - } - - result.Links = new(); - if (!string.IsNullOrEmpty(anime.Site_EN)) - foreach (var site in anime.Site_EN.Split('|')) - result.Links.Add(new() { Type = "source", Name = "Official Site (EN)", URL = site }); - - if (!string.IsNullOrEmpty(anime.Site_JP)) - foreach (var site in anime.Site_JP.Split('|')) - result.Links.Add(new() { Type = "source", Name = "Official Site (JP)", URL = site }); - - if (!string.IsNullOrEmpty(anime.Wikipedia_ID)) - result.Links.Add(new() { Type = "wiki", Name = "Wikipedia (EN)", URL = $"https://en.wikipedia.org/{anime.Wikipedia_ID}" }); - - if (!string.IsNullOrEmpty(anime.WikipediaJP_ID)) - result.Links.Add(new() { Type = "wiki", Name = "Wikipedia (JP)", URL = $"https://en.wikipedia.org/{anime.WikipediaJP_ID}" }); - - if (!string.IsNullOrEmpty(anime.CrunchyrollID)) - result.Links.Add(new() { Type = "streaming", Name = "Crunchyroll", URL = $"https://crunchyroll.com/anime/{anime.CrunchyrollID}" }); - - if (!string.IsNullOrEmpty(anime.FunimationID)) - result.Links.Add(new() { Type = "streaming", Name = "Funimation", URL = anime.FunimationID }); - - if (!string.IsNullOrEmpty(anime.HiDiveID)) - result.Links.Add(new() { Type = "streaming", Name = "HiDive", URL = $"https://www.hidive.com/{anime.HiDiveID}" }); - - if (anime.AllCinemaID.HasValue && anime.AllCinemaID.Value > 0) - result.Links.Add(new() { Type = "foreign-metadata", Name = "allcinema", URL = $"https://allcinema.net/cinema/{anime.AllCinemaID.Value}" }); - - if (anime.AnisonID.HasValue && anime.AnisonID.Value > 0) - result.Links.Add(new() { Type = "foreign-metadata", Name = "Anison", URL = $"https://anison.info/data/program/{anime.AnisonID.Value}.html" }); - - if (anime.SyoboiID.HasValue && anime.SyoboiID.Value > 0) - result.Links.Add(new() { Type = "foreign-metadata", Name = "syoboi", URL = $"https://cal.syoboi.jp/tid/{anime.SyoboiID.Value}/time" }); - - if (anime.BangumiID.HasValue && anime.BangumiID.Value > 0) - result.Links.Add(new() { Type = "foreign-metadata", Name = "bangumi", URL = $"https://bgm.tv/subject/{anime.BangumiID.Value}" }); - - if (anime.LainID.HasValue && anime.LainID.Value > 0) - result.Links.Add(new() { Type = "foreign-metadata", Name = ".lain", URL = $"http://lain.gr.jp/mediadb/media/{anime.LainID.Value}" }); - - if (anime.ANNID.HasValue && anime.ANNID.Value > 0) - result.Links.Add(new() { Type = "english-metadata", Name = "AnimeNewsNetwork", URL = $"https://www.animenewsnetwork.com/encyclopedia/anime.php?id={anime.ANNID.Value}" }); - - if (anime.VNDBID.HasValue && anime.VNDBID.Value > 0) - result.Links.Add(new() { Type = "english-metadata", Name = "VNDB", URL = $"https://vndb.org/v{anime.VNDBID.Value}" }); - - var vote = RepoFactory.AniDB_Vote.GetByEntityAndType(anime.AnimeID, AniDBVoteType.Anime) ?? - RepoFactory.AniDB_Vote.GetByEntityAndType(anime.AnimeID, AniDBVoteType.AnimeTemp); - if (vote != null) - { - var voteType = (AniDBVoteType)vote.VoteType == AniDBVoteType.Anime ? "Permanent" : "Temporary"; - result.UserRating = new Rating - { - Value = (decimal)Math.Round(vote.VoteValue / 100D, 1), - MaxValue = 10, - Type = voteType, - Source = "User" - }; - } - } - - public async Task<bool> QueueAniDBRefresh(ISchedulerFactory schedulerFactory, JobFactory jobFactory, - int animeID, bool force, bool downloadRelations, bool createSeriesEntry, bool immediate = false, - bool cacheOnly = false) - { - if (animeID == 0) return false; - if (immediate) - { - var command = jobFactory.CreateJob<GetAniDBAnimeJob>(c => - { - c.AnimeID = animeID; - c.DownloadRelations = downloadRelations; - c.ForceRefresh = force; - c.CacheOnly = !force && cacheOnly; - c.CreateSeriesEntry = createSeriesEntry; - }); - - try - { - return await command.Process() != null; - } - catch - { - return false; - } - } - - var scheduler = await schedulerFactory.GetScheduler(); - await scheduler.StartJob<GetAniDBAnimeJob>(c => - { - c.AnimeID = animeID; - c.DownloadRelations = downloadRelations; - c.ForceRefresh = force; - c.CacheOnly = !force && cacheOnly; - c.CreateSeriesEntry = createSeriesEntry; - }); - return false; - } - - public async Task<bool> QueueTvDBRefresh(int tvdbID, bool force, bool immediate = false) - { - if (immediate) - { - try - { - var job = _jobFactory.CreateJob<GetTvDBSeriesJob>(c => - { - c.TvDBSeriesID = tvdbID; - c.ForceRefresh = force; - }); - return await job.Process() != null; - } - catch - { - return false; - } - } - - var scheduler = await _schedulerFactory.GetScheduler(); - await scheduler.StartJob<GetTvDBSeriesJob>(c => - { - c.TvDBSeriesID = tvdbID; - c.ForceRefresh = force; - }); - return false; - } - - public static SeriesIDs GetIDs(SVR_AnimeSeries ser) - { - // Shoko - var ids = new SeriesIDs - { - ID = ser.AnimeSeriesID, - ParentGroup = ser.AnimeGroupID, - TopLevelGroup = ser.TopLevelAnimeGroup?.AnimeGroupID ?? 0 - }; - - // AniDB - var anidbId = ser.AniDB_Anime?.AnimeID; - if (anidbId.HasValue) - { - ids.AniDB = anidbId.Value; - } - - // TvDB - var tvdbIds = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(ser.AniDB_ID); - if (tvdbIds.Any()) - { - ids.TvDB.AddRange(tvdbIds.Select(a => a.TvDBID).Distinct()); - } - - // TODO: Cache the rest of these, so that they don't severely slow down the API - - // TMDB - // TODO: make this able to support more than one, in fact move it to its own and remove CrossRef_Other - var tmdbId = RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(ser.AniDB_ID, CrossRefType.MovieDB); - if (tmdbId != null && int.TryParse(tmdbId.CrossRefID, out var movieID)) - { - ids.TMDB.Add(movieID); - } - - // Trakt - // var traktIds = RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(ser.AniDB_ID).Select(a => a.TraktID) - // .Distinct().ToList(); - // if (traktIds.Any()) ids.TraktTv.AddRange(traktIds); - - // MAL - var malIds = RepoFactory.CrossRef_AniDB_MAL.GetByAnimeID(ser.AniDB_ID).Select(a => a.MALID).Distinct() - .ToList(); - if (malIds.Any()) - { - ids.MAL.AddRange(malIds); - } - - // TODO: AniList later - return ids; - } - - public static Image GetDefaultImage(int anidbId, ImageSizeType imageSizeType, - ImageEntityType? imageEntityType = null) - { - var defaultImage = imageEntityType.HasValue - ? RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeTypeAndImageEntityType(anidbId, - imageSizeType, imageEntityType.Value) - : RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(anidbId, imageSizeType); - return defaultImage != null - ? new Image(defaultImage.ImageParentID, (ImageEntityType)defaultImage.ImageParentType, true) - : null; - } - - public Images GetDefaultImages(SVR_AnimeSeries ser, bool randomiseImages = false) - { - var images = new Images(); - var random = _context.Items["Random"] as Random; - var allImages = GetArt(ser.AniDB_ID); - - var poster = randomiseImages - ? allImages.Posters.GetRandomElement(random) - : GetDefaultImage(ser.AniDB_ID, ImageSizeType.Poster) ?? allImages.Posters.FirstOrDefault(); - if (poster != null) - { - images.Posters.Add(poster); - } - - var fanart = randomiseImages - ? allImages.Fanarts.GetRandomElement(random) - : GetDefaultImage(ser.AniDB_ID, ImageSizeType.Fanart) ?? allImages.Fanarts.FirstOrDefault(); - if (fanart != null) - { - images.Fanarts.Add(fanart); - } - - var banner = randomiseImages - ? allImages.Banners.GetRandomElement(random) - : GetDefaultImage(ser.AniDB_ID, ImageSizeType.WideBanner) ?? allImages.Banners.FirstOrDefault(); - if (banner != null) - { - images.Banners.Add(banner); - } - - return images; - } - - public List<Series.TvDB> GetTvDBInfo(SVR_AnimeSeries ser) - { - var ael = ser.AnimeEpisodes; - return RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(ser.AniDB_ID) - .Select(xref => RepoFactory.TvDB_Series.GetByTvDBID(xref.TvDBID)) - .Select(tvdbSer => GetTvDB(tvdbSer, ser, ael)) - .ToList(); - } - - public static async Task AddSeriesVote(ISchedulerFactory schedulerFactory, SVR_AnimeSeries ser, int userID, Vote vote) - { - var voteType = (vote.Type?.ToLowerInvariant() ?? "") switch - { - "temporary" => AniDBVoteType.AnimeTemp, - "permanent" => AniDBVoteType.Anime, - _ => ser.AniDB_Anime?.GetFinishedAiring() ?? false ? AniDBVoteType.Anime : AniDBVoteType.AnimeTemp - }; - - var dbVote = RepoFactory.AniDB_Vote.GetByEntityAndType(ser.AniDB_ID, AniDBVoteType.AnimeTemp) ?? - RepoFactory.AniDB_Vote.GetByEntityAndType(ser.AniDB_ID, AniDBVoteType.Anime); - - if (dbVote == null) - { - dbVote = new AniDB_Vote { EntityID = ser.AniDB_ID }; - } - - dbVote.VoteValue = (int)Math.Floor(vote.GetRating(1000)); - dbVote.VoteType = (int)voteType; - - RepoFactory.AniDB_Vote.Save(dbVote); - - var scheduler = await schedulerFactory.GetScheduler(); - await scheduler.StartJob<VoteAniDBAnimeJob>(c => - { - c.AnimeID = ser.AniDB_ID; - c.VoteType = voteType; - c.VoteValue = vote.GetRating(); - } - ); - } - - public static Images GetArt(int animeID, bool includeDisabled = false) - { - var images = new Images(); - AddAniDBPoster(images, animeID); - AddTvDBImages(images, animeID, includeDisabled); - // AddMovieDBImages(ctx, images, animeID, includeDisabled); - return images; - } - - private static void AddAniDBPoster(Images images, int animeID) - { - images.Posters.Add(GetAniDBPoster(animeID)); - } - - public static Image GetAniDBPoster(int animeID) - { - var defaultImage = RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(animeID, - ImageSizeType.Poster); - var preferred = defaultImage != null && defaultImage.ImageParentType == (int)ImageEntityType.AniDB_Cover; - return new Image(animeID, ImageEntityType.AniDB_Cover, preferred); - } - - private static void AddTvDBImages(Images images, int animeID, bool includeDisabled = false) - { - var tvdbIDs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(animeID).ToList(); - - var defaultFanart = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeTypeAndImageEntityType(animeID, - ImageSizeType.Fanart, ImageEntityType.TvDB_FanArt); - var fanarts = tvdbIDs.SelectMany(a => RepoFactory.TvDB_ImageFanart.GetBySeriesID(a.TvDBID)).ToList(); - images.Fanarts.AddRange(fanarts.Where(a => includeDisabled || a.Enabled != 0).Select(a => - { - var preferred = defaultFanart != null && defaultFanart.ImageParentID == a.TvDB_ImageFanartID; - return new Image(a.TvDB_ImageFanartID, ImageEntityType.TvDB_FanArt, preferred, a.Enabled == 0); - })); - - var defaultBanner = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeTypeAndImageEntityType(animeID, - ImageSizeType.WideBanner, ImageEntityType.TvDB_Banner); - var banners = tvdbIDs.SelectMany(a => RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(a.TvDBID)).ToList(); - images.Banners.AddRange(banners.Where(a => includeDisabled || a.Enabled != 0).Select(a => - { - var preferred = defaultBanner != null && defaultBanner.ImageParentID == a.TvDB_ImageWideBannerID; - return new Image(a.TvDB_ImageWideBannerID, ImageEntityType.TvDB_Banner, preferred, a.Enabled == 0); - })); - - var defaultPoster = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeTypeAndImageEntityType(animeID, - ImageSizeType.Poster, ImageEntityType.TvDB_Cover); - var posters = tvdbIDs.SelectMany(a => RepoFactory.TvDB_ImagePoster.GetBySeriesID(a.TvDBID)).ToList(); - images.Posters.AddRange(posters.Where(a => includeDisabled || a.Enabled != 0).Select(a => - { - var preferred = defaultPoster != null && defaultPoster.ImageParentID == a.TvDB_ImagePosterID; - return new Image(a.TvDB_ImagePosterID, ImageEntityType.TvDB_Cover, preferred, a.Enabled == 0); - })); - } - - private static void AddMovieDBImages(Images images, int animeID, bool includeDisabled = false) - { - var moviedb = RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(animeID, CrossRefType.MovieDB); - - var moviedbPosters = moviedb == null - ? new List<MovieDB_Poster>() - : RepoFactory.MovieDB_Poster.GetByMovieID(int.Parse(moviedb.CrossRefID)); - var defaultPoster = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeTypeAndImageEntityType(animeID, - ImageSizeType.Poster, ImageEntityType.MovieDB_Poster); - images.Posters.AddRange(moviedbPosters.Where(a => includeDisabled || a.Enabled != 0).Select(a => - { - var preferred = defaultPoster != null && defaultPoster.ImageParentID == a.MovieDB_PosterID; - return new Image(a.MovieDB_PosterID, ImageEntityType.MovieDB_Poster, preferred, a.Enabled == 1); - })); - - var moviedbFanarts = moviedb == null - ? new List<MovieDB_Fanart>() - : RepoFactory.MovieDB_Fanart.GetByMovieID(int.Parse(moviedb.CrossRefID)); - var defaultFanart = - RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeTypeAndImageEntityType(animeID, - ImageSizeType.Fanart, ImageEntityType.MovieDB_FanArt); - images.Fanarts.AddRange(moviedbFanarts.Where(a => includeDisabled || a.Enabled != 0).Select(a => - { - var preferred = defaultFanart != null && defaultFanart.ImageParentID == a.MovieDB_FanartID; - return new Image(a.MovieDB_FanartID, ImageEntityType.MovieDB_FanArt, preferred, a.Enabled == 1); - })); - } - - public Series.AniDB GetAniDB(SVR_AniDB_Anime anime, SVR_AnimeSeries series = null, bool includeTitles = true) - { - series ??= RepoFactory.AnimeSeries.GetByAnimeID(anime.AnimeID); - var seriesTitle = series?.SeriesName ?? anime.PreferredTitle; - var result = new Series.AniDB - { - ID = anime.AnimeID, - ShokoID = series?.AnimeSeriesID, - Type = GetAniDBSeriesType(anime.AnimeType), - Title = seriesTitle, - Titles = includeTitles - ? anime.Titles.Select(title => new Title - { - Name = title.Title, - Language = title.LanguageCode, - Type = title.TitleType, - Default = string.Equals(title.Title, seriesTitle), - Source = "AniDB" - } - ).ToList() - : null, - Description = anime.Description, - Restricted = anime.Restricted == 1, - Poster = GetAniDBPoster(anime.AnimeID), - EpisodeCount = anime.EpisodeCountNormal, - Rating = new Rating - { - Source = "AniDB", Value = anime.Rating, MaxValue = 1000, Votes = anime.VoteCount - }, - UserApproval = null, - Relation = null, - }; - - if (anime.AirDate.HasValue) result.AirDate = anime.AirDate.Value; - if (anime.EndDate.HasValue) result.EndDate = anime.EndDate.Value; - - return result; - } - - public Series.AniDB GetAniDB(ResponseAniDBTitles.Anime result, SVR_AnimeSeries series = null, bool includeTitles = false) - { - if (series == null) - { - series = RepoFactory.AnimeSeries.GetByAnimeID(result.AnimeID); - } - - var anime = series != null ? series.AniDB_Anime : RepoFactory.AniDB_Anime.GetByAnimeID(result.AnimeID); - - var animeTitle = series?.SeriesName ?? anime?.PreferredTitle ?? result.PreferredTitle; - var anidb = new Series.AniDB - { - ID = result.AnimeID, - ShokoID = series?.AnimeSeriesID, - Type = GetAniDBSeriesType(anime?.AnimeType), - Title = animeTitle, - Titles = includeTitles - ? result.Titles.Select(title => new Title - { - Language = title.LanguageCode, - Name = title.Title, - Type = title.TitleType, - Default = string.Equals(title.Title, animeTitle), - Source = "AniDB" - } - ).ToList() - : null, - Description = anime?.Description, - Restricted = anime is { Restricted: 1 }, - EpisodeCount = anime?.EpisodeCount, - Poster = GetAniDBPoster(result.AnimeID), - }; - if (anime?.AirDate != null) anidb.AirDate = anime.AirDate.Value; - if (anime?.EndDate != null) anidb.EndDate = anime.EndDate.Value; - return anidb; - } - - public Series.AniDB GetAniDB(SVR_AniDB_Anime_Relation relation, SVR_AnimeSeries series = null, bool includeTitles = true) - { - series ??= RepoFactory.AnimeSeries.GetByAnimeID(relation.RelatedAnimeID); - var result = new Series.AniDB - { - ID = relation.RelatedAnimeID, - ShokoID = series?.AnimeSeriesID, - Poster = GetAniDBPoster(relation.RelatedAnimeID), - Rating = null, - UserApproval = null, - Relation = ((IRelatedMetadata)relation).RelationType, - }; - SetAniDBTitles(result, relation, series, includeTitles); - return result; - } - - private void SetAniDBTitles(Series.AniDB aniDB, AniDB_Anime_Relation relation, SVR_AnimeSeries series, bool includeTitles) - { - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(relation.RelatedAnimeID); - if (anime is not null) - { - aniDB.Type = GetAniDBSeriesType(anime.AnimeType); - aniDB.Title = series?.SeriesName ?? anime.PreferredTitle; - aniDB.Titles = includeTitles - ? anime.Titles.Select( - title => new Title - { - Name = title.Title, - Language = title.LanguageCode, - Type = title.TitleType, - Default = string.Equals(title.Title, aniDB.Title), - Source = "AniDB" - } - ).ToList() - : null; - aniDB.Description = anime.Description; - aniDB.Restricted = anime.Restricted == 1; - aniDB.EpisodeCount = anime.EpisodeCountNormal; - return; - } - - var result = _titleHelper.SearchAnimeID(relation.RelatedAnimeID); - if (result != null) - { - aniDB.Type = SeriesType.Unknown; - aniDB.Title = result.PreferredTitle; - aniDB.Titles = includeTitles - ? result.Titles.Select( - title => new Title - { - Language = title.LanguageCode, - Name = title.Title, - Type = title.TitleType, - Default = string.Equals(title.Title, aniDB.Title), - Source = "AniDB" - } - ).ToList() - : null; - aniDB.Description = null; - // If the other anime is present we assume they're of the same kind. Be it restricted or unrestricted. - anime = RepoFactory.AniDB_Anime.GetByAnimeID(relation.AnimeID); - aniDB.Restricted = anime is not null && anime.Restricted == 1; - return; - } - - aniDB.Type = SeriesType.Unknown; - aniDB.Titles = includeTitles ? new List<Title>() : null; - aniDB.Restricted = false; - } - - public Series.AniDB GetAniDB(AniDB_Anime_Similar similar, SVR_AnimeSeries series = null, bool includeTitles = true) - { - series ??= RepoFactory.AnimeSeries.GetByAnimeID(similar.SimilarAnimeID); - var result = new Series.AniDB - { - ID = similar.SimilarAnimeID, - ShokoID = series?.AnimeSeriesID, - Poster = GetAniDBPoster(similar.SimilarAnimeID), - Rating = null, - UserApproval = new Rating - { - Value = new Vote(similar.Approval, similar.Total).GetRating(100), - MaxValue = 100, - Votes = similar.Total, - Source = "AniDB", - Type = "User Approval" - }, - Relation = null, - Restricted = false, - }; - SetAniDBTitles(result, similar, series, includeTitles); - return result; - } - - private void SetAniDBTitles(Series.AniDB aniDB, AniDB_Anime_Similar similar, SVR_AnimeSeries series, bool includeTitles) - { - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(similar.SimilarAnimeID); - if (anime is not null) - { - aniDB.Type = GetAniDBSeriesType(anime.AnimeType); - aniDB.Title = series?.SeriesName ?? anime.PreferredTitle; - aniDB.Titles = includeTitles - ? anime.Titles.Select( - title => new Title - { - Name = title.Title, - Language = title.LanguageCode, - Type = title.TitleType, - Default = string.Equals(title.Title, aniDB.Title), - Source = "AniDB" - } - ).ToList() - : null; - aniDB.Description = anime.Description; - aniDB.Restricted = anime.Restricted == 1; - return; - } - - var result = _titleHelper.SearchAnimeID(similar.SimilarAnimeID); - if (result != null) - { - aniDB.Type = SeriesType.Unknown; - aniDB.Title = result.PreferredTitle; - aniDB.Titles = includeTitles - ? result.Titles.Select( - title => new Title - { - Language = title.LanguageCode, - Name = title.Title, - Type = title.TitleType, - Default = string.Equals(title.Title, aniDB.Title), - Source = "AniDB" - } - ).ToList() - : null; - aniDB.Description = null; - // If the other anime is present we assume they're of the same kind. Be it restricted or unrestricted. - anime = RepoFactory.AniDB_Anime.GetByAnimeID(similar.AnimeID); - aniDB.Restricted = anime is not null && anime.Restricted == 1; - return; - } - - aniDB.Type = SeriesType.Unknown; - aniDB.Title = null; - aniDB.Titles = includeTitles ? new List<Title>() : null; - aniDB.Description = null; - aniDB.Restricted = false; - } - - /// <summary> - /// Cast is aggregated, and therefore not in each provider - /// </summary> - /// <param name="animeID"></param> - /// <param name="roleTypes"></param> - /// <returns></returns> - public List<Role> GetCast(int animeID, HashSet<Role.CreatorRoleType> roleTypes = null) - { - var roles = new List<Role>(); - var xrefAnimeStaff = _crossRefAnimeStaffRepository.GetByAnimeID(animeID); - foreach (var xref in xrefAnimeStaff) - { - // Filter out any roles that are not of the desired type. - if (roleTypes != null && !roleTypes.Contains((Role.CreatorRoleType)xref.RoleType)) - continue; - - var character = xref.RoleID.HasValue ? _animeCharacterRepository.GetByID(xref.RoleID.Value) : null; - var staff = _animeStaffRepository.GetByID(xref.StaffID); - if (staff == null) - continue; - - var role = new Role - { - Character = - character != null - ? new Role.Person - { - Name = character.Name, - AlternateName = character.AlternateName, - Image = new Image(character.CharacterID, ImageEntityType.Character), - Description = character.Description - } - : null, - Staff = new Role.Person - { - Name = staff.Name, - AlternateName = staff.AlternateName, - Description = staff.Description, - Image = staff.ImagePath != null ? new Image(staff.StaffID, ImageEntityType.Staff) : null - }, - RoleName = (Role.CreatorRoleType)xref.RoleType, - RoleDetails = xref.Role - }; - roles.Add(role); - } - - return roles; - } - - public List<Tag> GetTags(SVR_AniDB_Anime anime, TagFilter.Filter filter, - bool excludeDescriptions = false, bool orderByName = false, bool onlyVerified = true) - { - // Only get the user tags if we don't exclude it (false == false), or if we invert the logic and want to include it (true == true). - IEnumerable<Tag> userTags = new List<Tag>(); - if (filter.HasFlag(TagFilter.Filter.User) == filter.HasFlag(TagFilter.Filter.Invert)) - { - userTags = _customTagRepository.GetByAnimeID(anime.AnimeID) - .Select(tag => new Tag(tag, excludeDescriptions)); - } - - var selectedTags = anime.GetAniDBTags(onlyVerified) - .DistinctBy(a => a.TagName) - .ToList(); - var tagFilter = new TagFilter<AniDB_Tag>(name => _aniDBTagRepository.GetByName(name).FirstOrDefault(), tag => tag.TagName, - name => new AniDB_Tag { TagNameSource = name }); - var anidbTags = tagFilter - .ProcessTags(filter, selectedTags) - .Select(tag => - { - var xref = _aniDBAnimeTagRepository.GetByTagID(tag.TagID).FirstOrDefault(xref => xref.AnimeID == anime.AnimeID); - return new Tag(tag, excludeDescriptions) { Weight = xref?.Weight ?? 0, IsLocalSpoiler = xref?.LocalSpoiler }; - }); - - if (orderByName) - return userTags.Concat(anidbTags) - .OrderByDescending(tag => tag.Source) - .ThenBy(tag => tag.Name) - .ToList(); - - return userTags.Concat(anidbTags) - .OrderByDescending(tag => tag.Source) - .ThenByDescending(tag => tag.Weight) - .ThenBy(tag => tag.Name) - .ToList(); - } - - public static SeriesType GetAniDBSeriesType(int? animeType) - { - return animeType.HasValue ? GetAniDBSeriesType((AniDBAnimeType)animeType.Value) : SeriesType.Unknown; - } - - public static SeriesType GetAniDBSeriesType(AniDBAnimeType animeType) - { - switch (animeType) - { - default: - case AniDBAnimeType.None: - return SeriesType.Unknown; - case AniDBAnimeType.TVSeries: - return SeriesType.TV; - case AniDBAnimeType.Movie: - return SeriesType.Movie; - case AniDBAnimeType.OVA: - return SeriesType.OVA; - case AniDBAnimeType.TVSpecial: - return SeriesType.TVSpecial; - case AniDBAnimeType.Web: - return SeriesType.Web; - case AniDBAnimeType.Other: - return SeriesType.Other; - } - } - - public Series.TvDB GetTvDB(TvDB_Series tbdbSeries, SVR_AnimeSeries series, - IEnumerable<SVR_AnimeEpisode> episodeList = null) - { - episodeList ??= series.AnimeEpisodes; - - var images = new Images(); - AddTvDBImages(images, series.AniDB_ID); - - // Aggregate stuff - var firstEp = episodeList.FirstOrDefault(a => - a.AniDB_Episode != null && a.AniDB_Episode.EpisodeType == (int)AniDBEpisodeType.Episode && - a.AniDB_Episode.EpisodeNumber == 1) - ?.TvDBEpisode; - - var lastEp = episodeList - .Where(a => a.AniDB_Episode != null && a.AniDB_Episode.EpisodeType == (int)AniDBEpisodeType.Episode) - .OrderBy(a => a.AniDB_Episode.EpisodeType) - .ThenBy(a => a.AniDB_Episode.EpisodeNumber).LastOrDefault() - ?.TvDBEpisode; - - var result = new Series.TvDB - { - ID = tbdbSeries.SeriesID, - Description = tbdbSeries.Overview, - Title = tbdbSeries.SeriesName, - Posters = images.Posters, - Fanarts = images.Fanarts, - Banners = images.Banners, - Season = firstEp?.SeasonNumber, - AirDate = firstEp?.AirDate, - EndDate = lastEp?.AirDate, - }; - if (tbdbSeries.Rating != null) - { - result.Rating = new Rating { Source = "TvDB", Value = tbdbSeries.Rating.Value, MaxValue = 10 }; - } - - return result; - } - - public SeriesSearchResult GetSeriesSearchResult(SeriesSearch.SearchResult<SVR_AnimeSeries> result) - { - var series = GetSeries(result.Result); - var searchResult = new SeriesSearchResult - { - Name = series.Name, - IDs = series.IDs, - Size = series.Size, - Sizes = series.Sizes, - Created = series.Created, - Updated = series.Updated, - AirsOn = series.AirsOn, - UserRating = series.UserRating, - Images = series.Images, - Links = series.Links, - _AniDB = series._AniDB, - _TvDB = series._TvDB, - ExactMatch = result.ExactMatch, - Index = result.Index, - Distance = result.Distance, - LengthDifference = result.LengthDifference, - Match = result.Match, - }; - return searchResult; - } - - public SeriesWithMultipleReleasesResult GetSeriesWithMultipleReleasesResult( - SVR_AnimeSeries animeSeries, - bool randomiseImages = false, - HashSet<DataSource> includeDataFrom = null, - bool ignoreVariations = true) - { - var series = GetSeries(animeSeries, randomiseImages, includeDataFrom); - var episodesWithMultipleReleases = RepoFactory.AnimeEpisode.GetWithMultipleReleases(ignoreVariations, animeSeries.AniDB_ID).Count; - return new() - { - Name = series.Name, - IDs = series.IDs, - Size = series.Size, - Sizes = series.Sizes, - Created = series.Created, - Updated = series.Updated, - AirsOn = series.AirsOn, - UserRating = series.UserRating, - Images = series.Images, - Links = series.Links, - _AniDB = series._AniDB, - _TvDB = series._TvDB, - EpisodeCount = episodesWithMultipleReleases, - }; - } -} diff --git a/Shoko.Server/API/v3/Helpers/WebUIFactory.cs b/Shoko.Server/API/v3/Helpers/WebUIFactory.cs index af27d79df..9d2599f98 100644 --- a/Shoko.Server/API/v3/Helpers/WebUIFactory.cs +++ b/Shoko.Server/API/v3/Helpers/WebUIFactory.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using Shoko.Models.Enums; -using Shoko.Models.Server; using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.API.v3.Models.Shoko; using Shoko.Server.Models; namespace Shoko.Server.API.v3.Helpers; @@ -11,12 +11,10 @@ namespace Shoko.Server.API.v3.Helpers; public class WebUIFactory { private readonly FilterFactory _filterFactory; - private readonly SeriesFactory _seriesFactory; - public WebUIFactory(FilterFactory filterFactory, SeriesFactory seriesFactory) + public WebUIFactory(FilterFactory filterFactory) { _filterFactory = filterFactory; - _seriesFactory = seriesFactory; } public Models.Shoko.WebUI.WebUISeriesExtra GetWebUISeriesExtra(SVR_AnimeSeries series) @@ -24,19 +22,30 @@ public Models.Shoko.WebUI.WebUISeriesExtra GetWebUISeriesExtra(SVR_AnimeSeries s var anime = series.AniDB_Anime; var animeEpisodes = anime.AniDBEpisodes; var runtimeLength = GuessCorrectRuntimeLength(animeEpisodes); - var cast = _seriesFactory.GetCast(anime.AnimeID, new () { Role.CreatorRoleType.Studio, Role.CreatorRoleType.Producer }); + var cast = Series.GetCast(anime.AnimeID, [Role.CreatorRoleType.Studio, Role.CreatorRoleType.Producer]); + var season = GetFirstAiringSeason(anime); var result = new Models.Shoko.WebUI.WebUISeriesExtra { RuntimeLength = runtimeLength, - FirstAirSeason = _filterFactory.GetFirstAiringSeasonGroupFilter(anime), + FirstAirSeason = season, Studios = cast.Where(role => role.RoleName == Role.CreatorRoleType.Studio).Select(role => role.Staff).ToList(), Producers = cast.Where(role => role.RoleName == Role.CreatorRoleType.Producer).Select(role => role.Staff).ToList(), - SourceMaterial = _seriesFactory.GetTags(anime, TagFilter.Filter.Invert | TagFilter.Filter.Source, excludeDescriptions: true).FirstOrDefault()?.Name ?? "Original Work", + SourceMaterial = Series.GetTags(anime, TagFilter.Filter.Invert | TagFilter.Filter.Source, excludeDescriptions: true).FirstOrDefault()?.Name ?? "Original Work", }; return result; } + private static string GetFirstAiringSeason(SVR_AniDB_Anime anime) + { + var type = (AnimeType)anime.AnimeType; + if (type != AnimeType.TVSeries && type != AnimeType.Web) + return null; + + var (year, season) = anime.Seasons.FirstOrDefault(); + return year == 0 ? null : $"{season} {year}"; + } + private static TimeSpan? GuessCorrectRuntimeLength(IReadOnlyList<SVR_AniDB_Episode> episodes) { // Return early if empty. @@ -45,7 +54,7 @@ public Models.Shoko.WebUI.WebUISeriesExtra GetWebUISeriesExtra(SVR_AnimeSeries s // Filter the list and return if empty. episodes = episodes - .Where(episode => episode.EpisodeType == (int)EpisodeType.Episode) + .Where(episode => episode.EpisodeTypeEnum == Shoko.Models.Enums.EpisodeType.Episode) .ToList(); if (episodes.Count == 0) return null; @@ -59,33 +68,22 @@ public Models.Shoko.WebUI.WebUISeriesExtra GetWebUISeriesExtra(SVR_AnimeSeries s return TimeSpan.FromSeconds(episodes[index].LengthSeconds); } - public Models.Shoko.WebUI.WebUIGroupExtra GetWebUIGroupExtra(SVR_AnimeGroup group, SVR_AnimeSeries series, SVR_AniDB_Anime anime, + public Models.Shoko.WebUI.WebUIGroupExtra GetWebUIGroupExtra(SVR_AnimeGroup group, SVR_AniDB_Anime anime, TagFilter.Filter filter = TagFilter.Filter.None, bool orderByName = false, int tagLimit = 30) { - var result = new Models.Shoko.WebUI.WebUIGroupExtra{ + var result = new Models.Shoko.WebUI.WebUIGroupExtra + { ID = group.AnimeGroupID, - Type = SeriesFactory.GetAniDBSeriesType(anime.AnimeType), + Type = anime.AnimeType.ToAniDBSeriesType(), Rating = new Rating { Source = "AniDB", Value = anime.Rating, MaxValue = 1000, Votes = anime.VoteCount } }; - if (anime.AirDate != null) - { - var airdate = anime.AirDate.Value; - if (airdate != DateTime.MinValue) - { - result.AirDate = airdate; - } - } + if (anime.AirDate is { } airDate && airDate != DateTime.MinValue) + result.AirDate = airDate; - if (anime.EndDate != null) - { - var enddate = anime.EndDate.Value; - if (enddate != DateTime.MinValue) - { - result.EndDate = enddate; - } - } + if (anime.EndDate is { } endDate && endDate != DateTime.MinValue) + result.EndDate = endDate; - result.Tags = _seriesFactory.GetTags(anime, filter, excludeDescriptions: true, orderByName) + result.Tags = Series.GetTags(anime, filter, excludeDescriptions: true, orderByName) .Take(tagLimit) .ToList(); diff --git a/Shoko.Server/API/v3/Models/AniDB/Creator.cs b/Shoko.Server/API/v3/Models/AniDB/Creator.cs new file mode 100644 index 000000000..6f3d46bee --- /dev/null +++ b/Shoko.Server/API/v3/Models/AniDB/Creator.cs @@ -0,0 +1,81 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Extensions; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Providers.AniDB; + +#nullable enable +namespace Shoko.Server.API.v3.Models.AniDB; + +/// <summary> +/// AniDB Creator APIv3 Data Transfer Object (DTO). +/// </summary> +public class Creator +{ + /// <summary> + /// The global ID of the creator. + /// </summary> + public int ID { get; set; } + + /// <summary> + /// The name of the creator, transcribed to use the latin alphabet. + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// The original name of the creator. + /// </summary> + public string? OriginalName { get; set; } + + /// <summary> + /// The type of creator. + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public CreatorType Type { get; set; } + + /// <summary> + /// The URL of the creator's English homepage. + /// </summary> + public string? EnglishHomepageUrl { get; set; } + + /// <summary> + /// The URL of the creator's Japanese homepage. + /// </summary> + public string? JapaneseHomepageUrl { get; set; } + + /// <summary> + /// The URL of the creator's English Wikipedia page. + /// </summary> + public string? EnglishWikiUrl { get; set; } + + /// <summary> + /// The URL of the creator's Japanese Wikipedia page. + /// </summary> + public string? JapaneseWikiUrl { get; set; } + + /// <summary> + /// The date that the creator was last updated on AniDB. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + /// <summary> + /// The image of the creator, if available. + /// </summary> + public Image? Image { get; set; } + + public Creator(AniDB_Creator creator) + { + ID = creator.CreatorID; + Name = creator.Name; + OriginalName = creator.OriginalName; + Type = creator.Type; + EnglishHomepageUrl = creator.EnglishHomepageUrl; + JapaneseHomepageUrl = creator.JapaneseHomepageUrl; + EnglishWikiUrl = creator.EnglishWikiUrl; + JapaneseWikiUrl = creator.JapaneseWikiUrl; + LastUpdatedAt = creator.LastUpdatedAt.ToUniversalTime(); + Image = creator.GetImageMetadata() is { } image ? new Image(image) : null; + } +} diff --git a/Shoko.Server/API/v3/Models/Common/ContentRating.cs b/Shoko.Server/API/v3/Models/Common/ContentRating.cs new file mode 100644 index 000000000..6734c2431 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Common/ContentRating.cs @@ -0,0 +1,39 @@ +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.API.v3.Models.Common; + +public class ContentRating +{ + /// <summary> + /// The content rating for the specified language. + /// </summary> + public string Rating { get; init; } + + /// <summary> + /// The country code the rating applies for. + /// </summary> + public string Country { get; init; } + + /// <summary> + /// The language code the rating applies for. + /// </summary> + public string Language { get; init; } + + /// <summary> + /// The source of the content rating. + /// </summary> + public string Source { get; init; } + + public ContentRating(string rating, string countryCode, string languageCode, DataSource source) + { + Rating = rating; + Country = countryCode; + Language = languageCode; + Source = source.ToString(); + } + + public ContentRating(TMDB_ContentRating contentRating) : + this(contentRating.Rating, contentRating.CountryCode, contentRating.LanguageCode, DataSource.TMDB) + { } +} diff --git a/Shoko.Server/API/v3/Models/Common/FileIncludeTypes.cs b/Shoko.Server/API/v3/Models/Common/FileIncludeTypes.cs index bf263b420..d56218e73 100644 --- a/Shoko.Server/API/v3/Models/Common/FileIncludeTypes.cs +++ b/Shoko.Server/API/v3/Models/Common/FileIncludeTypes.cs @@ -19,7 +19,8 @@ public enum FileNonDefaultIncludeType Ignored, MediaInfo, XRefs, - AbsolutePaths + AbsolutePaths, + ImportLimbo, } [JsonConverter(typeof(StringEnumConverter))] @@ -30,5 +31,6 @@ public enum FileIncludeOnlyType Duplicates, Unrecognized, ManualLinks, - Ignored + Ignored, + ImportLimbo, } diff --git a/Shoko.Server/API/v3/Models/Common/Image.cs b/Shoko.Server/API/v3/Models/Common/Image.cs index e267efad1..0386a91eb 100644 --- a/Shoko.Server/API/v3/Models/Common/Image.cs +++ b/Shoko.Server/API/v3/Models/Common/Image.cs @@ -1,16 +1,14 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.IO; -using System.Linq; -using ImageMagick; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Commons.Extensions; using Shoko.Models.Enums; -using Shoko.Server.Extensions; -using Shoko.Server.ImageDownload; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Models; +using Shoko.Server.Models.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Utilities; @@ -23,10 +21,10 @@ namespace Shoko.Server.API.v3.Models.Common; public class Image { /// <summary> - /// AniDB, TvDB, MovieDB, etc - /// </summary> + /// The image's ID. + /// /// </summary> [Required] - public ImageSource Source { get; set; } + public int ID { get; set; } /// <summary> /// text representation of type of image. fanart, poster, etc. Mainly so clients know what they are getting @@ -35,10 +33,16 @@ public class Image public ImageType Type { get; set; } /// <summary> - /// The image's ID, usually an int, but in the case of Static resources, it is the resource name. + /// AniDB, TMDB, etc. /// </summary> [Required] - public string ID { get; set; } + public ImageSource Source { get; set; } + + /// <summary> + /// Language code for the language used for the text in the image, if any. + /// Or null if the image doesn't contain any language specifics. + /// </summary> + public string? LanguageCode { get; set; } /// <summary> /// The relative path from the base image directory. A client with access to the server's filesystem can map @@ -47,10 +51,17 @@ public class Image public string? RelativeFilepath { get; set; } /// <summary> - /// Is it marked as default. Only one default is possible for a given <see cref="Image.Type"/>. + /// Indicates this is the preferred image for the <see cref="Type"/> for the + /// selected entity. /// </summary> public bool Preferred { get; set; } + /// <summary> + /// Indicates the images is disabled. You must explicitly ask for these, for + /// hopefully obvious reasons. + /// </summary> + public bool Disabled { get; set; } + /// <summary> /// Width of the image. /// </summary> @@ -61,11 +72,6 @@ public class Image /// </summary> public int? Height { get; set; } - /// <summary> - /// Is it marked as disabled. You must explicitly ask for these, for obvious reasons. - /// </summary> - public bool Disabled { get; set; } - /// <summary> /// Series info for the image, currently only set when sending a random /// image. @@ -73,599 +79,147 @@ public class Image [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public ImageSeriesInfo? Series { get; set; } = null; - public Image(int id, ImageEntityType type, bool preferred = false, bool disabled = false) : this(id.ToString(), - type, preferred, disabled) + public Image(IImageMetadata imageMetadata) { - switch (type) + ID = imageMetadata.ID; + Type = imageMetadata.ImageType.ToV3Dto(); + Source = imageMetadata.Source.ToV3Dto(); + + Preferred = imageMetadata.IsPreferred; + Disabled = !imageMetadata.IsEnabled; + LanguageCode = imageMetadata.LanguageCode; + + // we need to set _something_ for the clients that determine + // if an image exists by checking if a relative path is set, + // so we set the id. + RelativeFilepath = imageMetadata.IsLocalAvailable ? $"/{ID}" : null; + if (imageMetadata is TMDB_Image tmdbImage) { - case ImageEntityType.Static: - throw new ArgumentException("Static Resources do not use an integer ID"); - - case ImageEntityType.UserAvatar: - { - var user = RepoFactory.JMMUser.GetByID(id); - if (user != null && user.HasAvatarImage) - { - var imageMetadata = user.AvatarImageMetadata; - // we need to set _something_ for the clients that determine - // if an image exists by checking if a relative path is set, - // so we set the id. - RelativeFilepath = $"/{id}"; - Width = imageMetadata.Width; - Height = imageMetadata.Height; - } - break; - } - - default: - { - var imagePath = GetImagePath(type, id); - if (!string.IsNullOrEmpty(imagePath)) - { - RelativeFilepath = imagePath.Replace(ImageUtils.GetBaseImagesPath(), "").Replace("\\", "/"); - if (!RelativeFilepath.StartsWith("/")) - RelativeFilepath = "/" + RelativeFilepath; - // This causes serious IO lag on some systems. Enable at own risk. - if (Utils.SettingsProvider.GetSettings().LoadImageMetadata) - { - var info = new MagickImageInfo(imagePath); - Width = info.Width; - Height = info.Height; - } - } - break; - } + Width = tmdbImage.Width; + Height = tmdbImage.Height; } - } - - public Image(string id, ImageEntityType type, bool preferred = false, bool disabled = false) - { - ID = id; - Type = GetSimpleTypeFromImageType(type); - Source = GetSourceFromType(type); - - Preferred = preferred; - Disabled = disabled; - } - - public static ImageType GetSimpleTypeFromImageType(ImageEntityType type) - { - switch (type) + else if (imageMetadata is Image_Base imageBase && imageBase._width.HasValue && imageBase._height.HasValue) { - case ImageEntityType.TvDB_Cover: - case ImageEntityType.MovieDB_Poster: - case ImageEntityType.AniDB_Cover: - return ImageType.Poster; - case ImageEntityType.TvDB_Banner: - return ImageType.Banner; - case ImageEntityType.TvDB_Episode: - return ImageType.Thumb; - case ImageEntityType.TvDB_FanArt: - case ImageEntityType.MovieDB_FanArt: - return ImageType.Fanart; - case ImageEntityType.AniDB_Character: - case ImageEntityType.Character: - return ImageType.Character; - case ImageEntityType.AniDB_Creator: - case ImageEntityType.Staff: - return ImageType.Staff; - case ImageEntityType.Static: - return ImageType.Static; - case ImageEntityType.UserAvatar: - return ImageType.Avatar; - default: - throw new ArgumentOutOfRangeException(nameof(type), type, null); + Width = imageBase._width.Value; + Height = imageBase._height.Value; } - } - - public static ImageSource GetSourceFromType(ImageEntityType type) - { - switch (type) + else if (imageMetadata.IsLocalAvailable && Utils.SettingsProvider.GetSettings().LoadImageMetadata) { - case ImageEntityType.AniDB_Cover: - case ImageEntityType.AniDB_Character: - case ImageEntityType.AniDB_Creator: - return ImageSource.AniDB; - case ImageEntityType.TvDB_Banner: - case ImageEntityType.TvDB_Cover: - case ImageEntityType.TvDB_Episode: - case ImageEntityType.TvDB_FanArt: - return ImageSource.TvDB; - case ImageEntityType.MovieDB_FanArt: - case ImageEntityType.MovieDB_Poster: - return ImageSource.TMDB; - case ImageEntityType.Character: - case ImageEntityType.Staff: - case ImageEntityType.Static: - return ImageSource.Shoko; - case ImageEntityType.UserAvatar: - return ImageSource.User; - default: - throw new ArgumentOutOfRangeException(nameof(type), type, null); + Width = imageMetadata.Width; + Height = imageMetadata.Height; } } - /// <summary> - /// Gets the <see cref="ImageEntityType"/> for the given <paramref name="imageSource"/> and <paramref name="imageType"/>. - /// </summary> - /// <param name="imageSource"></param> - /// <param name="imageType"></param> - /// <returns></returns> - public static ImageEntityType? GetImageTypeFromSourceAndType(ImageSource imageSource, ImageType imageType) + public Image(int id, ImageEntityType imageEntityType, DataSourceType dataSource, bool preferred = false, bool disabled = false) { - return imageSource switch - { - ImageSource.AniDB => imageType switch - { - ImageType.Poster => ImageEntityType.AniDB_Cover, - ImageType.Character => ImageEntityType.AniDB_Character, - ImageType.Staff => ImageEntityType.AniDB_Creator, - _ => null - }, - ImageSource.TvDB => imageType switch - { - ImageType.Poster => ImageEntityType.TvDB_Cover, - ImageType.Banner => ImageEntityType.TvDB_Banner, - ImageType.Thumb => ImageEntityType.TvDB_Episode, - ImageType.Fanart => ImageEntityType.TvDB_FanArt, - _ => null - }, - ImageSource.TMDB => imageType switch - { - ImageType.Poster => ImageEntityType.MovieDB_Poster, - ImageType.Fanart => ImageEntityType.MovieDB_FanArt, - _ => null - }, - ImageSource.Shoko => imageType switch - { - ImageType.Static => ImageEntityType.Static, - ImageType.Character => ImageEntityType.Character, - ImageType.Staff => ImageEntityType.Staff, - _ => null - }, - ImageSource.User => imageType switch - { - ImageType.Avatar => ImageEntityType.UserAvatar, - _ => null - }, - _ => null - }; - } - - /// <summary> - /// Gets the <see cref="ImageSizeType"/> from the given <paramref name="imageEntityType"/>. - /// </summary> - /// <param name="imageEntityType">Image entity type.</param> - /// <returns>The <see cref="ImageSizeType"/></returns> - public static ImageSizeType? GetImageSizeTypeFromImageEntityType(ImageEntityType imageEntityType) - { - return imageEntityType switch - { - // Posters - ImageEntityType.AniDB_Cover => ImageSizeType.Poster, - ImageEntityType.TvDB_Cover => ImageSizeType.Poster, - ImageEntityType.MovieDB_Poster => ImageSizeType.Poster, - - // Banners - ImageEntityType.TvDB_Banner => ImageSizeType.WideBanner, - - // Fanart - ImageEntityType.TvDB_FanArt => ImageSizeType.Fanart, - ImageEntityType.MovieDB_FanArt => ImageSizeType.Fanart, - _ => null - }; - } - - /// <summary> - /// Gets the <see cref="ImageSizeType"/> from the given <paramref name="imageType"/>. - /// </summary> - /// <param name="imageType"></param> - /// <returns>The <see cref="ImageSizeType"/></returns> - public static ImageSizeType GetImageSizeTypeFromType(ImageType imageType) - { - return imageType switch - { - // Posters - ImageType.Poster => ImageSizeType.Poster, - - // Banners - ImageType.Banner => ImageSizeType.WideBanner, - - // Fanart - ImageType.Fanart => ImageSizeType.Fanart, - _ => ImageSizeType.Poster - }; - } - - public static string? GetImagePath(ImageEntityType type, int id) - { - string path; + ID = id; + Type = imageEntityType.ToV3Dto(); + Source = dataSource.ToV3Dto(); - switch (type) + Preferred = preferred; + Disabled = disabled; + switch (dataSource) { - // 1 - case ImageEntityType.AniDB_Cover: - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(id); - if (anime == null) - { - return null; - } - - path = anime.PosterPath; - if (File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - } - - break; - // 4 - case ImageEntityType.TvDB_Banner: - var wideBanner = RepoFactory.TvDB_ImageWideBanner.GetByID(id); - if (wideBanner == null) - { - return null; - } - - path = wideBanner.GetFullImagePath(); - if (File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - } - - break; - - // 5 - case ImageEntityType.TvDB_Cover: - var poster = RepoFactory.TvDB_ImagePoster.GetByID(id); - if (poster == null) - { - return null; - } - - path = poster.GetFullImagePath(); - if (File.Exists(path)) - { - return path; - } - else + case DataSourceType.User: + if (imageEntityType == ImageEntityType.Art) { - path = string.Empty; - } - - break; - - // 6 - case ImageEntityType.TvDB_Episode: - var ep = RepoFactory.TvDB_Episode.GetByTvDBID(id); - if (ep == null) - { - return null; - } - - path = ep.GetFullImagePath(); - if (File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - } - - break; - - // 7 - case ImageEntityType.TvDB_FanArt: - var fanart = RepoFactory.TvDB_ImageFanart.GetByID(id); - if (fanart == null) - { - return null; - } - - path = fanart.GetFullImagePath(); - if (File.Exists(path)) - { - return path; - } - - path = string.Empty; - break; - - // 8 - case ImageEntityType.MovieDB_FanArt: - var mFanart = RepoFactory.MovieDB_Fanart.GetByID(id); - if (mFanart == null) - { - return null; - } - - mFanart = RepoFactory.MovieDB_Fanart.GetByOnlineID(mFanart.URL); - if (mFanart == null) - { - return null; - } - - path = mFanart.GetFullImagePath(); - if (File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; - } - - break; - - // 9 - case ImageEntityType.MovieDB_Poster: - var mPoster = RepoFactory.MovieDB_Poster.GetByID(id); - if (mPoster == null) - { - return null; - } - - mPoster = RepoFactory.MovieDB_Poster.GetByOnlineID(mPoster.URL); - if (mPoster == null) - { - return null; - } - - path = mPoster.GetFullImagePath(); - if (File.Exists(path)) - { - return path; - } - else - { - path = string.Empty; + var user = RepoFactory.JMMUser.GetByID(id); + if (user != null && user.HasAvatarImage) + { + var imageMetadata = user.AvatarImageMetadata; + // we need to set _something_ for the clients that determine + // if an image exists by checking if a relative path is set, + // so we set the id. + RelativeFilepath = $"/{id}"; + Width = imageMetadata.Width; + Height = imageMetadata.Height; + } } - break; - case ImageEntityType.Character: - var character = RepoFactory.AnimeCharacter.GetByID(id); - if (character == null) - { - return null; - } - - path = ImageUtils.GetBaseAniDBCharacterImagesPath() + Path.DirectorySeparatorChar + character.ImagePath; - if (File.Exists(path)) + // We can now grab the metadata from the database(!) + case DataSourceType.TMDB: + var tmdbImage = RepoFactory.TMDB_Image.GetByID(id); + if (tmdbImage != null) { - return path; - } - else - { - path = string.Empty; + LanguageCode = tmdbImage.LanguageCode; + var relativePath = tmdbImage.RelativePath; + if (!string.IsNullOrEmpty(relativePath)) + { + RelativeFilepath = relativePath.Replace("\\", "/"); + if (RelativeFilepath[0] != '/') + RelativeFilepath = "/" + RelativeFilepath; + } + Width = tmdbImage.Width; + Height = tmdbImage.Height; } - break; - case ImageEntityType.Staff: - var staff = RepoFactory.AnimeStaff.GetByID(id); - if (staff == null) - { - return null; - } - - path = ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar + staff.ImagePath; - if (File.Exists(path)) - { - return path; - } - else + default: + var metadata = ImageUtils.GetImageMetadata(dataSource, imageEntityType, id); + if (metadata is not null && metadata.IsLocalAvailable) { - path = string.Empty; + RelativeFilepath = metadata.LocalPath!.Replace(ImageUtils.GetBaseImagesPath(), "").Replace("\\", "/"); + if (RelativeFilepath[0] != '/') + RelativeFilepath = "/" + RelativeFilepath; + // This causes serious IO lag on some systems. Enable at own risk. + if (Utils.SettingsProvider.GetSettings().LoadImageMetadata) + { + Width = metadata.Width; + Height = metadata.Height; + } } - - break; - - default: - path = string.Empty; break; } - - return path; } - private static List<ImageSource> BannerImageSources = new() { ImageSource.TvDB }; - - private static List<ImageSource> PosterImageSources = new() - { - ImageSource.AniDB, - // ImageSource.TMDB, - ImageSource.TvDB - }; - - // There is only one thumbnail provider atm. - private static List<ImageSource> ThumbImageSources = new() { ImageSource.TvDB }; - - // TMDB is too unreliable atm, so we will only use TvDB for now. - private static List<ImageSource> FanartImageSources = new() - { - ImageSource.TvDB - // ImageSource.TMDB, - }; - - private static List<ImageSource> CharacterImageSources = new() - { - // ImageSource.AniDB, - ImageSource.Shoko - }; - - private static List<ImageSource> StaffImageSources = new() - { - // ImageSource.AniDB, - ImageSource.Shoko - }; - - private static List<ImageSource> StaticImageSources = new() { ImageSource.Shoko }; - - internal static ImageSource GetRandomImageSource(ImageType imageType) + private static readonly List<DataSourceType> _bannerImageSources = + [ + DataSourceType.TMDB, + ]; + + private static readonly List<DataSourceType> _posterImageSources = + [ + DataSourceType.AniDB, + DataSourceType.TMDB, + ]; + + private static readonly List<DataSourceType> _thumbImageSources = + [ + DataSourceType.TMDB, + ]; + + private static readonly List<DataSourceType> _backdropImageSources = + [ + DataSourceType.TMDB, + ]; + + private static readonly List<DataSourceType> _characterImageSources = + [ + DataSourceType.AniDB, + ]; + + private static readonly List<DataSourceType> _staffImageSources = + [ + DataSourceType.AniDB, + ]; + + internal static DataSourceType GetRandomImageSource(ImageType imageType) { var sourceList = imageType switch { - ImageType.Poster => PosterImageSources, - ImageType.Banner => BannerImageSources, - ImageType.Thumb => ThumbImageSources, - ImageType.Fanart => FanartImageSources, - ImageType.Character => CharacterImageSources, - ImageType.Staff => StaffImageSources, - _ => StaticImageSources + ImageType.Poster => _posterImageSources, + ImageType.Banner => _bannerImageSources, + ImageType.Thumb => _thumbImageSources, + ImageType.Backdrop => _backdropImageSources, + ImageType.Character => _characterImageSources, + ImageType.Staff => _staffImageSources, + _ => [], }; return sourceList.GetRandomElement(); } - internal static int? GetRandomImageID(ImageEntityType imageType) - { - return imageType switch - { - ImageEntityType.AniDB_Cover => RepoFactory.AniDB_Anime.GetAll() - .Where(a => a?.PosterPath != null && !a.GetAllTags().Contains("18 restricted")) - .GetRandomElement()?.AnimeID, - ImageEntityType.AniDB_Character => RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => a.Characters).Select(a => a.GetCharacter()).Where(a => a != null) - .GetRandomElement()?.AniDB_CharacterID, - // This will likely be slow - ImageEntityType.AniDB_Creator => RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => a.Characters) - .SelectMany(a => RepoFactory.AniDB_Character_Seiyuu.GetByCharID(a.CharID)) - .Select(a => RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(a.SeiyuuID)).Where(a => a != null) - .GetRandomElement()?.AniDB_SeiyuuID, - // TvDB doesn't allow H content, so we get to skip the check! - ImageEntityType.TvDB_Banner => RepoFactory.TvDB_ImageWideBanner.GetAll() - .GetRandomElement()?.TvDB_ImageWideBannerID, - // TvDB doesn't allow H content, so we get to skip the check! - ImageEntityType.TvDB_Cover => RepoFactory.TvDB_ImagePoster.GetAll() - .GetRandomElement()?.TvDB_ImagePosterID, - // TvDB doesn't allow H content, so we get to skip the check! - ImageEntityType.TvDB_Episode => RepoFactory.TvDB_Episode.GetAll() - .GetRandomElement()?.Id, - // TvDB doesn't allow H content, so we get to skip the check! - ImageEntityType.TvDB_FanArt => RepoFactory.TvDB_ImageFanart.GetAll() - .GetRandomElement()?.TvDB_ImageFanartID, - ImageEntityType.MovieDB_FanArt => RepoFactory.MovieDB_Fanart.GetAll() - .GetRandomElement()?.MovieDB_FanartID, - ImageEntityType.MovieDB_Poster => RepoFactory.MovieDB_Poster.GetAll() - .GetRandomElement()?.MovieDB_PosterID, - ImageEntityType.Character => RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => RepoFactory.CrossRef_Anime_Staff.GetByAnimeID(a.AnimeID)) - .Where(a => a.RoleType == (int)StaffRoleType.Seiyuu && a.RoleID.HasValue) - .Select(a => RepoFactory.AnimeCharacter.GetByID(a.RoleID!.Value)) - .GetRandomElement()?.CharacterID, - ImageEntityType.Staff => RepoFactory.AniDB_Anime.GetAll() - .Where(a => a != null && !a.GetAllTags().Contains("18 restricted")) - .SelectMany(a => RepoFactory.CrossRef_Anime_Staff.GetByAnimeID(a.AnimeID)) - .Select(a => RepoFactory.AnimeStaff.GetByID(a.StaffID)) - .GetRandomElement()?.StaffID, - _ => null - }; - } - - internal static SVR_AnimeSeries? GetFirstSeriesForImage(ImageEntityType imageType, int imageID) - { - switch (imageType) - { - case ImageEntityType.AniDB_Cover: - return RepoFactory.AnimeSeries.GetByAnimeID(imageID); - case ImageEntityType.TvDB_Banner: - { - var tvdbWideBanner = RepoFactory.TvDB_ImageWideBanner.GetByID(imageID); - if (tvdbWideBanner == null) - return null; - - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(tvdbWideBanner.SeriesID) - .FirstOrDefault(); - if (xref == null) - return null; - - return RepoFactory.AnimeSeries.GetByAnimeID(xref.AniDBID); - } - case ImageEntityType.TvDB_Cover: - { - var tvdbPoster = RepoFactory.TvDB_ImagePoster.GetByID(imageID); - if (tvdbPoster == null) - return null; - - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(tvdbPoster.SeriesID) - .FirstOrDefault(); - if (xref == null) - return null; - - return RepoFactory.AnimeSeries.GetByAnimeID(xref.AniDBID); - } - case ImageEntityType.TvDB_FanArt: - { - var tvdbFanart = RepoFactory.TvDB_ImageFanart.GetByID(imageID); - if (tvdbFanart == null) - return null; - - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(tvdbFanart.SeriesID) - .FirstOrDefault(); - if (xref == null) - return null; - - return RepoFactory.AnimeSeries.GetByAnimeID(xref.AniDBID); - } - case ImageEntityType.TvDB_Episode: - { - var tvdbEpisode = RepoFactory.TvDB_Episode.GetByID(imageID); - if (tvdbEpisode == null) - return null; - - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(tvdbEpisode.SeriesID) - .FirstOrDefault(); - if (xref == null) - return null; - - return RepoFactory.AnimeSeries.GetByAnimeID(xref.AniDBID); - } - case ImageEntityType.MovieDB_FanArt: - { - var tmdbFanart = RepoFactory.MovieDB_Fanart.GetByID(imageID); - if (tmdbFanart == null) - return null; - - // This will be slow as HELL. Why don't we have a lookup? - var xref = RepoFactory.CrossRef_AniDB_Other.GetAll() - .FirstOrDefault(xref => xref.CrossRefType == (int)CrossRefType.MovieDB && int.Parse(xref.CrossRefID) == tmdbFanart.MovieId); - if (xref == null) - return null; - - return RepoFactory.AnimeSeries.GetByAnimeID(xref.AnimeID); - } - case ImageEntityType.MovieDB_Poster: - { - var tmdbPoster = RepoFactory.MovieDB_Poster.GetByID(imageID); - if (tmdbPoster == null) - return null; - - // This will be slow as HELL. Why don't we have a lookup? - var xref = RepoFactory.CrossRef_AniDB_Other.GetAll() - .FirstOrDefault(xref => xref.CrossRefType == (int)CrossRefType.MovieDB && int.Parse(xref.CrossRefID) == tmdbPoster.MovieId); - if (xref == null) - return null; - - return RepoFactory.AnimeSeries.GetByAnimeID(xref.AnimeID); - } - default: - return null; - }; - } - /// <summary> /// Image source. /// </summary> @@ -673,17 +227,12 @@ internal static ImageSource GetRandomImageSource(ImageType imageType) public enum ImageSource { /// <summary> - /// + /// AniDB. /// </summary> AniDB = 1, /// <summary> - /// - /// </summary> - TvDB = 2, - - /// <summary> - /// + /// The Movie DataBase (TMDB). /// </summary> TMDB = 3, @@ -693,9 +242,9 @@ public enum ImageSource User = 99, /// <summary> - /// + /// Shoko. /// </summary> - Shoko = 100 + Shoko = 100, } /// <summary> @@ -705,44 +254,57 @@ public enum ImageSource public enum ImageType { /// <summary> - /// + /// The standard poster image. May or may not contain text. /// </summary> Poster = 1, /// <summary> - /// + /// A long/wide banner image, usually with text. /// </summary> Banner = 2, /// <summary> - /// + /// Thumbnail image. + /// </summary> + Thumbnail = 3, + + /// <summary> + /// Temp. synonym until it's safe to remove it. /// </summary> - Thumb = 3, + Thumb = Thumbnail, /// <summary> - /// + /// Backdrop / background images. Usually doesn't contain any text, but + /// it might. /// </summary> - Fanart = 4, + Backdrop = 4, /// <summary> - /// + /// Temp. synonym until it's safe to remove it. + /// </summary> + Fanart = Backdrop, + + /// <summary> + /// Character image. May be a close up portrait of the character, or a + /// full-body view of the character. /// </summary> Character = 5, /// <summary> - /// + /// Staff image. May be a close up portrait of the person, or a + /// full-body view of the person. /// </summary> Staff = 6, /// <summary> - /// User avatar. + /// Clear-text logo. /// </summary> - Avatar = 99, + Logo = 7, /// <summary> - /// Static resources are only valid if the <see cref="Image.Source"/> is set to <see cref="ImageSource.Shoko"/>. + /// User avatar. /// </summary> - Static = 100 + Avatar = 99, } public class ImageSeriesInfo @@ -776,8 +338,9 @@ public class DefaultImageBody /// from the API. Also see <seealso cref="Image.ID"/>. /// </summary> /// <value></value> - [Required, MinLength(1)] - public string ID { get; set; } = ""; + [Required] + [Range(0, int.MaxValue)] + public int ID { get; set; } /// <summary> /// The image source. @@ -786,5 +349,54 @@ public class DefaultImageBody [Required] public ImageSource Source { get; set; } } + + public class EnableImageBody + { + /// <summary> + /// Indicates that the image should be enabled. + /// </summary> + [Required] + public bool Enabled { get; set; } + } } } + +public static class ImageExtensions +{ + public static ImageEntityType ToServer(this Image.ImageType type) + => type switch + { + Image.ImageType.Avatar => ImageEntityType.Art, + Image.ImageType.Banner => ImageEntityType.Banner, + Image.ImageType.Character => ImageEntityType.Character, + Image.ImageType.Backdrop => ImageEntityType.Backdrop, + Image.ImageType.Poster => ImageEntityType.Poster, + Image.ImageType.Staff => ImageEntityType.Person, + Image.ImageType.Thumb => ImageEntityType.Thumbnail, + Image.ImageType.Logo => ImageEntityType.Logo, + _ => ImageEntityType.None, + }; + + public static Image.ImageType ToV3Dto(this ImageEntityType type) + => type switch + { + ImageEntityType.Art => Image.ImageType.Avatar, + ImageEntityType.Banner => Image.ImageType.Banner, + ImageEntityType.Character => Image.ImageType.Character, + ImageEntityType.Backdrop => Image.ImageType.Backdrop, + ImageEntityType.Poster => Image.ImageType.Poster, + ImageEntityType.Person => Image.ImageType.Staff, + ImageEntityType.Thumbnail => Image.ImageType.Thumb, + ImageEntityType.Logo => Image.ImageType.Logo, + _ => Image.ImageType.Staff, + }; + + public static DataSourceType ToServer(this Image.ImageSource source) + => Enum.Parse<DataSourceType>(source.ToString()); + + public static Image.ImageSource ToV3Dto(this DataSourceType source) + => Enum.Parse<Image.ImageSource>(source.ToString()); + + public static Image.ImageSource ToV3Dto(this DataSourceEnum source) + => Enum.Parse<Image.ImageSource>(source.ToString()); +} diff --git a/Shoko.Server/API/v3/Models/Common/Images.cs b/Shoko.Server/API/v3/Models/Common/Images.cs index 0037e713f..9e29de149 100644 --- a/Shoko.Server/API/v3/Models/Common/Images.cs +++ b/Shoko.Server/API/v3/Models/Common/Images.cs @@ -1,10 +1,16 @@ using System.Collections.Generic; +using Newtonsoft.Json; +#nullable enable namespace Shoko.Server.API.v3.Models.Common; public class Images { - public List<Image> Posters { get; set; } = new(); - public List<Image> Fanarts { get; set; } = new(); - public List<Image> Banners { get; set; } = new(); + public List<Image> Posters { get; set; } = []; + public List<Image> Backdrops { get; set; } = []; + public List<Image> Banners { get; set; } = []; + public List<Image> Logos { get; set; } = []; + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public List<Image>? Thumbnails { get; set; } = null; } diff --git a/Shoko.Server/API/v3/Models/Common/LanguageDetails.cs b/Shoko.Server/API/v3/Models/Common/LanguageDetails.cs new file mode 100644 index 000000000..c5793ae52 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Common/LanguageDetails.cs @@ -0,0 +1,24 @@ +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; + +#nullable enable +namespace Shoko.Server.API.v3.Models.Common; + +public class LanguageDetails +{ + /// <summary> + /// Language name. + /// </summary> + public string Name; + + /// <summary> + /// Alpha 2 code, with `x-` extensions. + /// </summary> + public string Alpha2; + + public LanguageDetails(TitleLanguage language) + { + Name = language.GetDescription(); + Alpha2 = language.GetString(); + } +} diff --git a/Shoko.Server/API/v3/Models/Common/ListResult.cs b/Shoko.Server/API/v3/Models/Common/ListResult.cs index 225d47885..060f05f21 100644 --- a/Shoko.Server/API/v3/Models/Common/ListResult.cs +++ b/Shoko.Server/API/v3/Models/Common/ListResult.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; #nullable enable namespace Shoko.Server.API.v3.Models.Common; @@ -20,7 +21,15 @@ public ListResult() } /// <summary> - /// Create a new fully initialised list result. + /// Create a new fully initialized list result. + /// </summary> + /// <param name="total">Total count</param> + /// <param name="list">List of <typeparamref name="T"/> entries.</param> + public ListResult(int total, IEnumerable<T> list) + : this(total, list.ToList()) { } + + /// <summary> + /// Create a new fully initialized list result. /// </summary> /// <param name="total">Total count</param> /// <param name="list">List of <typeparamref name="T"/> entries.</param> diff --git a/Shoko.Server/API/v3/Models/Common/Network.cs b/Shoko.Server/API/v3/Models/Common/Network.cs new file mode 100644 index 000000000..bf0788181 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Common/Network.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.API.v3.Models.Common; + +/// <summary> +/// APIv3 Network Data Transfer Object (DTO). +/// </summary> +public class Network +{ + /// <summary> + /// Network ID relative to the <see cref="Source"/>. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// The name of the studio. + /// </summary> + public string Name { get; init; } + + /// <summary> + /// The country the studio originates from. + /// </summary> + public string CountryOfOrigin { get; init; } + + /// <summary> + /// Entities produced by the studio in the local collection, both movies + /// and/or shows. + /// </summary> + public int Size { get; init; } + + /// <summary> + /// The source of which the studio metadata belongs to. + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public DataSource Source; + + public Network(TMDB_Network company) + { + ID = company.TmdbNetworkID; + Name = company.Name; + CountryOfOrigin = company.CountryOfOrigin; + Size = company.GetTmdbNetworkCrossReferences().Count; + Source = DataSource.TMDB; + } +} diff --git a/Shoko.Server/API/v3/Models/Common/Overview.cs b/Shoko.Server/API/v3/Models/Common/Overview.cs new file mode 100644 index 000000000..116727c40 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Common/Overview.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.API.v3.Models.Common; + +/// <summary> +/// Overview information. +/// </summary> +public class Overview +{ + /// <summary> + /// The overview value. + /// </summary> + [Required] + public string Value { get; init; } + + /// <summary> + /// Language code. Alpha 2. + /// </summary> + [Required] + public string Language { get; init; } + + /// <summary> + /// Indicates this is the default overview for the entity. + /// </summary> + public bool Default { get; init; } + + /// <summary> + /// Indicates this is the user preferred overview. + /// </summary> + /// <value></value> + public bool Preferred { get; init; } + + /// <summary> + /// Indicates the source where the overview is from. + /// </summary> + [Required] + public string Source { get; init; } + + public Overview(TMDB_Overview overview, string mainDescription = null, TMDB_Overview preferredDescription = null) + { + Value = overview.Value; + Language = overview.Language.GetString(); + Default = overview.Language == TitleLanguage.English && !string.IsNullOrEmpty(mainDescription) && string.Equals(overview.Value, mainDescription); + Preferred = overview.Equals(preferredDescription); + Source = "TMDB"; + } +} diff --git a/Shoko.Server/API/v3/Models/Common/Resource.cs b/Shoko.Server/API/v3/Models/Common/Resource.cs new file mode 100644 index 000000000..61d57a2ec --- /dev/null +++ b/Shoko.Server/API/v3/Models/Common/Resource.cs @@ -0,0 +1,41 @@ + +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Shoko.Server.API.v3.Models.Common; + +/// <summary> +/// A site link, as in hyperlink. +/// </summary> +public class Resource +{ + /// <summary> + /// Resource type. + /// </summary> + [Required] + public string Type { get; init; } = string.Empty; + + /// <summary> + /// site name + /// </summary> + [Required] + public string Name { get; init; } = string.Empty; + + /// <summary> + /// the url to the series page + /// </summary> + [Required] + public string URL { get; init; } = string.Empty; + + public Resource() { } + + public Resource((string type, string name, string url) tuple) + : this(tuple.type, tuple.name, tuple.url) { } + + public Resource(string type, string name, string url) + { + Type = type; + Name = name; + URL = url; + } +} diff --git a/Shoko.Server/API/v3/Models/Common/Role.cs b/Shoko.Server/API/v3/Models/Common/Role.cs index 0a236ba52..3b882f731 100644 --- a/Shoko.Server/API/v3/Models/Common/Role.cs +++ b/Shoko.Server/API/v3/Models/Common/Role.cs @@ -1,7 +1,13 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using Shoko.Models.Server; +using Shoko.Server.API.v3.Helpers; +using Shoko.Server.Extensions; +using Shoko.Server.Models.TMDB; using System.ComponentModel.DataAnnotations; +using System.Linq; +#nullable enable namespace Shoko.Server.API.v3.Models.Common; /// <summary> @@ -10,10 +16,10 @@ namespace Shoko.Server.API.v3.Models.Common; public class Role { /// <summary> - /// Most will be Japanese. Once AniList is in, it will have multiple options + /// The character played, if applicable /// </summary> - [Required] - public string Language { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Person? Character { get; set; } /// <summary> /// The person who plays a character, writes the music, etc. @@ -21,11 +27,6 @@ public class Role [Required] public Person Staff { get; set; } - /// <summary> - /// The character played, if applicable - /// </summary> - public Person Character { get; set; } - /// <summary> /// The role that the staff plays, cv, writer, director, etc /// </summary> @@ -35,36 +36,208 @@ public class Role /// <summary> /// Extra info about the role. For example, role can be voice actor, while role_details is Main Character /// </summary> - public string RoleDetails { get; set; } + [Required] + public string RoleDetails { get; set; } = string.Empty; + + public Role(CrossRef_Anime_Staff xref, AnimeStaff staff, AnimeCharacter? character) + { + Character = character == null ? null : new() + { + ID = character.AniDBID, + Name = character.Name, + AlternateName = character.AlternateName, + Description = character.Description, + Image = character.GetImageMetadata() is { } characterImage ? new Image(characterImage) : null, + }; + Staff = new() + { + ID = staff.AniDBID, + Name = staff.Name, + AlternateName = staff.AlternateName, + Description = staff.Description, + Image = staff.GetImageMetadata() is { } staffImage ? new Image(staffImage) : null, + }; + RoleName = (CreatorRoleType)xref.RoleType; + RoleDetails = xref.Role; + } + + public Role(TMDB_Movie_Cast cast) + { + var person = cast.GetTmdbPerson(); + var personImages = person.GetImages(); + Character = new() + { + Name = cast.CharacterName, + }; + Staff = new() + { + ID = person.Id, + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = CreatorRoleType.Seiyuu; + RoleDetails = "Character"; + } + + public Role(TMDB_Show_Cast cast) + { + var person = cast.GetTmdbPerson(); + var personImages = person.GetImages(); + Character = new() + { + Name = cast.CharacterName, + }; + Staff = new() + { + ID = person.Id, + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = CreatorRoleType.Seiyuu; + RoleDetails = "Character"; + } + + public Role(TMDB_Season_Cast cast) + { + var person = cast.GetTmdbPerson(); + var personImages = person.GetImages(); + Character = new() + { + Name = cast.CharacterName, + }; + Staff = new() + { + ID = person.Id, + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = CreatorRoleType.Seiyuu; + RoleDetails = "Character"; + } + + public Role(TMDB_Episode_Cast cast) + { + var person = cast.GetTmdbPerson(); + var personImages = person.GetImages(); + Character = new() + { + Name = cast.CharacterName, + }; + Staff = new() + { + ID = person.Id, + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = CreatorRoleType.Seiyuu; + RoleDetails = "Character"; + } + + public Role(TMDB_Movie_Crew crew) + { + var person = crew.GetTmdbPerson(); + var personImages = person.GetImages(); + Staff = new() + { + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = crew.ToCreatorRole(); + RoleDetails = $"{crew.Department}, ${crew.Job}"; + } + + public Role(TMDB_Show_Crew crew) + { + var person = crew.GetTmdbPerson(); + var personImages = person.GetImages(); + Staff = new() + { + ID = person.Id, + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = crew.ToCreatorRole(); + RoleDetails = $"{crew.Department}, ${crew.Job}"; + } + + public Role(TMDB_Season_Crew crew) + { + var person = crew.GetTmdbPerson(); + var personImages = person.GetImages(); + Staff = new() + { + ID = person.Id, + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = crew.ToCreatorRole(); + RoleDetails = $"{crew.Department}, ${crew.Job}"; + } + + public Role(TMDB_Episode_Crew crew) + { + var person = crew.GetTmdbPerson(); + var personImages = person.GetImages(); + Staff = new() + { + ID = person.Id, + Name = person.EnglishName, + AlternateName = person.Aliases.Count == 0 ? person.EnglishName : person.Aliases[0].Split("/").Last().Trim(), + Description = person.EnglishBiography, + Image = personImages.Count > 0 ? new Image(personImages[0]) : null, + }; + RoleName = crew.ToCreatorRole(); + RoleDetails = $"{crew.Department}, ${crew.Job}"; + } /// <summary> /// A generic person object with the name, altname, description, and image /// </summary> public class Person { + /// <summary> + /// The provider id of the person object, if available and applicable. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public int? ID { get; set; } + /// <summary> /// Main Name, romanized if needed /// ex. Sawano Hiroyuki /// </summary> [Required] - public string Name { get; set; } + public string Name { get; set; } = string.Empty; /// <summary> /// Alternate Name, this can be any other name, whether kanji, an alias, etc /// ex. 澤野弘之 /// </summary> - public string AlternateName { get; set; } + public string AlternateName { get; set; } = string.Empty; /// <summary> /// A description, bio, etc /// ex. Sawano Hiroyuki was born September 12, 1980 in Tokyo, Japan. He is a composer and arranger. /// </summary> - public string Description { get; set; } + public string Description { get; set; } = string.Empty; /// <summary> /// image object, usually a profile picture of sorts /// </summary> - public Image Image { get; set; } + public Image? Image { get; set; } } [JsonConverter(typeof(StringEnumConverter))] @@ -113,6 +286,6 @@ public enum CreatorRoleType /// <summary> /// Responsible for the creation of the source work this show is detrived from. /// </summary> - SourceWork + SourceWork, } } diff --git a/Shoko.Server/API/v3/Models/Common/Studio.cs b/Shoko.Server/API/v3/Models/Common/Studio.cs new file mode 100644 index 000000000..e2fc7cc11 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Common/Studio.cs @@ -0,0 +1,61 @@ + +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.API.v3.Models.Common; + +/// <summary> +/// APIv3 Studio Data Transfer Object (DTO). +/// </summary> +public class Studio +{ + /// <summary> + /// Studio ID relative to the <see cref="Source"/>. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// The name of the studio. + /// </summary> + public string Name { get; init; } + + /// <summary> + /// The country the studio originates from. + /// </summary> + public string CountryOfOrigin { get; init; } + + /// <summary> + /// Entities produced by the studio in the local collection, both movies + /// and/or shows. + /// </summary> + public int Size { get; init; } + + /// <summary> + /// Logos used by the studio. + /// </summary> + public IReadOnlyList<Image> Logos { get; init; } + + /// <summary> + /// The source of which the studio metadata belongs to. + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public DataSource Source { get; init; } + + public Studio(TMDB_Company company) + { + ID = company.TmdbCompanyID; + Name = company.Name; + CountryOfOrigin = company.CountryOfOrigin; + Size = company.GetTmdbCompanyCrossReferences().Count; + Logos = company.GetImages(ImageEntityType.Logo) + .Select(image => new Image(image)) + .ToList(); + Source = DataSource.TMDB; + } +} diff --git a/Shoko.Server/API/v3/Models/Common/Tag.cs b/Shoko.Server/API/v3/Models/Common/Tag.cs index 8b08e1904..7c3da5c83 100644 --- a/Shoko.Server/API/v3/Models/Common/Tag.cs +++ b/Shoko.Server/API/v3/Models/Common/Tag.cs @@ -1,7 +1,9 @@ using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Models.Server; +using Shoko.Server.Repositories; #nullable enable namespace Shoko.Server.API.v3.Models.Common; @@ -95,10 +97,62 @@ public Tag(AniDB_Tag tag, bool excludeDescriptions = false) [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonConverter(typeof(IsoDateTimeConverter))] public DateTime? LastUpdated { get; set; } - + /// <summary> /// Source. Anidb, User, etc. /// </summary> /// <value></value> public string Source { get; set; } + + public static class Input + { + /// <summary> + /// Create or update a custom tag. + /// </summary> + public class CreateOrUpdateCustomTagBody + { + /// <summary> + /// Set the tag name. Set to null or empty to skip. Cannot be null + /// or empty when creating a new tag. + /// </summary> + public string? Name { get; set; } = null; + + /// <summary> + /// Set the tag description. Set to null to skip. Set to any string + /// value to override existing or set the new description. + /// </summary> + public string? Description { get; set; } = null; + + public Tag? MergeWithExisting(CustomTag tag, ModelStateDictionary modelState) + { + if (!string.IsNullOrEmpty(Name?.Trim())) + { + var existing = RepoFactory.CustomTag.GetByTagName(Name); + if (existing is not null && existing.CustomTagID != tag.CustomTagID) + modelState.AddModelError(nameof(Name), "Unable to create duplicate tag with the same name."); + } + + if (!modelState.IsValid) + return null; + + var updated = tag.CustomTagID is 0; + if (!string.IsNullOrEmpty(Name?.Trim())) + { + tag.TagName = Name; + updated = true; + } + + if (Description is not null) + { + tag.TagDescription = Description.Trim(); + updated = true; + } + + if (updated) + RepoFactory.CustomTag.Save(tag); + + return new(tag); + } + } + } } diff --git a/Shoko.Server/API/v3/Models/Common/Title.cs b/Shoko.Server/API/v3/Models/Common/Title.cs index 49eea88d3..3b848ab62 100644 --- a/Shoko.Server/API/v3/Models/Common/Title.cs +++ b/Shoko.Server/API/v3/Models/Common/Title.cs @@ -2,42 +2,91 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.AniDB.Titles; +#nullable enable namespace Shoko.Server.API.v3.Models.Common; /// <summary> -/// Title object, stores the title, type, language, and source -/// if using a TvDB title, assume "eng:official". If using AniList, assume "x-jat:main" -/// AniDB's MainTitle is "x-jat:main" +/// APIv3 Title Data Transfer Object (DTO) /// </summary> public class Title { /// <summary> - /// the title + /// The title. /// </summary> [Required] - public string Name { get; set; } + public string Name { get; init; } /// <summary> /// convert to AniDB style (x-jat is the special one, but most are standard 3-digit short names) /// </summary> [Required] - public string Language { get; set; } + public string Language { get; init; } /// <summary> - /// AniDB type + /// Title Type /// </summary> [JsonConverter(typeof(StringEnumConverter))] - public TitleType Type { get; set; } + public TitleType Type { get; init; } /// <summary> - /// If this is the default title + /// Indicates this is the default title for the entity. /// </summary> - public bool Default { get; set; } + public bool Default { get; init; } /// <summary> - /// AniDB, TvDB, AniList, etc + /// Indicates this is the user preferred title. + /// </summary> + /// <value></value> + public bool Preferred { get; init; } + + /// <summary> + /// AniDB, TMDB, AniList, etc. /// </summary> [Required] - public string Source { get; set; } + public string Source { get; init; } + + public Title(SVR_AniDB_Anime_Title title, string? mainTitle = null, string? preferredTitle = null) + { + Name = title.Title; + Language = title.LanguageCode; + Type = title.TitleType; + Default = !string.IsNullOrEmpty(mainTitle) && string.Equals(title.Title, mainTitle); + Preferred = !string.IsNullOrEmpty(preferredTitle) && string.Equals(title.Title, preferredTitle); + Source = "AniDB"; + } + + public Title(ResponseAniDBTitles.Anime.AnimeTitle title, string? mainTitle = null, string? preferredTitle = null) + { + Name = title.Title; + Language = title.LanguageCode; + Type = title.TitleType; + Default = !string.IsNullOrEmpty(mainTitle) && string.Equals(title.Title, mainTitle); + Preferred = !string.IsNullOrEmpty(preferredTitle) && string.Equals(title.Title, preferredTitle); + Source = "AniDB"; + } + + public Title(SVR_AniDB_Episode_Title title, string? mainTitle = null, SVR_AniDB_Episode_Title? preferredTitle = null) + { + Name = title.Title; + Language = title.LanguageCode; + Type = TitleType.None; + Default = title.Language == TitleLanguage.English && !string.IsNullOrEmpty(mainTitle) && string.Equals(title.Title, mainTitle); + Preferred = preferredTitle is not null && title.AniDB_Episode_TitleID == preferredTitle.AniDB_Episode_TitleID; + Source = "AniDB"; + } + + public Title(TMDB_Title title, string? mainTitle = null, TMDB_Title? preferredTitle = null) + { + Name = title.Value; + Language = title.Language.GetString(); + Type = TitleType.None; + Default = title.Language == TitleLanguage.English && !string.IsNullOrEmpty(mainTitle) && string.Equals(title.Value, mainTitle); + Preferred = title.Equals(preferredTitle); + Source = "TMDB"; + } } diff --git a/Shoko.Server/API/v3/Models/Common/Vote.cs b/Shoko.Server/API/v3/Models/Common/Vote.cs index dc57654df..7dbeca3ac 100644 --- a/Shoko.Server/API/v3/Models/Common/Vote.cs +++ b/Shoko.Server/API/v3/Models/Common/Vote.cs @@ -19,6 +19,8 @@ public Vote(decimal value, int maxValue = 10) MaxValue = maxValue; } + public Vote() { } + /// <summary> /// The normalised user-submitted rating in the range [0, <paramref name="maxValue" />]. /// </summary> diff --git a/Shoko.Server/API/v3/Models/Common/YearlySeason.cs b/Shoko.Server/API/v3/Models/Common/YearlySeason.cs new file mode 100644 index 000000000..de84d8714 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Common/YearlySeason.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using Shoko.Models.Enums; + +namespace Shoko.Server.API.v3.Models.Common; + +public record YearlySeason(int Year, AnimeSeason AnimeSeason) : IComparable<YearlySeason> +{ + public int CompareTo(YearlySeason other) + { + if (ReferenceEquals(this, other)) + { + return 0; + } + + if (ReferenceEquals(null, other)) + { + return 1; + } + + if (ReferenceEquals(null, this)) + { + return -1; + } + + var yearComparison = Year.CompareTo(other.Year); + if (yearComparison != 0) + { + return yearComparison; + } + + return AnimeSeason.CompareTo(other.AnimeSeason); + } +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Dashboard.cs b/Shoko.Server/API/v3/Models/Shoko/Dashboard.cs index 9944e0cdd..7c3fe1dad 100644 --- a/Shoko.Server/API/v3/Models/Shoko/Dashboard.cs +++ b/Shoko.Server/API/v3/Models/Shoko/Dashboard.cs @@ -3,10 +3,10 @@ using Newtonsoft.Json.Converters; using Shoko.Commons.Extensions; using Shoko.Models.Enums; -using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.Converters; -using Shoko.Server.API.v3.Helpers; using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Extensions; using Shoko.Server.Models; using Shoko.Server.Repositories; @@ -72,7 +72,7 @@ public class CollectionStats public int UnrecognizedFiles { get; set; } /// <summary> - /// The number of series missing both the TvDB and MovieDB Links + /// The number of series missing TMDB Links /// </summary> public int SeriesWithMissingLinks { get; set; } @@ -143,16 +143,15 @@ public EpisodeDetails(SVR_AniDB_Episode episode, SVR_AniDB_Anime anime, SVR_Anim ? RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(episode.EpisodeID)?.AnimeEpisodeID : null }; - Title = episode.PreferredTitle; + Title = episode.PreferredTitle.Title; Number = episode.EpisodeNumber; Type = Episode.MapAniDBEpisodeType(episode.GetEpisodeTypeEnum()); AirDate = episode.GetAirDateAsDate(); Duration = file?.DurationTimeSpan ?? new TimeSpan(0, 0, episode.LengthSeconds); ResumePosition = userRecord?.ResumePositionTimeSpan; Watched = userRecord?.WatchedDate?.ToUniversalTime(); - SeriesTitle = series?.SeriesName ?? anime.PreferredTitle; - SeriesPoster = SeriesFactory.GetDefaultImage(anime.AnimeID, ImageSizeType.Poster) ?? - SeriesFactory.GetAniDBPoster(anime.AnimeID); + SeriesTitle = series?.PreferredTitle ?? anime.PreferredTitle; + SeriesPoster = new Image(anime.PreferredOrDefaultPoster); } /// <summary> diff --git a/Shoko.Server/API/v3/Models/Shoko/Episode.cs b/Shoko.Server/API/v3/Models/Shoko/Episode.cs index 3e792ed7a..217bfe3b5 100644 --- a/Shoko.Server/API/v3/Models/Shoko/Episode.cs +++ b/Shoko.Server/API/v3/Models/Shoko/Episode.cs @@ -8,19 +8,25 @@ using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.Converters; +using Shoko.Server.API.v3.Helpers; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.Models; using Shoko.Server.Repositories; + using AniDBEpisodeType = Shoko.Models.Enums.EpisodeType; using DataSource = Shoko.Server.API.v3.Models.Common.DataSource; +using TmdbEpisode = Shoko.Server.API.v3.Models.TMDB.Episode; +using TmdbMovie = Shoko.Server.API.v3.Models.TMDB.Movie; +#nullable enable namespace Shoko.Server.API.v3.Models.Shoko; public class Episode : BaseModel { /// <summary> - /// The relevant IDs for the Episode: Shoko, AniDB, TvDB + /// The relevant IDs for the Episode: Shoko, AniDB, TMDB. /// </summary> public EpisodeIDs IDs { get; set; } @@ -29,6 +35,16 @@ public class Episode : BaseModel /// </summary> public bool HasCustomName { get; set; } + /// <summary> + /// Preferred episode description based on the language preference. + /// </summary> + public string Description { get; set; } + + /// <summary> + /// The preferred images for the episode. + /// </summary> + public Images Images { get; set; } + /// <summary> /// The duration of the episode. /// </summary> @@ -57,74 +73,140 @@ public class Episode : BaseModel /// <summary> /// Episode is marked as "ignored." Which means it won't be show up in the - /// api unless explictly requested, and will not count against the unwatched + /// api unless explicitly requested, and will not count against the unwatched /// counts and missing counts for the series. /// </summary> public bool IsHidden { get; set; } + /// <summary> + /// The user's rating + /// </summary> + public Rating? UserRating { get; set; } + +#pragma warning disable IDE1006 /// <summary> /// The <see cref="Episode.AniDB"/>, if <see cref="DataSource.AniDB"/> is /// included in the data to add. /// </summary> [JsonProperty("AniDB", NullValueHandling = NullValueHandling.Ignore)] - public AniDB _AniDB { get; set; } + public AniDB? _AniDB { get; set; } +#pragma warning restore IDE1006 /// <summary> - /// The <see cref="Episode.TvDB"/> entries, if <see cref="DataSource.TvDB"/> + /// The <see cref="TmdbData"/> entries, if <see cref="DataSource.TMDB"/> /// is included in the data to add. /// </summary> - [JsonProperty("TvDB", NullValueHandling = NullValueHandling.Ignore)] - public IEnumerable<TvDB> _TvDB { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public TmdbData? TMDB { get; set; } /// <summary> - /// Files assosiated with the episode, if included with the metadata. + /// Files associated with the episode, if included with the metadata. /// </summary> [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public IEnumerable<File> Files { get; set; } + public IEnumerable<File>? Files { get; set; } /// <summary> /// File/episode cross-references linked to the episode. /// </summary> [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public IEnumerable<FileCrossReference.EpisodeCrossReferenceIDs> CrossReferences { get; set; } - - public Episode() { } + public IEnumerable<FileCrossReference.EpisodeCrossReferenceIDs>? CrossReferences { get; set; } - public Episode(HttpContext context, SVR_AnimeEpisode episode, HashSet<DataSource> includeDataFrom = null, bool includeFiles = false, bool includeMediaInfo = false, bool includeAbsolutePaths = false, bool withXRefs = false) + public Episode(HttpContext context, SVR_AnimeEpisode episode, HashSet<DataSource>? includeDataFrom = null, bool includeFiles = false, bool includeMediaInfo = false, bool includeAbsolutePaths = false, bool withXRefs = false) { + includeDataFrom ??= []; var userID = context.GetUser()?.JMMUserID ?? 0; var episodeUserRecord = episode.GetUserRecord(userID); - var anidbEpisode = episode.AniDB_Episode; - var tvdbEpisodes = episode.TvDBEpisodes; + var anidbEpisode = episode.AniDB_Episode ?? + throw new NullReferenceException($"Unable to get AniDB Episode {episode.AniDB_EpisodeID} for Anime Episode {episode.AnimeEpisodeID}"); + var tmdbMovieXRefs = episode.TmdbMovieCrossReferences; + var tmdbEpisodeXRefs = episode.TmdbEpisodeCrossReferences; var files = episode.VideoLocals; var (file, fileUserRecord) = files .Select(file => (file, userRecord: RepoFactory.VideoLocalUser.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID))) .OrderByDescending(tuple => tuple.userRecord?.LastUpdated) .FirstOrDefault(); + var vote = RepoFactory.AniDB_Vote.GetByEntityAndType(episode.AniDB_EpisodeID, AniDBVoteType.Episode); IDs = new EpisodeIDs { ID = episode.AnimeEpisodeID, ParentSeries = episode.AnimeSeriesID, AniDB = episode.AniDB_EpisodeID, - TvDB = tvdbEpisodes.Select(a => a.Id).ToList() + TvDB = tmdbEpisodeXRefs.Select(xref => xref.TmdbEpisode?.TvdbEpisodeID).WhereNotNull().Distinct().ToList(), + IMDB = tmdbMovieXRefs + .Select(xref => xref.TmdbMovie?.ImdbMovieID) + .WhereNotNull() + .Distinct() + .ToList(), + TMDB = new() + { + Episode = tmdbEpisodeXRefs + .Where(xref => xref.TmdbEpisodeID != 0) + .Select(xref => xref.TmdbEpisodeID) + .ToList(), + Movie = tmdbMovieXRefs + .Select(xref => xref.TmdbMovieID) + .ToList(), + Show = tmdbEpisodeXRefs + .Where(xref => xref.TmdbShowID != 0) + .Select(xref => xref.TmdbShowID) + .Distinct() + .ToList(), + }, }; HasCustomName = !string.IsNullOrEmpty(episode.EpisodeNameOverride); + Images = episode.GetImages().ToDto(includeThumbnails: true, preferredImages: true); Duration = file?.DurationTimeSpan ?? new TimeSpan(0, 0, anidbEpisode.LengthSeconds); ResumePosition = fileUserRecord?.ResumePositionTimeSpan; Watched = fileUserRecord?.WatchedDate?.ToUniversalTime(); WatchCount = episodeUserRecord?.WatchedCount ?? 0; IsHidden = episode.IsHidden; Name = episode.PreferredTitle; + Description = episode.PreferredOverview; Size = files.Count; - if (includeDataFrom?.Contains(DataSource.AniDB) ?? false) + if (vote is not null) + { + UserRating = new() + { + Value = (decimal)Math.Round(vote.VoteValue / 100D, 1), + MaxValue = 10, + Type = AniDBVoteType.Episode.ToString(), + Source = "User" + }; + } + + if (includeDataFrom.Contains(DataSource.AniDB)) _AniDB = new AniDB(anidbEpisode); - if (includeDataFrom?.Contains(DataSource.TvDB) ?? false) - _TvDB = tvdbEpisodes.Select(tvdbEpisode => new TvDB(tvdbEpisode)); + if (includeDataFrom.Contains(DataSource.TMDB)) + TMDB = new() + { + Episodes = tmdbEpisodeXRefs + .Select(tmdbEpisodeXref => tmdbEpisodeXref.TmdbEpisode) + .WhereNotNull() + .GroupBy(tmdbEpisode => tmdbEpisode.TmdbShowID) + .Select(groupBy => (TmdbShow: groupBy.First().TmdbShow!, TmdbEpisodes: groupBy.ToList())) + .Where(tuple => tuple.TmdbShow is not null) + .SelectMany(tuple0 => + string.IsNullOrEmpty(tuple0.TmdbShow.PreferredAlternateOrderingID) + ? tuple0.TmdbEpisodes.Select(tmdbEpisode => new TmdbEpisode(tuple0.TmdbShow, tmdbEpisode)) + : tuple0.TmdbEpisodes + .Select(tmdbEpisode => (TmdbEpisode: tmdbEpisode, TmdbAlternateOrdering: tmdbEpisode.GetTmdbAlternateOrderingEpisodeById(tuple0.TmdbShow.PreferredAlternateOrderingID))) + .Where(tuple1 => tuple1.TmdbAlternateOrdering is not null) + .Select(tuple1 => new TmdbEpisode(tuple0.TmdbShow, tuple1.TmdbEpisode, tuple1.TmdbAlternateOrdering) + )) + .ToList(), + Movies = tmdbMovieXRefs + .Select(tmdbMovieXref => tmdbMovieXref.TmdbMovie) + .WhereNotNull() + .Select(tmdbMovie => new TmdbMovie(tmdbMovie)) + .ToList(), + }; if (includeFiles) - Files = files.Select(f => new File(context, f, false, includeDataFrom, includeMediaInfo, includeAbsolutePaths)); + Files = files + .Select(f => new File(context, f, false, includeDataFrom, includeMediaInfo, includeAbsolutePaths)) + .ToList(); if (withXRefs) - CrossReferences = FileCrossReference.From(episode.FileCrossRefs).FirstOrDefault()?.EpisodeIDs ?? []; + CrossReferences = FileCrossReference.From(episode.FileCrossReferences).FirstOrDefault()?.EpisodeIDs ?? []; } internal static EpisodeType MapAniDBEpisodeType(AniDBEpisodeType episodeType) @@ -139,18 +221,6 @@ internal static EpisodeType MapAniDBEpisodeType(AniDBEpisodeType episodeType) _ => EpisodeType.Unknown, }; - public static void AddEpisodeVote(HttpContext context, SVR_AnimeEpisode ep, int userID, Vote vote) - { - var dbVote = RepoFactory.AniDB_Vote.GetByEntityAndType(ep.AnimeEpisodeID, AniDBVoteType.Episode) ?? - new AniDB_Vote { EntityID = ep.AnimeEpisodeID, VoteType = (int)AniDBVoteType.Episode }; - dbVote.VoteValue = (int)Math.Floor(vote.GetRating(1000)); - - RepoFactory.AniDB_Vote.Save(dbVote); - - //var cmdVote = new CommandRequest_VoteAnimeEpisode(ep.AniDB_EpisodeID, voteType, vote.GetRating()); - //cmdVote.Save(); - } - /// <summary> /// AniDB specific data for an Episode /// </summary> @@ -177,15 +247,9 @@ public AniDB(SVR_AniDB_Episode ep) AirDate = ep.GetAirDateAsDate(); Description = ep.Description; Rating = new Rating { MaxValue = 10, Value = rating, Votes = votes, Source = "AniDB" }; - Title = mainTitle; + Title = mainTitle.Title; Titles = titles - .Select(a => new Title - { - Name = a.Title, - Language = a.LanguageCode, - Default = string.Equals(a.Title, defaultTitle), - Source = "AniDB", - }) + .Select(a => new Title(a, defaultTitle.Title, mainTitle)) .ToList(); } @@ -232,89 +296,6 @@ public AniDB(SVR_AniDB_Episode ep) public Rating Rating { get; set; } } - public class TvDB - { - public TvDB(TvDB_Episode tvDBEpisode) - { - var rating = tvDBEpisode.Rating == null - ? null - : new Rating { MaxValue = 10, Value = tvDBEpisode.Rating.Value, Source = "TvDB" }; - ID = tvDBEpisode.Id; - Season = tvDBEpisode.SeasonNumber; - Number = tvDBEpisode.EpisodeNumber; - AbsoluteNumber = tvDBEpisode.AbsoluteNumber; - Title = tvDBEpisode.EpisodeName; - Description = tvDBEpisode.Overview; - AirDate = tvDBEpisode.AirDate; - Rating = rating; - AirsAfterSeason = tvDBEpisode.AirsAfterSeason; - AirsBeforeSeason = tvDBEpisode.AirsBeforeSeason; - AirsBeforeEpisode = tvDBEpisode.AirsBeforeEpisode; - Thumbnail = new Image(tvDBEpisode.Id, ImageEntityType.TvDB_Episode, true); - } - - /// <summary> - /// TvDB Episode ID - /// </summary> - public int ID { get; set; } - - /// <summary> - /// Season Number, 0 is Specials. TvDB's Season system doesn't always make sense for anime, so don't count on it - /// </summary> - public int Season { get; set; } - - /// <summary> - /// Episode Number in the Season. This is not Absolute Number - /// </summary> - public int Number { get; set; } - - /// <summary> - /// Absolute Episode Number. Keep in mind that due to reordering, this may not be accurate. - /// </summary> - public int? AbsoluteNumber { get; set; } - - /// <summary> - /// Episode Title, in the language selected for TvDB. TvDB doesn't allow pulling more than one language at a time, so this isn't a list. - /// </summary> - public string Title { get; set; } - - /// <summary> - /// Episode Description, in the language selected for TvDB. See Title for more info on Language. - /// </summary> - public string Description { get; set; } - - /// <summary> - /// Air Date. Unfortunately, the TvDB air date doesn't necessarily conform to a specific timezone, so it can be a day off. If you see one that's wrong, please fix it on TvDB. You have the ID here in this model for easy lookup. - /// </summary> - [JsonConverter(typeof(DateFormatConverter), "yyyy-MM-dd")] - public DateTime? AirDate { get; set; } - - /// <summary> - /// Mostly for specials. It shows when in the timeline the episode aired. I wouldn't count on it, as it's often blank. - /// </summary> - public int? AirsAfterSeason { get; set; } - - /// <summary> - /// Mostly for specials. It shows when in the timeline the episode aired. I wouldn't count on it, as it's often blank. - /// </summary> - public int? AirsBeforeSeason { get; set; } - - /// <summary> - /// Like AirsAfterSeason, it is for determining where in the timeline an episode airs. Also often blank. - /// </summary> - public int? AirsBeforeEpisode { get; set; } - - /// <summary> - /// Rating of the episode - /// </summary> - public Rating Rating { get; set; } - - /// <summary> - /// The TvDB Thumbnail. Later, we'll have more thumbnail support, and episodes will have an Images endpoint like series, but for now, this will do. - /// </summary> - public Image Thumbnail { get; set; } - } - public class EpisodeIDs : IDs { #region Series @@ -341,9 +322,44 @@ public class EpisodeIDs : IDs /// </summary> public List<int> TvDB { get; set; } = []; - // TODO Support for TvDB string IDs (like in the new URLs) one day maybe + /// <summary> + /// The IMDB Movie IDs. + /// </summary> + public List<string> IMDB { get; set; } = []; #endregion + /// <summary> + /// The Movie DataBase (TMDB) Cross-Reference IDs. + /// </summary> + public TmdbEpisodeIDs TMDB { get; init; } = new(); + + public class TmdbEpisodeIDs + { + public List<int> Episode { get; init; } = []; + + public List<int> Movie { get; init; } = []; + + public List<int> Show { get; init; } = []; + } + } + + public class TmdbData + { + public IEnumerable<TmdbEpisode> Episodes { get; init; } = []; + + public IEnumerable<TmdbMovie> Movies { get; init; } = []; + } + + public static class Input + { + public class EpisodeTitleOverrideBody + { + /// <summary> + /// New title to be set as override for the series + /// </summary> + [Required(AllowEmptyStrings = true)] + public string Title { get; set; } = string.Empty; + } } } @@ -404,12 +420,3 @@ public enum EpisodeType /// </summary> Extra = 10 } - -public class EpisodeTitleOverride -{ - /// <summary> - /// New title to be set as override for the series - /// </summary> - [Required(AllowEmptyStrings = true)] - public string Title { get; set; } -} diff --git a/Shoko.Server/API/v3/Models/Shoko/File.cs b/Shoko.Server/API/v3/Models/Shoko/File.cs index 8ea81819b..108a3a3e0 100644 --- a/Shoko.Server/API/v3/Models/Shoko/File.cs +++ b/Shoko.Server/API/v3/Models/Shoko/File.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels; using Shoko.Server.API.Converters; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.Models; @@ -17,7 +18,7 @@ namespace Shoko.Server.API.v3.Models.Shoko; -public class File +public partial class File { /// <summary> /// The ID of the File. You'll need this to play it. @@ -128,11 +129,12 @@ public File(HttpContext context, SVR_VideoLocal file, bool withXRefs = false, Ha public File(SVR_VideoLocal_User userRecord, SVR_VideoLocal file, bool withXRefs = false, HashSet<DataSource> includeDataFrom = null, bool includeMediaInfo = false, bool includeAbsolutePaths = false) { + var mediaInfo = file.MediaInfo as IMediaInfo; ID = file.VideoLocalID; Size = file.FileSize; IsVariation = file.IsVariation; Hashes = new Hashes { ED2K = file.Hash, MD5 = file.MD5, CRC32 = file.CRC32, SHA1 = file.SHA1 }; - Resolution = FileQualityFilter.GetResolution(file); + Resolution = mediaInfo?.VideoStream?.Resolution; Locations = file.Places.Select(location => new Location(location, includeAbsolutePaths)).ToList(); AVDump = new AVDumpInfo(file); Duration = file.DurationTimeSpan; @@ -152,19 +154,15 @@ public File(SVR_VideoLocal_User userRecord, SVR_VideoLocal file, bool withXRefs _AniDB = new AniDB(anidbFile); } - if (includeMediaInfo) - { - var mediaContainer = file?.MediaInfo ?? - throw new Exception("Unable to find media container for File"); - MediaInfo = new MediaInfo(file, mediaContainer); - } + if (includeMediaInfo && mediaInfo is not null) + MediaInfo = new MediaInfo(file, mediaInfo); } #nullable enable /// <summary> /// Represents a file location. /// </summary> - public class Location + public partial class Location { /// <summary> /// The file location id. @@ -246,32 +244,6 @@ public class AutoRelocateBody public bool DeleteEmptyDirectories { get; set; } = true; } - /// <summary> - /// Represents the information required to create or move to a new file - /// location. - /// </summary> - public class NewLocationBody - { - /// <summary> - /// The id of the <see cref="ImportFolder"/> where this file should - /// be relocated to. - /// </summary> - [Required] - public int ImportFolderID { get; set; } - - /// <summary> - /// The new relative path from the <see cref="ImportFolder"/>'s path - /// on the server. - /// </summary> - [Required] - public string RelativePath { get; set; } = string.Empty; - - /// <summary> - /// Indicates whether empty directories should be deleted after - /// relocating the file. - /// </summary> - public bool DeleteEmptyDirectories { get; set; } = true; - } } #nullable disable diff --git a/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs b/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs index 7bc70c0d4..bfee149e3 100644 --- a/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs +++ b/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Models; using Shoko.Server.Repositories; @@ -17,12 +19,12 @@ public class FileCrossReference /// <summary> /// The Series IDs. /// </summary> - public SeriesCrossReferenceIDs SeriesID { get; set; } + public SeriesCrossReferenceIDs SeriesID { get; set; } = new(); /// <summary> /// The Episode IDs. /// </summary> - public List<EpisodeCrossReferenceIDs> EpisodeIDs { get; set; } + public List<EpisodeCrossReferenceIDs> EpisodeIDs { get; set; } = []; /// <summary> /// File episode cross-reference for a series. @@ -40,9 +42,9 @@ public class EpisodeCrossReferenceIDs public int AniDB { get; set; } /// <summary> - /// Any TvDB IDs linked to the AniDB episode. + /// The Movie DataBase (TMDB) Cross-Reference IDs. /// </summary> - public List<int> TvDB { get; set; } + public Episode.EpisodeIDs.TmdbEpisodeIDs TMDB { get; set; } = new(); /// <summary> /// The AniDB Release Group's ID, or null if this is a manually linked @@ -53,7 +55,7 @@ public class EpisodeCrossReferenceIDs /// <summary> /// ED2K hash to look up the file by hash + file size. /// </summary> - public string ED2K { get; set; } + public string ED2K { get; set; } = string.Empty; /// <summary> /// File size to look up the file by hash + file size. @@ -63,12 +65,12 @@ public class EpisodeCrossReferenceIDs /// <summary> /// Percentage file is matched to the episode. /// </summary> - public CrossReferencePercentage Percentage { get; set; } + public CrossReferencePercentage Percentage { get; set; } = new(); /// <summary> /// The cross-reference source. /// </summary> - public string Source { get; set; } + public string Source { get; set; } = string.Empty; } public class CrossReferencePercentage @@ -111,9 +113,9 @@ public class SeriesCrossReferenceIDs public int AniDB { get; set; } /// <summary> - /// Any TvDB IDs linked to the AniDB series. + /// The Movie DataBase (TMDB) Cross-Reference IDs. /// </summary> - public List<int> TvDB { get; set; } + public Series.SeriesIDs.TmdbSeriesIDs TMDB { get; set; } = new(); } private static int PercentageToFileCount(int percentage) @@ -136,17 +138,18 @@ private static int PercentageToFileCount(int percentage) _ => 0, // anything below this we can't reliably measure. }; - public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episode> crossReferences) + public static List<FileCrossReference> From(IEnumerable<IVideoCrossReference> crossReferences) => crossReferences + .Where(xref => xref.Video is not null) .Select(xref => { // Percentages. Tuple<int, int> percentage = new(0, 100); - int? releaseGroup = xref.CrossRefSource == (int)CrossRefSource.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref.Hash, xref.FileSize)?.GroupID ?? 0 : null; + int? releaseGroup = xref.Source == DataSourceEnum.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref.ED2K, xref.Size)?.GroupID ?? 0 : null; var assumedFileCount = PercentageToFileCount(xref.Percentage); if (assumedFileCount > 1) { - var xrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.EpisodeID) + var xrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID) // Filter to only cross-references which are partially linked in the same number of parts to the episode, and from the same group as the current cross-reference. .Where(xref2 => PercentageToFileCount(xref2.Percentage) == assumedFileCount && (xref2.CrossRefSource == (int)CrossRefSource.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref2.Hash, xref2.FileSize)?.GroupID ?? -1 : null) == releaseGroup) // This will order by the "full" episode if the xref is linked to both a "full" episode and "part" episode, @@ -162,7 +165,7 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod .ThenBy(tuple => tuple.episode?.EpisodeNumber) .ThenBy(tuple => tuple.xref.EpisodeOrder) .ToList(); - var index = xrefs.FindIndex(tuple => tuple.xref.CrossRef_File_EpisodeID == xref.CrossRef_File_EpisodeID); + var index = xrefs.FindIndex(tuple => string.Equals(tuple.xref.Hash, xref.ED2K) && tuple.xref.FileSize == xref.Size); if (index > 0) { // Note: this is bound to be inaccurate if we don't have all the files linked to the episode locally, but as long @@ -183,15 +186,29 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod } } - var shokoEpisode = xref.AnimeEpisode; + var shokoEpisode = xref.ShokoEpisode as SVR_AnimeEpisode; return ( xref, dto: new EpisodeCrossReferenceIDs { ID = shokoEpisode?.AnimeEpisodeID, - AniDB = xref.EpisodeID, + AniDB = xref.AnidbEpisodeID, ReleaseGroup = releaseGroup, - TvDB = shokoEpisode?.TvDBEpisodes.Select(b => b.Id).ToList() ?? [], + TMDB = new() + { + Episode = shokoEpisode?.TmdbEpisodeCrossReferences + .Where(xref => xref.TmdbEpisodeID != 0) + .Select(xref => xref.TmdbEpisodeID) + .ToList() ?? [], + Movie = shokoEpisode?.TmdbMovieCrossReferences + .Select(xref => xref.TmdbMovieID) + .ToList() ?? [], + Show = shokoEpisode?.TmdbEpisodeCrossReferences + .Where(xref => xref.TmdbShowID != 0) + .Select(xref => xref.TmdbShowID) + .Distinct() + .ToList() ?? [], + }, Percentage = new() { Size = xref.Percentage, @@ -199,9 +216,9 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod Start = percentage.Item1, End = percentage.Item2, }, - ED2K = xref.Hash, - FileSize = xref.FileSize, - Source = xref.CrossRefSource == (int)CrossRefSource.AniDB ? "AniDB" : "User", + ED2K = xref.ED2K, + FileSize = xref.Size, + Source = xref.Source == DataSourceEnum.AniDB ? "AniDB" : "User", } ); }) @@ -212,7 +229,7 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod // we will attempt to lookup the episode to grab it's id but fallback // to the cross-reference anime id if the episode is not locally available // yet. - .GroupBy(tuple => tuple.xref.AniDBEpisode?.AnimeID ?? tuple.xref.AnimeID) + .GroupBy(tuple => tuple.xref.AnidbEpisode?.SeriesID ?? tuple.xref.AnidbAnimeID) .Select(tuples => { var shokoSeries = RepoFactory.AnimeSeries.GetByAnimeID(tuples.Key); @@ -222,7 +239,11 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod { ID = shokoSeries?.AnimeSeriesID, AniDB = tuples.Key, - TvDB = shokoSeries?.TvDBSeries.Select(b => b.SeriesID).ToList() ?? [], + TMDB = new() + { + Movie = shokoSeries?.TmdbMovieCrossReferences.Select(xref => xref.TmdbMovieID).Distinct().ToList() ?? [], + Show = shokoSeries?.TmdbShowCrossReferences.Select(xref => xref.TmdbShowID).Distinct().ToList() ?? [], + }, }, EpisodeIDs = tuples.Select(tuple => tuple.dto).ToList(), }; diff --git a/Shoko.Server/API/v3/Models/Shoko/Filter.cs b/Shoko.Server/API/v3/Models/Shoko/Filter.cs index f193476f5..219ebaf4c 100644 --- a/Shoko.Server/API/v3/Models/Shoko/Filter.cs +++ b/Shoko.Server/API/v3/Models/Shoko/Filter.cs @@ -19,7 +19,7 @@ public class Filter : BaseModel /// <summary> /// The Filter ID. /// </summary> - public FilterIDs IDs { get; set; } + public FilterIDs IDs { get; set; } = new(); /// <summary> /// Indicates the filter cannot be edited by a user. @@ -75,7 +75,7 @@ public class FilterCondition /// ex. And, Or, Not, HasAudioLanguage /// </summary> [Required] - public string Type { get; set; } + public string Type { get; set; } = string.Empty; /// <summary> /// The first, or left, child expression.<br/> @@ -116,13 +116,13 @@ public class FilterExpressionHelp /// This is what you give the API, not actually the internal type (it is the internal type without the word Expression) /// </summary> [Required] - public string Expression { get; init; } + public string Expression { get; init; } = string.Empty; /// <summary> /// The human readable name of the Expression /// </summary> [Required] - public string Name { get; init; } + public string Name { get; init; } = string.Empty; /// <summary> /// The group that this filter expression belongs to. This can help with filtering the expression types @@ -134,7 +134,7 @@ public class FilterExpressionHelp /// A description of what the expression is doing, comparing, etc /// </summary> [Required] - public string Description { get; init; } + public string Description { get; init; } = string.Empty; /// <summary> /// This is what the expression would be considered for parameters, for example, Air Date is a Date Selector @@ -228,7 +228,8 @@ public enum FilterExpressionParameterType Date, Number, String, - TimeSpan + TimeSpan, + Bool, } } @@ -239,19 +240,19 @@ public class SortingCriteriaHelp /// This is what you give the API, not actually the internal type (it is the internal type without the word Expression) /// </summary> [Required] - public string Type { get; init; } + public string Type { get; init; } = string.Empty; /// <summary> /// Human readable name /// </summary> [Required] - public string Name { get; set; } + public string Name { get; set; } = string.Empty; /// <summary> /// A description of what the expression is doing, comparing, etc /// </summary> [Required] - public string Description { get; init; } + public string Description { get; init; } = string.Empty; } /// <summary> @@ -267,7 +268,7 @@ public class SortingCriteria /// ex. And, Or, Not, HasAudioLanguage<br/> /// </summary> [Required] - public string Type { get; set; } + public string Type { get; set; } = string.Empty; /// <summary> /// The next expression to fall back on when the SortingExpression is equal or invalid, for example, sort by Episode Count descending then by Name @@ -311,12 +312,12 @@ public class CreateOrUpdateFilterBody public bool IsDirectory { get; set; } /// <summary> - /// Indicates the filter should be hidden unless explictly requested. This will hide the filter from the normal UIs. + /// Indicates the filter should be hidden unless explicitly requested. This will hide the filter from the normal UIs. /// </summary> public bool IsHidden { get; set; } /// <summary> - /// Inidcates the filter should be applied at the series level. + /// Indicates the filter should be applied at the series level. /// Filter conditions like like Seasons, Years, Tags, etc only count series individually, rather than by group. /// </summary> public bool ApplyAtSeriesLevel { get; set; } diff --git a/Shoko.Server/API/v3/Models/Shoko/Group.cs b/Shoko.Server/API/v3/Models/Shoko/Group.cs index 5c2ef6d8d..b25168f44 100644 --- a/Shoko.Server/API/v3/Models/Shoko/Group.cs +++ b/Shoko.Server/API/v3/Models/Shoko/Group.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -66,32 +67,20 @@ public class Group : BaseModel #region Constructors - public Group(HttpContext ctx, SVR_AnimeGroup group, bool randomiseImages = false) + public Group(SVR_AnimeGroup group, int userID = 0, bool randomizeImages = false) { var subGroupCount = group.Children.Count; - var userID = ctx.GetUser()?.JMMUserID ?? 0; var allSeries = group.AllSeries; var mainSeries = allSeries.FirstOrDefault(); - var episodes = allSeries.SelectMany(a => a.AnimeEpisodes).ToList(); - + var episodes = allSeries.SelectMany(a => a.AllAnimeEpisodes).ToList(); IDs = new GroupIDs { ID = group.AnimeGroupID }; if (group.DefaultAnimeSeriesID != null) - { IDs.PreferredSeries = group.DefaultAnimeSeriesID.Value; - } - if (mainSeries != null) - { IDs.MainSeries = mainSeries.AnimeSeriesID; - } - if (group.AnimeGroupParentID.HasValue) - { IDs.ParentGroup = group.AnimeGroupParentID.Value; - } - IDs.TopLevelGroup = group.TopLevelAnimeGroup.AnimeGroupID; - Name = group.GroupName; SortName = group.SortName; Description = group.Description; @@ -99,10 +88,7 @@ public Group(HttpContext ctx, SVR_AnimeGroup group, bool randomiseImages = false Size = allSeries.Count(series => series.AnimeGroupID == group.AnimeGroupID); HasCustomName = group.IsManuallyNamed == 1; HasCustomDescription = group.OverrideDescription == 1; - - // TODO make a factory for this file. Not feeling it rn - var factory = ctx.RequestServices.GetRequiredService<SeriesFactory>(); - Images = mainSeries == null ? new Images() : factory.GetDefaultImages(mainSeries, randomiseImages); + Images = mainSeries == null ? new Images() : mainSeries.GetImages().ToDto(preferredImages: true, randomizeImages: randomizeImages); } #endregion @@ -161,7 +147,7 @@ public class CreateOrUpdateGroupBody /// <summary> /// All the series to put into the group. /// </summary> - public List<int>? SeriesIDs { get; set; } = new(); + public List<int>? SeriesIDs { get; set; } = null; /// <summary> /// All groups to put into the group as sub-groups. @@ -170,7 +156,7 @@ public class CreateOrUpdateGroupBody /// If the parent group is a sub-group of any of the groups in this /// array, then the request will be aborted. /// </remarks> - public List<int>? GroupIDs { get; set; } = new(); + public List<int>? GroupIDs { get; set; } = null; /// <summary> /// The group's custom name. @@ -198,7 +184,7 @@ public class CreateOrUpdateGroupBody /// <remarks> /// Leave it as <c>null</c> to conditionally set the value if /// <see cref="Name"/> is set, or - /// explictly set it to <c>true</c> to lock in the new/current + /// explicitly set it to <c>true</c> to lock in the new/current /// names, or set it to <c>false</c> to reset the names back to the /// automatic naming based on the main series. /// </remarks> @@ -209,7 +195,7 @@ public class CreateOrUpdateGroupBody /// </summary> /// <remarks> /// Leave it as <c>null</c> to conditionally set the value if - /// <see cref="Description"/> is set, or explictly set it to + /// <see cref="Description"/> is set, or explicitly set it to /// <c>true</c> to lock in the new/current description, or set it to /// <c>false</c> to reset the names back to the automatic naming /// based on the main series. @@ -227,7 +213,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) GroupIDs = group.Children.Select(group => group.AnimeGroupID).ToList(); } - public Group? MergeWithExisting(HttpContext ctx, SVR_AnimeGroup group, ModelStateDictionary modelState) + public Group? MergeWithExisting(SVR_AnimeGroup group, int userID, ModelStateDictionary modelState) { // Validate if the parent exists if a parent id is set. SVR_AnimeGroup? parent = null; @@ -240,7 +226,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) } else { - if (parent.IsDescendantOf(GroupIDs)) + if (GroupIDs is not null && parent.IsDescendantOf(GroupIDs)) modelState.AddModelError(nameof(ParentGroupID), "Infinite recursion detected between selected parent group and child groups."); if (group.AnimeGroupID != 0 && parent.IsDescendantOf(group.AnimeGroupID)) modelState.AddModelError(nameof(ParentGroupID), "Infinite recursion detected between selected parent group and current group."); @@ -248,9 +234,9 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) } // Get the groups and validate the group ids. - var childGroups = GroupIDs == null ? new() : GroupIDs + var childGroups = GroupIDs == null ? [] : GroupIDs .Select(groupID => groupID > 0 ? RepoFactory.AnimeGroup.GetByID(groupID) : null) - .OfType<SVR_AnimeGroup>() + .WhereNotNull() .ToList(); if (childGroups.Count != (GroupIDs?.Count ?? 0)) { @@ -261,7 +247,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) } // Get the series and validate the series ids. - var seriesList = SeriesIDs == null ? new() : SeriesIDs + var seriesList = SeriesIDs == null ? [] : SeriesIDs .Select(id => id > 0 ? RepoFactory.AnimeSeries.GetByID(id) : null) .WhereNotNull() .ToList(); @@ -285,7 +271,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) modelState.AddModelError(nameof(GroupIDs), "Unable to create an empty group without any series or child groups."); } - // Find the preferred series among the list of seris. + // Find the preferred series among the list of series. SVR_AnimeSeries? preferredSeries = null; if (PreferredSeriesID.HasValue && PreferredSeriesID.Value != 0) { @@ -316,6 +302,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) continue; childGroup.AnimeGroupParentID = group.AnimeGroupID; + childGroup.DateTimeUpdated = DateTime.Now; RepoFactory.AnimeGroup.Save(childGroup, false); } @@ -332,7 +319,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) // Check if the names have changed if we omit the value, or if // we set it to true. - if (!HasCustomName.HasValue || HasCustomName.Value) + if (HasCustomName ?? true) { // Lock the name if it's set to true. if (HasCustomName.HasValue) @@ -350,11 +337,11 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) else { group.IsManuallyNamed = 0; - group.GroupName = (preferredSeries ?? group.MainSeries ?? group.AllSeries.FirstOrDefault())?.SeriesName ?? group.GroupName; + group.GroupName = (preferredSeries ?? group.MainSeries ?? group.AllSeries.FirstOrDefault())?.PreferredTitle ?? group.GroupName; } // Same as above, but for the description. - if (!HasCustomDescription.HasValue || HasCustomDescription.Value) + if (HasCustomDescription ?? true) { if (HasCustomDescription.HasValue) group.OverrideDescription = 1; @@ -371,7 +358,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) else { group.OverrideDescription = 0; - group.Description = (preferredSeries ?? group.MainSeries ?? group.AllSeries.FirstOrDefault())?.AniDB_Anime.Description ?? group.Description; + group.Description = (preferredSeries ?? group.MainSeries ?? group.AllSeries.FirstOrDefault())?.AniDB_Anime?.Description ?? group.Description; } // Update stats for all groups in the chain @@ -382,7 +369,7 @@ public CreateOrUpdateGroupBody(SVR_AnimeGroup group) ShokoEventHandler.Instance.OnSeriesUpdated(series, UpdateReason.Updated); // Return a new representation of the group. - return new Group(ctx, group); + return new Group(group, userID); } } } @@ -423,12 +410,12 @@ public GroupSizes(SeriesSizes sizes) public class SeriesTypeCounts { - public int Unknown; - public int Other; - public int TV; - public int TVSpecial; - public int Web; - public int Movie; - public int OVA; + public int Unknown { get; set; } + public int Other { get; set; } + public int TV { get; set; } + public int TVSpecial { get; set; } + public int Web { get; set; } + public int Movie { get; set; } + public int OVA { get; set; } } } diff --git a/Shoko.Server/API/v3/Models/Shoko/ImportFolder.cs b/Shoko.Server/API/v3/Models/Shoko/ImportFolder.cs index fda61fd4a..b33d5c1e4 100644 --- a/Shoko.Server/API/v3/Models/Shoko/ImportFolder.cs +++ b/Shoko.Server/API/v3/Models/Shoko/ImportFolder.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.Models; @@ -40,10 +41,15 @@ public ImportFolder() { } public ImportFolder(SVR_ImportFolder folder) { var series = RepoFactory.VideoLocalPlace.GetByImportFolder(folder.ImportFolderID) - .Select(a => a?.VideoLocal?.Hash).Where(a => !string.IsNullOrEmpty(a)).Distinct() - .SelectMany(RepoFactory.CrossRef_File_Episode.GetByHash).DistinctBy(a => a.AnimeID).Count(); + .Select(a => a?.VideoLocal?.Hash) + .Where(a => !string.IsNullOrEmpty(a)) + .Distinct() + .SelectMany(RepoFactory.CrossRef_File_Episode.GetByHash) + .DistinctBy(a => a.AnimeID) + .Count(); var size = RepoFactory.VideoLocalPlace.GetByImportFolder(folder.ImportFolderID) - .Select(a => a.VideoLocal).Where(b => b != null) + .Select(a => a.VideoLocal) + .WhereNotNull() .Sum(b => b.FileSize); DropFolderType type; diff --git a/Shoko.Server/API/v3/Models/Shoko/MediaInfo.cs b/Shoko.Server/API/v3/Models/Shoko/MediaInfo.cs index 012d3b301..fe3afde65 100644 --- a/Shoko.Server/API/v3/Models/Shoko/MediaInfo.cs +++ b/Shoko.Server/API/v3/Models/Shoko/MediaInfo.cs @@ -83,44 +83,28 @@ public class MediaInfo /// </summary> public List<ChapterInfo> Chapters { get; } - public MediaInfo(SVR_VideoLocal file, MediaContainer mediaContainer) + public MediaInfo(SVR_VideoLocal file, IMediaInfo mediaInfo) { - var general = mediaContainer.GeneralStream; - Title = general.Title; + Title = mediaInfo.Title; Duration = file.DurationTimeSpan; - BitRate = general.OverallBitRate; - FrameRate = general.FrameRate; - Encoded = general.Encoded_Date?.ToUniversalTime(); - Audio = mediaContainer.AudioStreams + BitRate = mediaInfo.BitRate; + FrameRate = mediaInfo.FrameRate; + Encoded = mediaInfo.Encoded?.ToUniversalTime(); + Audio = mediaInfo.AudioStreams .Select(audio => new AudioStreamInfo(audio)) .ToList(); - Video = mediaContainer.VideoStreams + Video = mediaInfo.VideoStreams .Select(video => new VideoStreamInfo(video)) .ToList(); - Subtitles = mediaContainer.TextStreams + Subtitles = mediaInfo.TextStreams .Select(text => new TextStreamInfo(text)) .ToList(); - Chapters = new(); - FileExtension = general.FileExtension; - MediaContainer = general.Format; - MediaContainerVersion = general.Format_Version; - var menu = mediaContainer.MenuStreams.FirstOrDefault(); - if (menu?.extra != null) - { - foreach (var (key, value) in menu.extra) - { - if (string.IsNullOrEmpty(key)) - continue; - var (hours, minutes, seconds, milliseconds, _rest) = key[1..].Split('_'); - if (!TimeSpan.TryParse($"{hours}:{minutes}:{seconds}.{milliseconds}", out var timestamp)) - continue; - var index = value.IndexOf(':'); - var title = string.IsNullOrEmpty(value) ? string.Empty : index != -1 ? value[(index + 1)..].Trim() : value.Trim(); - var language = index == -1 || index == 0 ? TitleLanguage.Unknown : value[..index].GetTitleLanguage(); - var chapterInfo = new ChapterInfo(title, language, timestamp); - Chapters.Add(chapterInfo); - } - } + Chapters = mediaInfo.Chapters + .Select(chapter => new ChapterInfo(chapter)) + .ToList(); + FileExtension = mediaInfo.FileExtension; + MediaContainer = mediaInfo.ContainerName; + MediaContainerVersion = mediaInfo.ContainerVersion; } public class StreamInfo @@ -176,18 +160,18 @@ public class StreamInfo /// </summary> public StreamFormatInfo Format { get; } - public StreamInfo(Stream stream) + public StreamInfo(IStream stream) { ID = stream.ID; - UID = stream.UniqueID; + UID = stream.UID; Title = stream.Title; - Order = stream.StreamOrder; - IsDefault = stream.Default; - IsForced = stream.Forced; - Language = stream.Language?.GetTitleLanguage(); + Order = stream.Order; + IsDefault = stream.IsDefault; + IsForced = stream.IsForced; + Language = stream.Language; LanguageCode = stream.LanguageCode; - Codec = new StreamCodecInfo(stream); - Format = new StreamFormatInfo(stream); + Codec = new StreamCodecInfo(stream.Codec); + Format = new StreamFormatInfo(stream.Format); } } @@ -220,7 +204,7 @@ public class StreamFormatInfo public string? AdditionalFeatures { get; } /// <summary> - /// Format edianness, if available. + /// Format endianness, if available. /// </summary> public string? Endianness { get; } @@ -243,7 +227,7 @@ public class StreamFormatInfo public string? HDR { get; } /// <summary> - /// HDR format compatibility informaiton, if available. + /// HDR format compatibility information, if available. /// </summary> /// <remarks> /// Only available for <see cref="VideoStreamInfo"/>. @@ -259,7 +243,7 @@ public class StreamFormatInfo public bool? CABAC { get; } /// <summary> - /// Bi-direcitonal video object planes (BVOP). + /// Bi-directional video object planes (BVOP). /// </summary> /// <remarks> /// Only available for <see cref="VideoStreamInfo"/>. @@ -290,26 +274,23 @@ public class StreamFormatInfo /// </remarks> public int? ReferenceFrames { get; } - public StreamFormatInfo(Stream stream) + public StreamFormatInfo(IStreamFormatInfo info) { - Name = stream.Format?.ToLowerInvariant() ?? "unknown"; - Profile = stream.Format_Profile?.ToLowerInvariant(); - Level = stream.Format_Level?.ToLowerInvariant(); - Settings = stream.Format_Settings; - AdditionalFeatures = stream.Format_AdditionalFeatures; - Endianness = stream.Format_Settings_Endianness; - Tier = stream.Format_Tier; - Commercial = stream.Format_Commercial_IfAny; - if (stream is VideoStream videoStream) - { - HDR = videoStream.HDR_Format; - HDRCompatibility = videoStream.HDR_Format_Compatibility; - CABAC = videoStream.Format_Settings_CABAC; - BVOP = videoStream.Format_Settings_BVOP; - QPel = videoStream.Format_Settings_QPel; - GMC = videoStream.Format_Settings_GMC; - ReferenceFrames = videoStream.Format_Settings_RefFrames; - } + Name = info.Name; + Profile = info.Profile; + Level = info.Level; + Settings = info.Settings; + AdditionalFeatures = info.AdditionalFeatures; + Endianness = info.Endianness; + Tier = info.Tier; + Commercial = info.Commercial; + HDR = info.HDR; + HDRCompatibility = info.HDRCompatibility; + CABAC = info.CABAC; + BVOP = info.BVOP; + QPel = info.QPel; + GMC = info.GMC; + ReferenceFrames = info.ReferenceFrames; } } @@ -331,11 +312,11 @@ public class StreamCodecInfo /// </summary> public string? Raw { get; } - public StreamCodecInfo(Stream stream) + public StreamCodecInfo(IStreamCodecInfo codec) { - Name = stream.Codec; - Simplified = LegacyMediaUtils.TranslateCodec(stream)?.ToLowerInvariant() ?? "unknown"; - Raw = stream.CodecID?.ToLowerInvariant(); + Name = codec.Name; + Simplified = codec.Simplified; + Raw = codec.Raw; } } @@ -393,7 +374,7 @@ public class VideoStreamInfo : StreamInfo public string ChromaSubsampling { get; } /// <summary> - /// Matrix co-efficients. + /// Matrix co-efficiencies. /// </summary> public string? MatrixCoefficients { get; } @@ -407,19 +388,19 @@ public class VideoStreamInfo : StreamInfo /// </summary> public int BitDepth { get; } - public VideoStreamInfo(VideoStream stream) : base(stream) + public VideoStreamInfo(IVideoStream stream) : base(stream) { Width = stream.Width; Height = stream.Height; - Resolution = MediaInfoUtils.GetStandardResolution(new Tuple<int, int>(Width, Height)); + Resolution = stream.Resolution; PixelAspectRatio = stream.PixelAspectRatio; FrameRate = stream.FrameRate; - FrameRateMode = stream.FrameRate_Mode; + FrameRateMode = stream.FrameRateMode; FrameCount = stream.FrameCount; ScanType = stream.ScanType; ColorSpace = stream.ColorSpace; ChromaSubsampling = stream.ChromaSubsampling; - MatrixCoefficients = stream.matrix_coefficients; + MatrixCoefficients = stream.MatrixCoefficients; BitRate = stream.BitRate; BitDepth = stream.BitDepth; } @@ -468,15 +449,15 @@ public class AudioStreamInfo : StreamInfo /// </summary> public int BitDepth { get; } - public AudioStreamInfo(AudioStream stream) : base(stream) + public AudioStreamInfo(IAudioStream stream) : base(stream) { Channels = stream.Channels; ChannelLayout = stream.ChannelLayout; SamplesPerFrame = stream.SamplesPerFrame; SamplingRate = stream.SamplingRate; - CompressionMode = stream.Compression_Mode; + CompressionMode = stream.CompressionMode; BitRate = stream.BitRate; - BitRateMode = stream.BitRate_Mode; + BitRateMode = stream.BitRateMode; BitDepth = stream.BitDepth; } } @@ -502,12 +483,12 @@ public class TextStreamInfo : StreamInfo [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string? ExternalFilename { get; } - public TextStreamInfo(TextStream stream) : base(stream) + public TextStreamInfo(ITextStream stream) : base(stream) { SubTitle = stream.SubTitle; - IsExternal = stream.External; - if (stream.External && !string.IsNullOrEmpty(stream.Filename)) - ExternalFilename = stream.Filename; + IsExternal = stream.IsExternal; + if (stream.IsExternal && !string.IsNullOrEmpty(stream.ExternalFilename)) + ExternalFilename = stream.ExternalFilename; } } @@ -529,11 +510,11 @@ public class ChapterInfo /// </summary> public TimeSpan Timestamp { get; } - public ChapterInfo(string title, TitleLanguage language, TimeSpan timestamp) + public ChapterInfo(IChapterInfo info) { - Title = title; - Language = language; - Timestamp = timestamp; + Title = info.Title; + Language = info.Language; + Timestamp = info.Timestamp; } } } diff --git a/Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs b/Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs new file mode 100644 index 000000000..f8b3091e0 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Shoko.Server.API.v3.Models.Shoko; + +/// <summary> +/// Playlist item. +/// </summary> +public class PlaylistItem +{ + /// <summary> + /// The main episode for the playlist item. + /// </summary> + public Episode Episode { get; } + + /// <summary> + /// Any additional episodes for the playlist item, if any. + /// </summary> + public IReadOnlyList<Episode> AdditionalEpisodes { get; } + + /// <summary> + /// All file parts for the playlist item. + /// </summary> + public IReadOnlyList<File> Parts { get; } + + /// <summary> + /// Initializes a new <see cref="PlaylistItem"/>. + /// </summary> + /// <param name="episodes">Episodes.</param> + /// <param name="files">Files.</param> + public PlaylistItem(IReadOnlyList<Episode> episodes, IReadOnlyList<File> files) + { + Episode = episodes[0]; + AdditionalEpisodes = episodes.Skip(1).ToList(); + Parts = files; + } +} + diff --git a/Shoko.Server/API/v3/Models/Shoko/Queue.cs b/Shoko.Server/API/v3/Models/Shoko/Queue.cs index af8cb7e3b..e8a778c1e 100644 --- a/Shoko.Server/API/v3/Models/Shoko/Queue.cs +++ b/Shoko.Server/API/v3/Models/Shoko/Queue.cs @@ -31,7 +31,7 @@ public class Queue /// <summary> /// The currently executing jobs and their details /// </summary> - public List<QueueItem> CurrentlyExecuting { get; set; } + public List<QueueItem> CurrentlyExecuting { get; set; } = []; public class QueueItem { @@ -40,22 +40,22 @@ public class QueueItem /// queue items over the life span of the queue, but only one item will /// exist with the same name at any given time. /// </summary> - public string Key { get; init; } + public string Key { get; init; } = string.Empty; /// <summary> /// The queue item type. /// </summary> - public string Type { get; init; } + public string Type { get; init; } = string.Empty; /// <summary> /// The Title line of a Queue Item, e.g. Hashing File /// </summary> - public string Title { get; init; } + public string Title { get; init; } = string.Empty; /// <summary> /// The details of the queue item. e.g. { "File Path": "/mnt/Drop/Steins Gate/Episode 1.mkv" } /// </summary> - public Dictionary<string, object> Details { get; init; } + public Dictionary<string, object> Details { get; init; } = []; /// <summary> /// Indicates the item is currently actively running in the queue. diff --git a/Shoko.Server/API/v3/Models/Shoko/ReleaseGroup.cs b/Shoko.Server/API/v3/Models/Shoko/ReleaseGroup.cs index 2e1d18d53..7723c2e67 100644 --- a/Shoko.Server/API/v3/Models/Shoko/ReleaseGroup.cs +++ b/Shoko.Server/API/v3/Models/Shoko/ReleaseGroup.cs @@ -1,5 +1,5 @@ -using Shoko.Models.Server; +using Shoko.Server.Models.AniDB; namespace Shoko.Server.API.v3.Models.Shoko; @@ -31,7 +31,7 @@ public ReleaseGroup(AniDB_ReleaseGroup group) { ID = group.GroupID; Name = group.GroupName; - ShortName = group.ShortName; + ShortName = group.GroupNameShort; Source = "AniDB"; } } diff --git a/Shoko.Server/API/v3/Models/Shoko/Relocation/BatchRelocateBody.cs b/Shoko.Server/API/v3/Models/Shoko/Relocation/BatchRelocateBody.cs new file mode 100644 index 000000000..a912aa52b --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/Relocation/BatchRelocateBody.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.API.v3.Models.Shoko.Relocation; + +public class BatchRelocateBody +{ + /// <summary> + /// The file IDs to preview + /// </summary> + [Required] + public IEnumerable<int> FileIDs { get; set; } + + /// <summary> + /// The config to use. If null, the default config will be used + /// </summary> + public RenamerConfig? Config { get; set; } +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Relocation/RelocateBody.cs b/Shoko.Server/API/v3/Models/Shoko/Relocation/RelocateBody.cs new file mode 100644 index 000000000..160a9ba5d --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/Relocation/RelocateBody.cs @@ -0,0 +1,31 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Shoko.Server.API.v3.Models.Shoko.Relocation; + +/// <summary> +/// Represents the information required to create or move to a new file +/// location. +/// </summary> +public class RelocateBody +{ + /// <summary> + /// The id of the <see cref="ImportFolder"/> where this file should + /// be relocated to. + /// </summary> + [Required] + public int ImportFolderID { get; set; } + + /// <summary> + /// The new relative path from the <see cref="ImportFolder"/>'s path + /// on the server. + /// </summary> + [Required] + public string RelativePath { get; set; } = string.Empty; + + /// <summary> + /// Indicates whether empty directories should be deleted after + /// relocating the file. + /// </summary> + public bool DeleteEmptyDirectories { get; set; } = true; +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Relocation/RelocationResult.cs b/Shoko.Server/API/v3/Models/Shoko/Relocation/RelocationResult.cs new file mode 100644 index 000000000..161df76d9 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/Relocation/RelocationResult.cs @@ -0,0 +1,84 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; + +namespace Shoko.Server.API.v3.Models.Shoko.Relocation; + +/// <summary> +/// Represents the result of a file relocation process. +/// </summary> +public class RelocationResult +{ + /// <summary> + /// The file id. + /// </summary> + [Required] + public int FileID { get; set; } + + /// <summary> + /// The file location id. May be null if a location to use was not + /// found. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public int? FileLocationID { get; set; } = null; + + /// <summary> + /// The name of the config that produced the final location for the + /// file if the relocation was successful and was not the result of + /// a manual relocation. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string? ConfigName { get; set; } = null; + + /// <summary> + /// The new id of the <see cref="ImportFolder"/> where the file now + /// resides, if the relocation was successful. Remember to check + /// <see cref="IsSuccess"/> to see the status of the relocation. + /// </summary> + /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public int? ImportFolderID { get; set; } = null; + + /// <summary> + /// Indicates whether the file was relocated successfully. + /// </summary> + [Required] + public bool IsSuccess { get; set; } = false; + + /// <summary> + /// Indicates whether the file was actually relocated from one + /// location to another, or if it was already at it's correct + /// location. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? IsRelocated { get; set; } = null; + + /// <summary> + /// Indicates if the result is only a preview and the file has not + /// actually been relocated yet. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? IsPreview { get; set; } = null; + + /// <summary> + /// The error message if the relocation was not successful. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string? ErrorMessage { get; set; } = null; + + /// <summary> + /// The new relative path from the <see cref="ImportFolder"/>'s path + /// on the server, if relocation was successful. Remember to check + /// <see cref="IsSuccess"/> to see the status of the relocation. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string? RelativePath { get; set; } = null; + + /// <summary> + /// The new absolute path for the file on the server, if relocation + /// was successful. Remember to check <see cref="IsSuccess"/> to see + /// the status of the relocation. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string? AbsolutePath { get; set; } = null; +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Relocation/Renamer.cs b/Shoko.Server/API/v3/Models/Shoko/Relocation/Renamer.cs new file mode 100644 index 000000000..df1500547 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/Relocation/Renamer.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +using DefaultSetting = Shoko.Server.API.v3.Models.Shoko.Relocation.Setting; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.API.v3.Models.Shoko.Relocation; + +public class Renamer +{ + /// <summary> + /// The ID of the renamer. Not used anywhere in the API, but could be useful as a list key + /// </summary> + public string RenamerID { get; set; } + + /// <summary> + /// The assembly version of the renamer. + /// </summary> + public string? Version { get; set; } + + /// <summary> + /// The name of the renamer. This is a unique ID! + /// </summary> + public string Name { get; set; } + + /// <summary> + /// A short description about the renamer. + /// </summary> + public string Description { get; set; } + + /// <summary> + /// If the renamer is enabled. + /// </summary> + public bool Enabled { get; set; } + + /// <summary> + /// The setting type definitions for the renamer. + /// </summary> + public List<SettingDefinition>? Settings { get; set; } + + /// <summary> + /// The default settings for the renamer, if any were provided + /// </summary> + public List<DefaultSetting>? DefaultSettings { get; set; } + +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Relocation/RenamerConfig.cs b/Shoko.Server/API/v3/Models/Shoko/Relocation/RenamerConfig.cs new file mode 100644 index 000000000..7ab091fed --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/Relocation/RenamerConfig.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.API.v3.Models.Shoko.Relocation; + +public class RenamerConfig +{ + /// <summary> + /// The ID of the renamer + /// </summary> + public string RenamerID { get; set; } + + /// <summary> + /// The name of the renamer. This is a unique ID! + /// </summary> + public string Name { get; set; } + + public List<Setting>? Settings { get; set; } +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Relocation/Setting.cs b/Shoko.Server/API/v3/Models/Shoko/Relocation/Setting.cs new file mode 100644 index 000000000..5c2b61693 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/Relocation/Setting.cs @@ -0,0 +1,17 @@ + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.API.v3.Models.Shoko.Relocation; + +public class Setting +{ + /// <summary> + /// Name of the setting + /// </summary> + public string Name { get; set; } + + /// <summary> + /// Value of the setting + /// </summary> + public object? Value { get; set; } +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Relocation/SettingDefinition.cs b/Shoko.Server/API/v3/Models/Shoko/Relocation/SettingDefinition.cs new file mode 100644 index 000000000..5ac78ac4a --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/Relocation/SettingDefinition.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Plugin.Abstractions.Enums; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.API.v3.Models.Shoko.Relocation; + +/// <summary> +/// A definition to build UI for a setting +/// </summary> +public class SettingDefinition +{ + /// <summary> + /// Name of the setting + /// </summary> + public string Name { get; set; } + + /// <summary> + /// Description of the setting + /// </summary> + public string? Description { get; set; } + + /// <summary> + /// The type of the setting. This is both for the webui to display and an easy lookup for what type to send and receive as a value + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public RenamerSettingType SettingType { get; set; } + + /// <summary> + /// Language for the editor to use for highlighting and intellisense + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public CodeLanguage? Language { get; set; } + + /// <summary> + /// The minimum value for the setting. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public object? MinimumValue { get; set; } + + /// <summary> + /// The maximum value for the setting. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public object? MaximumValue { get; set; } +} diff --git a/Shoko.Server/API/v3/Models/Shoko/Renamer.cs b/Shoko.Server/API/v3/Models/Shoko/Renamer.cs deleted file mode 100644 index 5fd470faa..000000000 --- a/Shoko.Server/API/v3/Models/Shoko/Renamer.cs +++ /dev/null @@ -1,367 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using Newtonsoft.Json; -using Shoko.Models.Server; -using Shoko.Server.API.v3.Models.Common; -using Shoko.Server.Repositories; -using Shoko.Server.Utilities; - -using RenameFileHelper = Shoko.Server.Renamer.RenameFileHelper; - -#nullable enable -namespace Shoko.Server.API.v3.Models.Shoko; - -public class Renamer : BaseModel -{ - /// <summary> - /// The assembly version of the renamer. - /// </summary> - public string Version { get; set; } - /// <summary> - /// A short description about the renamer. - /// </summary> - public string Description { get; set; } - - /// <summary> - /// If the renamer is enabled. - /// </summary> - public bool Enabled { get; set; } - - /// <summary> - /// Lower numbers mean higher priority. Will be null if a priority is not set yet. - /// </summary> - public int? Priority { get; set; } - - public Renamer(string name, (Type type, string description, string version) value) - { - var settings = Utils.SettingsProvider.GetSettings(); - var scripts = RepoFactory.RenameScript.GetByRenamerType(name); - Name = name; - Size = scripts.Count; - Version = value.version; - Description = value.description; - Enabled = !settings.Plugins.EnabledRenamers.TryGetValue(name, out var enabled) || enabled; - Priority = settings.Plugins.RenamerPriorities.TryGetValue(Name, out var priority) ? priority : null; - } - - public class Script - { - - /// <summary> - /// Script ID. - /// </summary> - public int ID { get; set; } - - /// <summary> - /// Script name. Must be unique across all scripts. - /// </summary> - public string Name { get; set; } - - /// <summary> - /// The last known name of the <see cref="Models.Shoko.Renamer"/> this script belongs to. - /// </summary> - public string RenamerName { get; set; } - - /// <summary> - /// True if the renamer is locally available. - /// </summary> - public bool RenamerIsAvailable { get; set; } - - /// <summary> - /// Determines if the script should ran automatically on import if the renamer is enabled. - /// </summary> - public bool EnabledOnImport { get; set; } - - /// <summary> - /// The script body. - /// </summary> - public string? Body { get; set; } - - public Script(RenameScript script) - { - ID = script.RenameScriptID; - Name = script.ScriptName; - RenamerName = script.RenamerType; - RenamerIsAvailable = RenameFileHelper.Renamers.ContainsKey(script.RenamerType); - EnabledOnImport = script.IsEnabledOnImport == 1; - Body = !string.IsNullOrWhiteSpace(script.Script) ? script.Script : null; - } - } - - /// <summary> - /// Represents the result of a file relocation process. - /// </summary> - public class RelocateResult - { - /// <summary> - /// The file id. - /// </summary> - [Required] - public int FileID { get; set; } - - /// <summary> - /// The file location id. May be null if a location to use was not - /// found. - /// </summary> - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public int? FileLocationID { get; set; } = null; - - /// <summary> - /// The id of the script that produced the final location for the - /// file if the relocation was successful and was not the result of - /// a manual relocation. - /// </summary> - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public int? ScriptID { get; set; } = null; - - /// <summary> - /// The new id of the <see cref="ImportFolder"/> where the file now - /// resides, if the relocation was successful. Remember to check - /// <see cref="IsSuccess"/> to see the status of the relocation. - /// </summary> - /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public int? ImportFolderID { get; set; } = null; - - /// <summary> - /// Indicates whether the file was relocated successfully. - /// </summary> - [Required] - public bool IsSuccess { get; set; } = false; - - /// <summary> - /// Indicates whether the file was actually relocated from one - /// location to another, or if it was already at it's correct - /// location. - /// </summary> - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public bool? IsRelocated { get; set; } = null; - - /// <summary> - /// Indicates if the result is only a preview and the file has not - /// actually been relocated yet. - /// </summary> - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public bool? IsPreview { get; set; } = null; - - /// <summary> - /// The error message if the relocation was not successful. - /// </summary> - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string? ErrorMessage { get; set; } = null; - - /// <summary> - /// The new relative path from the <see cref="ImportFolder"/>'s path - /// on the server, if relocation was successful. Remember to check - /// <see cref="IsSuccess"/> to see the status of the relocation. - /// </summary> - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string? RelativePath { get; set; } = null; - - /// <summary> - /// The new absolute path for the file on the server, if relocation - /// was successful. Remember to check <see cref="IsSuccess"/> to see - /// the status of the relocation. - /// </summary> - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string? AbsolutePath { get; set; } = null; - } - - public static class Input - { - public class ModifyRenamerBody - { - /// <summary> - /// If the renamer is enabled. - /// </summary> - public bool? Enabled { get; set; } = null; - - /// <summary> - /// Lower numbers mean higher priority. Will be null if a priority is not set yet. - /// </summary> - public int? Priority { get; set; } = null; - - public ModifyRenamerBody() { } - - public ModifyRenamerBody(string name) - { - var settings = Utils.SettingsProvider.GetSettings(); - Enabled = !settings.Plugins.EnabledRenamers.TryGetValue(name, out var enabled) || enabled; - Priority = settings.Plugins.RenamerPriorities.TryGetValue(name, out var priority) ? priority : null; - } - - public Renamer MergeWithExisting(string name, (Type type, string description, string value) value) - { - // Get the settings object. - var settings = Utils.SettingsProvider.GetSettings(); - - // Set the enabled status. - if (Enabled.HasValue) - { - if (!settings.Plugins.EnabledRenamers.TryAdd(name, Enabled.Value)) - settings.Plugins.EnabledRenamers[name] = Enabled.Value; - } - - // Set the priority. - if (Priority.HasValue) - { - if (!settings.Plugins.RenamerPriorities.TryAdd(name, Priority.Value)) - settings.Plugins.RenamerPriorities[name] = Priority.Value; - } - - // Save the settings. - Utils.SettingsProvider.SaveSettings(); - - return new Renamer(name, value); - } - } - - public class ModifyScriptBody - { - - /// <summary> - /// Script name. Must be unique across all scripts. - /// </summary> - [Required] - public string Name { get; set; } = ""; - - /// <summary> - /// The name of the <see cref="Models.Shoko.Renamer"/> this script - /// belongs to. - /// </summary> - [Required] - public string RenamerName { get; set; } = ""; - - /// <summary> - /// Determines if the script should ran automatically on import if the renamer is enabled. - /// </summary> - [Required] - public bool EnabledOnImport { get; set; } = false; - - /// <summary> - /// The script body. - /// </summary> - public string? Body { get; set; } = null; - - public ModifyScriptBody() { } - - public ModifyScriptBody(RenameScript script) - { - Name = script.ScriptName; - RenamerName = script.RenamerType; - EnabledOnImport = script.IsEnabledOnImport == 1; - Body = !string.IsNullOrWhiteSpace(script.Script) ? script.Script : null; - } - - public Script MergeWithExisting(RenameScript script) - { - script.ScriptName = Name; - script.RenamerType = RenamerName; - script.IsEnabledOnImport = EnabledOnImport ? 1 : 0; - script.Script = Body ?? ""; - - // Check to make sure we multiple scripts enable on import, since - // only one can be selected. - if (EnabledOnImport) - { - var allScripts = RepoFactory.RenameScript.GetAll(); - foreach (var s in allScripts) - { - if (s.IsEnabledOnImport == 1 && - (script.RenameScriptID == 0 || (script.RenameScriptID != s.RenameScriptID))) - { - s.IsEnabledOnImport = 0; - RepoFactory.RenameScript.Save(s); - } - } - } - - RepoFactory.RenameScript.Save(script); - return new Script(script); - } - } - - public class NewScriptBody - { - /// <summary> - /// Script name. Must be unique across all scripts. - /// </summary> - [Required] - public string Name { get; set; } = ""; - - /// <summary> - /// The name of the <see cref="Models.Shoko.Renamer"/> this script - /// belongs to. - /// </summary> - [Required] - public string RenamerName { get; set; } = ""; - - /// <summary> - /// Determines if the script should be automatically ran on import - /// if the renamer is enabled. - /// </summary> - [Required] - public bool EnabledOnImport { get; set; } = false; - - /// <summary> - /// The script body. - /// </summary> - public string? Body { get; set; } = null; - } - - public class BatchAutoRelocateBody - { - /// <summary> - /// Indicates whether the result should be a preview of the - /// relocation. - /// </summary> - public bool Preview { get; set; } = true; - - /// <summary> - /// Move the files. Leave as `null` to use the default - /// setting for move on import. - /// </summary> - public bool Move { get; set; } = false; - - /// <summary> - /// Indicates whether empty directories should be deleted after - /// relocating the file. - /// </summary> - public bool DeleteEmptyDirectories { get; set; } = true; - - /// <summary> - /// List of Shoko file IDs to relocate to the new location. - /// </summary> - [Required] - public List<int> FileIDs { get; set; } = []; - } - - public class BatchPreviewAutoRelocateWithRenamerBody - { - /// <summary> - /// The name of the renamer to use. - /// </summary> - [Required] - public string RenamerName { get; set; } = string.Empty; - - /// <summary> - /// The script body, if any is needed for the renamer. Can be - /// omitted if the renamer doesn't require a script. - /// </summary> - public string? ScriptBody { get; set; } = null; - - /// <summary> - /// Move the files. Leave as `null` to use the default - /// setting for move on import. - /// </summary> - public bool Move { get; set; } = false; - - /// <summary> - /// List of Shoko file IDs to preview the new location for. - /// </summary> - [Required] - public List<int> FileIDs { get; set; } = []; - } - } -} diff --git a/Shoko.Server/API/v3/Models/Shoko/Series.cs b/Shoko.Server/API/v3/Models/Shoko/Series.cs index d7625bf81..6c579a367 100644 --- a/Shoko.Server/API/v3/Models/Shoko/Series.cs +++ b/Shoko.Server/API/v3/Models/Shoko/Series.cs @@ -1,18 +1,34 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using Shoko.Commons.Extensions; +using Shoko.Models.Enums; +using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.API.Converters; +using Shoko.Server.API.v3.Helpers; using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Extensions; using Shoko.Server.Models; +using Shoko.Server.Providers.AniDB.Titles; using Shoko.Server.Repositories; +using Shoko.Server.Utilities; -using AniDBEpisodeType = Shoko.Models.Enums.EpisodeType; using AniDBAnimeType = Shoko.Models.Enums.AnimeType; -using RelationType = Shoko.Plugin.Abstractions.DataModels.RelationType; +using AniDBEpisodeType = Shoko.Models.Enums.EpisodeType; using DataSource = Shoko.Server.API.v3.Models.Common.DataSource; +using InternalEpisodeType = Shoko.Models.Enums.EpisodeType; +using RelationType = Shoko.Plugin.Abstractions.DataModels.RelationType; +using TmdbMovie = Shoko.Server.API.v3.Models.TMDB.Movie; +using TmdbShow = Shoko.Server.API.v3.Models.TMDB.Show; +#nullable enable namespace Shoko.Server.API.v3.Models.Shoko; /// <summary> @@ -20,6 +36,11 @@ namespace Shoko.Server.API.v3.Models.Shoko; /// </summary> public class Series : BaseModel { + private static AniDBTitleHelper? _titleHelper = null; + + private static AniDBTitleHelper TitleHelper + => _titleHelper ??= Utils.ServiceContainer.GetService<AniDBTitleHelper>()!; + /// <summary> /// The relevant IDs for the series, Shoko Internal, AniDB, etc /// </summary> @@ -30,6 +51,11 @@ public class Series : BaseModel /// </summary> public bool HasCustomName { get; set; } + /// <summary> + /// Preferred description for series. + /// </summary> + public string Description { get; set; } + /// <summary> /// The default or random pictures for a series. This allows the client to not need to get all images and pick one. /// There should always be a poster, but no promises on the rest. @@ -39,7 +65,7 @@ public class Series : BaseModel /// <summary> /// the user's rating /// </summary> - public Rating UserRating { get; set; } + public Rating? UserRating { get; set; } /// <summary> /// The inferred days of the week this series airs on. @@ -74,19 +100,198 @@ public class Series : BaseModel [JsonConverter(typeof(IsoDateTimeConverter))] public DateTime Updated { get; set; } +#pragma warning disable IDE1006 /// <summary> /// The <see cref="Series.AniDB"/>, if <see cref="DataSource.AniDB"/> is /// included in the data to add. /// </summary> [JsonProperty("AniDB", NullValueHandling = NullValueHandling.Ignore)] - public AniDB _AniDB { get; set; } + public AniDB? _AniDB { get; set; } +#pragma warning restore IDE1006 /// <summary> - /// The <see cref="Series.TvDB"/> entries, if <see cref="DataSource.TvDB"/> + /// The <see cref="TmdbData"/> entries, if <see cref="DataSource.TMDB"/> /// is included in the data to add. /// </summary> - [JsonProperty("TvDB", NullValueHandling = NullValueHandling.Ignore)] - public IEnumerable<TvDB> _TvDB { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public TmdbData? TMDB { get; set; } + + public Series(SVR_AnimeSeries ser, int userId = 0, bool randomizeImages = false, HashSet<DataSource>? includeDataFrom = null) + { + var anime = ser.AniDB_Anime ?? + throw new NullReferenceException($"Unable to get AniDB Anime {ser.AniDB_ID} for AnimeSeries {ser.AnimeSeriesID}"); + var animeType = (AniDBAnimeType)anime.AnimeType; + var allEpisodes = ser.AllAnimeEpisodes; + var vote = RepoFactory.AniDB_Vote.GetByEntityAndType(anime.AnimeID, AniDBVoteType.Anime) ?? + RepoFactory.AniDB_Vote.GetByEntityAndType(anime.AnimeID, AniDBVoteType.AnimeTemp); + var tmdbMovieXRefs = ser.TmdbMovieCrossReferences; + var tmdbShowXRefs = ser.TmdbShowCrossReferences; + var sizes = ModelHelper.GenerateSeriesSizes(allEpisodes, userId); + IDs = new() + { + ID = ser.AnimeSeriesID, + ParentGroup = ser.AnimeGroupID, + TopLevelGroup = ser.TopLevelAnimeGroup?.AnimeGroupID ?? 0, + AniDB = ser.AniDB_ID, + TvDB = tmdbShowXRefs.Select(xref => xref.TmdbShow?.TvdbShowID).WhereNotNull().Distinct().ToList(), + IMDB = tmdbMovieXRefs + .Select(xref => xref.TmdbMovie?.ImdbMovieID) + .WhereNotNull() + .Distinct() + .ToList(), + TMDB = new() + { + Movie = tmdbMovieXRefs.Select(a => a.TmdbMovieID).Distinct().ToList(), + Show = tmdbShowXRefs.Select(a => a.TmdbShowID).Distinct().ToList(), + }, + TraktTv = ser.TraktShowCrossReferences.Select(a => a.TraktID).Distinct().ToList(), + MAL = ser.MALCrossReferences.Select(a => a.MALID).Distinct().ToList() + }; + Links = anime.Resources + .Select(tuple => new Resource(tuple)) + .ToList(); + Name = ser.PreferredTitle; + HasCustomName = !string.IsNullOrEmpty(ser.SeriesNameOverride); + Description = ser.PreferredOverview; + Images = ser.GetImages().ToDto(preferredImages: true, randomizeImages: randomizeImages); + AirsOn = animeType == AniDBAnimeType.TVSeries || animeType == AniDBAnimeType.Web ? GetAirsOnDaysOfWeek(allEpisodes) : []; + Sizes = sizes; + Created = ser.DateTimeCreated.ToUniversalTime(); + Updated = ser.DateTimeUpdated.ToUniversalTime(); + Size = sizes.Local.Credits + sizes.Local.Episodes + sizes.Local.Others + sizes.Local.Parodies + sizes.Local.Specials + sizes.Local.Trailers; + if (vote is not null) + UserRating = new() + { + Value = (decimal)Math.Round(vote.VoteValue / 100D, 1), + MaxValue = 10, + Type = (AniDBVoteType)vote.VoteType == AniDBVoteType.Anime ? "Permanent" : "Temporary", + Source = "User" + }; + if (includeDataFrom?.Contains(DataSource.AniDB) ?? false) + _AniDB = new(anime, ser); + if (includeDataFrom?.Contains(DataSource.TMDB) ?? false) + TMDB = new() + { + Movies = ser.TmdbMovies.Select(movie => new TmdbMovie(movie)), + Shows = ser.TmdbShows.Select(show => new TmdbShow(show, show.PreferredAlternateOrdering)), + }; + } + + /// <summary> + /// Get the most recent days in the week the show airs on. + /// </summary> + /// <param name="animeEpisodes">Optionally pass in the episodes so we don't have to fetch them.</param> + /// <param name="includeThreshold">Threshold of episodes to include in the calculation.</param> + /// <returns></returns> + private static List<DayOfWeek> GetAirsOnDaysOfWeek(IEnumerable<SVR_AnimeEpisode> animeEpisodes, int includeThreshold = 24) + { + var now = DateTime.Now; + var filteredEpisodes = animeEpisodes + .Select(episode => + { + var aniDB = episode.AniDB_Episode; + var airDate = aniDB.GetAirDateAsDate(); + return (episode, aniDB, airDate); + }) + .Where(tuple => + { + // Shouldn't happen, but the compiler want us to check so we check. + if (tuple.aniDB is null) + return false; + + // We ignore all other types except the "normal" type. + if ((AniDBEpisodeType)tuple.aniDB.EpisodeType != AniDBEpisodeType.Episode) + return false; + + // We ignore any unknown air dates and dates in the future. + if (!tuple.airDate.HasValue || tuple.airDate.Value > now) + return false; + + return true; + }) + .ToList(); + + // Threshold used to filter out outliers, e.g. a weekday that only happens + // once or twice for whatever reason, or when a show gets an early preview, + // an episode moving, etc... + var outlierThreshold = Math.Min((int)Math.Ceiling(filteredEpisodes.Count / 12D), 4); + return filteredEpisodes + .OrderByDescending(tuple => tuple.aniDB!.EpisodeNumber) + // We check up to the `x` last aired episodes to get a grasp on which days + // it airs on. This helps reduce variance in days for long-running + // shows, such as One Piece, etc... + .Take(includeThreshold) + .Select(tuple => tuple.airDate!.Value.DayOfWeek) + .GroupBy(weekday => weekday) + .Where(list => list.Count() > outlierThreshold) + .Select(list => list.Key) + .OrderBy(weekday => weekday) + .ToList(); + } + + /// <summary> + /// Cast is aggregated, and therefore not in each provider + /// </summary> + /// <param name="animeID"></param> + /// <param name="roleTypes"></param> + /// <returns></returns> + public static List<Role> GetCast(int animeID, HashSet<Role.CreatorRoleType>? roleTypes = null) + { + var roles = new List<Role>(); + var xrefAnimeStaff = RepoFactory.CrossRef_Anime_Staff.GetByAnimeID(animeID); + foreach (var xref in xrefAnimeStaff) + { + // Filter out any roles that are not of the desired type. + if (roleTypes != null && !roleTypes.Contains((Role.CreatorRoleType)xref.RoleType)) + continue; + + var character = xref.RoleID.HasValue ? RepoFactory.AnimeCharacter.GetByID(xref.RoleID.Value) : null; + var staff = RepoFactory.AnimeStaff.GetByID(xref.StaffID); + if (staff == null) + continue; + + var role = new Role(xref, staff, character); + roles.Add(role); + } + + return roles; + } + + public static List<Tag> GetTags(SVR_AniDB_Anime anime, TagFilter.Filter filter, bool excludeDescriptions = false, bool orderByName = false, bool onlyVerified = true) + { + // Only get the user tags if we don't exclude it (false == false), or if we invert the logic and want to include it (true == true). + IEnumerable<Tag> userTags = new List<Tag>(); + if (filter.HasFlag(TagFilter.Filter.User) == filter.HasFlag(TagFilter.Filter.Invert)) + { + userTags = RepoFactory.CustomTag.GetByAnimeID(anime.AnimeID) + .Select(tag => new Tag(tag, excludeDescriptions)); + } + + var selectedTags = anime.GetAniDBTags(onlyVerified) + .DistinctBy(a => a.TagName) + .ToList(); + var tagFilter = new TagFilter<AniDB_Tag>(name => RepoFactory.AniDB_Tag.GetByName(name).FirstOrDefault(), tag => tag.TagName, + name => new AniDB_Tag { TagNameSource = name }); + var anidbTags = tagFilter + .ProcessTags(filter, selectedTags) + .Select(tag => + { + var xref = RepoFactory.AniDB_Anime_Tag.GetByTagID(tag.TagID).FirstOrDefault(xref => xref.AnimeID == anime.AnimeID); + return new Tag(tag, excludeDescriptions) { Weight = xref?.Weight ?? 0, IsLocalSpoiler = xref?.LocalSpoiler }; + }); + + if (orderByName) + return userTags.Concat(anidbTags) + .OrderByDescending(tag => tag.Source) + .ThenBy(tag => tag.Name) + .ToList(); + + return userTags.Concat(anidbTags) + .OrderByDescending(tag => tag.Source) + .ThenByDescending(tag => tag.Weight) + .ThenBy(tag => tag.Name) + .ToList(); + } /// <summary> /// Auto-matching settings for the series. @@ -95,7 +300,6 @@ public class AutoMatchSettings { public AutoMatchSettings() { - TvDB = false; TMDB = false; Trakt = false; // MAL = false; @@ -106,7 +310,6 @@ public AutoMatchSettings() public AutoMatchSettings(SVR_AnimeSeries series) { - TvDB = !series.IsTvDBAutoMatchingDisabled; TMDB = !series.IsTMDBAutoMatchingDisabled; Trakt = !series.IsTraktAutoMatchingDisabled; // MAL = !series.IsMALAutoMatchingDisabled; @@ -117,7 +320,6 @@ public AutoMatchSettings(SVR_AnimeSeries series) public AutoMatchSettings MergeWithExisting(SVR_AnimeSeries series) { - series.IsTvDBAutoMatchingDisabled = !TvDB; series.IsTMDBAutoMatchingDisabled = !TMDB; series.IsTraktAutoMatchingDisabled = !Trakt; // series.IsMALAutoMatchingDisabled = !MAL; @@ -130,12 +332,6 @@ public AutoMatchSettings MergeWithExisting(SVR_AnimeSeries series) return new AutoMatchSettings(series); } - /// <summary> - /// Auto-match against TvDB. - /// </summary> - [Required] - public bool TvDB { get; set; } - /// <summary> /// Auto-match against The Movie Database (TMDB). /// </summary> @@ -181,7 +377,6 @@ public class AniDB /// <summary> /// AniDB ID /// </summary> - [Required] public int ID { get; set; } /// <summary> @@ -193,38 +388,34 @@ public class AniDB /// <summary> /// Series type. Series, OVA, Movie, etc /// </summary> - [Required] [JsonConverter(typeof(StringEnumConverter))] public SeriesType Type { get; set; } /// <summary> /// Main Title, usually matches x-jat /// </summary> - [Required] public string Title { get; set; } /// <summary> /// There should always be at least one of these, the <see cref="Title"/>. /// </summary> - [Required] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public List<Title> Titles { get; set; } + public List<Title>? Titles { get; set; } /// <summary> /// Description. /// </summary> [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string Description { get; set; } + public string? Description { get; set; } /// <summary> - /// Air date (2013-02-27, shut up avael). Anything without an air date is going to be missing a lot of info. + /// Indicates when the AniDB anime first started airing, if it's known. In the 'yyyy-MM-dd' format, or null. /// </summary> - [Required] [JsonConverter(typeof(DateFormatConverter), "yyyy-MM-dd")] public DateTime? AirDate { get; set; } /// <summary> - /// End date, can be omitted. Omitted means that it's still airing (2013-02-27) + /// Indicates when the AniDB anime stopped airing. It will be null if it's still airing or haven't aired yet. In the 'yyyy-MM-dd' format, or null. /// </summary> [JsonConverter(typeof(DateFormatConverter), "yyyy-MM-dd")] public DateTime? EndDate { get; set; } @@ -235,10 +426,9 @@ public class AniDB public bool Restricted { get; set; } /// <summary> - /// The main or default poster. + /// The preferred poster for the anime. /// </summary> - [Required] - public Image Poster { get; set; } + public Image? Poster { get; set; } /// <summary> /// Number of <see cref="EpisodeType.Normal"/> episodes contained within the series if it's known. @@ -249,13 +439,13 @@ public class AniDB /// The average rating for the anime. /// </summary> [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public Rating Rating { get; set; } + public Rating? Rating { get; set; } /// <summary> /// User approval rate for the similar submission. Only available for similar. /// </summary> [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public Rating UserApproval { get; set; } + public Rating? UserApproval { get; set; } /// <summary> /// Relation type. Only available for relations. @@ -263,6 +453,93 @@ public class AniDB [JsonConverter(typeof(StringEnumConverter))] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public RelationType? Relation { get; set; } + + + private AniDB(int animeId, bool includeTitles, SVR_AnimeSeries? series = null, SVR_AniDB_Anime? anime = null, ResponseAniDBTitles.Anime? result = null) + { + ID = animeId; + if ((anime ??= (series is not null ? series.AniDB_Anime : RepoFactory.AniDB_Anime.GetByAnimeID(animeId))) is not null) + { + ArgumentNullException.ThrowIfNull(anime); + series ??= RepoFactory.AnimeSeries.GetByAnimeID(animeId); + var seriesTitle = series?.PreferredTitle ?? anime.PreferredTitle; + ShokoID = series?.AnimeSeriesID; + Type = anime.AnimeType.ToAniDBSeriesType(); + Title = seriesTitle; + Titles = includeTitles + ? anime.Titles.Select(title => new Title(title, anime.MainTitle, seriesTitle)).ToList() + : null; + Description = anime.Description; + Restricted = anime.IsRestricted; + Poster = new Image(anime.PreferredOrDefaultPoster); + EpisodeCount = anime.EpisodeCountNormal; + Rating = new Rating + { + Source = "AniDB", + Value = anime.Rating, + MaxValue = 1000, + Votes = anime.VoteCount, + }; + UserApproval = null; + Relation = null; + AirDate = anime.AirDate; + EndDate = anime.EndDate; + } + else if ((result ??= TitleHelper.SearchAnimeID(animeId)) is not null) + { + Type = SeriesType.Unknown; + Title = result.PreferredTitle; + Titles = includeTitles + ? result.Titles.Select( + title => new Title(title, result.MainTitle, Title) + { + Language = title.LanguageCode, + Name = title.Title, + Type = title.TitleType, + Default = string.Equals(title.Title, Title), + Source = "AniDB" + } + ).ToList() + : null; + Description = null; + Poster = new Image(animeId, ImageEntityType.Poster, DataSourceType.AniDB); + } + else + { + Type = SeriesType.Unknown; + Title = string.Empty; + Titles = includeTitles ? [] : null; + Poster = new Image(animeId, ImageEntityType.Poster, DataSourceType.AniDB); + } + } + + public AniDB(SVR_AniDB_Anime anime, SVR_AnimeSeries? series = null, bool includeTitles = true) + : this(anime.AnimeID, includeTitles, series, anime) { } + + public AniDB(ResponseAniDBTitles.Anime result, SVR_AnimeSeries? series = null, bool includeTitles = true) + : this(result.AnimeID, includeTitles, series) { } + + public AniDB(SVR_AniDB_Anime_Relation relation, SVR_AnimeSeries? series = null, bool includeTitles = true) + : this(relation.RelatedAnimeID, includeTitles, series) + { + Relation = ((IRelatedMetadata)relation).RelationType; + // If the other anime is present we assume they're of the same kind. Be it restricted or unrestricted. + if (Type == SeriesType.Unknown && TitleHelper.SearchAnimeID(relation.RelatedAnimeID) is not null) + Restricted = RepoFactory.AniDB_Anime.GetByAnimeID(relation.AnimeID) is { IsRestricted: true }; + } + + public AniDB(AniDB_Anime_Similar similar, SVR_AnimeSeries? series = null, bool includeTitles = true) + : this(similar.SimilarAnimeID, includeTitles, series) + { + UserApproval = new() + { + Value = new Vote(similar.Approval, similar.Total).GetRating(100), + MaxValue = 100, + Votes = similar.Total, + Source = "AniDB", + Type = "User Approval" + }; + } } /// <summary> @@ -273,97 +550,86 @@ public class AniDBRecommendedForYou /// <summary> /// The recommended AniDB entry. /// </summary> - public AniDB Anime; + public AniDB Anime { get; init; } /// <summary> /// Number of similar anime that resulted in this recommendation. /// </summary> - public int SimilarTo; + public int SimilarTo { get; init; } + + public AniDBRecommendedForYou(AniDB anime, int similarCount) + { + Anime = anime; + SimilarTo = similarCount; + } } - /// <summary> - /// The TvDB Data model for series - /// </summary> - public class TvDB + public class SeriesIDs : IDs { - /// <summary> - /// TvDB ID - /// </summary> - [Required] - public int ID { get; set; } + #region Groups /// <summary> - /// Air date (2013-02-27, shut up avael) + /// The ID of the direct parent group, if it has one. /// </summary> - [JsonConverter(typeof(DateFormatConverter), "yyyy-MM-dd")] - public DateTime? AirDate { get; set; } + public int ParentGroup { get; set; } /// <summary> - /// End date, can be null. Null means that it's still airing (2013-02-27) + /// The ID of the top-level (ancestor) group this series belongs to. /// </summary> - [JsonConverter(typeof(DateFormatConverter), "yyyy-MM-dd")] - public DateTime? EndDate { get; set; } + public int TopLevelGroup { get; set; } + + #endregion + + #region XRefs + + // These are useful for many things, but for clients, it is mostly auxiliary /// <summary> - /// TvDB only supports one title + /// The AniDB ID /// </summary> [Required] - public string Title { get; set; } + public int AniDB { get; set; } /// <summary> - /// Description + /// The TvDB IDs /// </summary> - public string Description { get; set; } + public List<int> TvDB { get; set; } = []; /// <summary> - /// TvDB Season. This value is not guaranteed to be even kind of accurate - /// TvDB matchings and links affect this. Null means no match. 0 means specials + /// The IMDB Movie IDs. /// </summary> - public int? Season { get; set; } + public List<string> IMDB { get; set; } = []; /// <summary> - /// Posters + /// The Movie Database (TMDB) IDs. /// </summary> - public List<Image> Posters { get; set; } + public TmdbSeriesIDs TMDB { get; set; } = new(); /// <summary> - /// Fanarts + /// The MyAnimeList IDs /// </summary> - public List<Image> Fanarts { get; set; } + public List<int> MAL { get; set; } = []; /// <summary> - /// Banners + /// The TraktTv IDs /// </summary> - public List<Image> Banners { get; set; } + public List<string> TraktTv { get; set; } = []; - /// <summary> - /// The rating object - /// </summary> - public Rating Rating { get; set; } + #endregion + + public class TmdbSeriesIDs + { + public List<int> Movie { get; init; } = []; + + public List<int> Show { get; init; } = []; + } } - /// <summary> - /// A site link, as in hyperlink. - /// </summary> - public class Resource + public class TmdbData { - /// <summary> - /// Resource type. - /// </summary> - [Required] - public string Type { get; set; } - - /// <summary> - /// site name - /// </summary> - [Required] - public string Name { get; set; } + public IEnumerable<TmdbMovie> Movies { get; init; } = []; - /// <summary> - /// the url to the series page - /// </summary> - [Required] - public string URL { get; set; } + public IEnumerable<TmdbShow> Shows { get; init; } = []; } #region Inputs @@ -375,10 +641,31 @@ public class LinkCommonBody /// <summary> /// Provider ID to add. /// </summary> - [Required] - public int ID; + [Required, Range(1, int.MaxValue)] + public int ID { get; set; } + + /// <summary> + /// Replace all existing links. + /// </summary> + public bool Replace { get; set; } = false; - public bool Replace = false; + /// <summary> + /// Forcefully refresh metadata even if we recently did a refresh. + /// </summary> + public bool Refresh { get; set; } = false; + } + + public class LinkShowBody : LinkCommonBody + { + } + + public class LinkMovieBody : LinkCommonBody + { + /// <summary> + /// Also link to the given AniDB episode by ID. + /// </summary> + [Required, Range(1, int.MaxValue)] + public int EpisodeID { get; set; } } public class UnlinkCommonBody @@ -386,115 +673,179 @@ public class UnlinkCommonBody /// <summary> /// Provider ID to remove. /// </summary> - [Required] - public int ID; + [DefaultValue(0)] + [Range(0, int.MaxValue)] + public int ID { get; set; } + + /// <summary> + /// Purge the provider metadata from the database. + /// </summary> + public bool Purge { get; set; } = false; } - } - #endregion -} + public class UnlinkMovieBody : UnlinkCommonBody + { + /// <summary> + /// Only unlink to the given AniDB episode by ID. + /// </summary> + [DefaultValue(0)] + [Range(0, int.MaxValue)] + public int EpisodeID { get; set; } + } -public class SeriesIDs : IDs -{ - #region Groups + /// <summary> + /// Body for auto-matching AniDB episodes to TMDB episodes. + /// </summary> + public class AutoMatchTmdbEpisodesBody + { + /// <summary> + /// The specified TMDB Show ID to search for links. This parameter is used to select a specific show. + /// </summary> + [Range(1, int.MaxValue)] + public int? TmdbShowID { get; set; } - /// <summary> - /// The ID of the direct parent group, if it has one. - /// </summary> - public int ParentGroup { get; set; } + /// <summary> + /// The specified TMDB Season ID to search for links. If not provided, links are searched for any season of the selected or first linked show. + /// </summary> + [Range(1, int.MaxValue)] + public int? TmdbSeasonID { get; set; } - /// <summary> - /// The ID of the top-level (ancestor) group this series belongs to. - /// </summary> - public int TopLevelGroup { get; set; } + /// <summary> + /// Determines whether to retain any and all existing links. + /// </summary> + [DefaultValue(true)] + public bool KeepExisting { get; set; } = true; + } - #endregion + public class OverrideTmdbEpisodeMappingBody + { + /// <summary> + /// Unset all existing links before applying the overrides. + /// </summary> + /// <remarks> + /// This will ensure the auto-links won't override the new unset + /// links, unlink if you had reset them through the DELETE endpoint. + /// </remarks> + public bool UnsetAll { get; set; } = false; - #region XRefs + /// <summary> + /// Replacing existing links or add new additional links. + /// </summary> + [Required] + public IReadOnlyList<OverrideTmdbEpisodeLinkBody> Mapping { get; set; } = []; + } - // These are useful for many things, but for clients, it is mostly auxiliary + public class OverrideTmdbEpisodeLinkBody + { + /// <summary> + /// AniDB Episode ID. + /// </summary> + [Required, Range(1, int.MaxValue)] + public int AniDBID { get; set; } - /// <summary> - /// The AniDB ID - /// </summary> - [Required] - public int AniDB { get; set; } + /// <summary> + /// TMDB Episode ID. Set to <c>0</c> to not link to any episode. + /// </summary> + [Required, Range(0, int.MaxValue)] + public int TmdbID { get; set; } - /// <summary> - /// The TvDB IDs - /// </summary> - public List<int> TvDB { get; set; } = new(); + /// <summary> + /// Replace existing episode links. + /// </summary> + public bool Replace { get; set; } = false; - // TODO Support for TvDB string IDs (like in the new URLs) one day maybe + /// <summary> + /// Episode index. Set to <c>null</c> to automatically calculate the + /// index. + /// </summary> + [Range(0, int.MaxValue)] + public int? Index { get; set; } = null; + } - /// <summary> - /// The Movie DB IDs - /// </summary> - public List<int> TMDB { get; set; } = new(); + public class TitleOverrideBody + { + /// <summary> + /// New title to be set as override for the series + /// </summary> + [Required(AllowEmptyStrings = true)] + public string Title { get; set; } = string.Empty; + } - /// <summary> - /// The MyAnimeList IDs - /// </summary> - public List<int> MAL { get; set; } = new(); + public class AddOrRemoveUserTagsBody + { + /// <summary> + /// User Tag IDs to add/remove from the series. + /// </summary> + [Required] + [MinLength(1)] + public int[] IDs { get; set; } = []; + } + } - /// <summary> - /// The TraktTv IDs - /// </summary> - public List<string> TraktTv { get; set; } = new(); + #endregion /// <summary> - /// The AniList IDs + /// An Extended Series Model with Values for Search Results /// </summary> - public List<int> AniList { get; set; } = new(); + public class SearchResult : Series + { + /// <summary> + /// Indicates whether the search result is an exact match to the query. + /// </summary> + public bool ExactMatch { get; set; } - #endregion -} + /// <summary> + /// Represents the position of the match within the sanitized string. + /// This property is only applicable when ExactMatch is set to true. + /// A lower value indicates a match that occurs earlier in the string. + /// </summary> + public int Index { get; set; } -/// <summary> -/// An Extended Series Model with Values for Search Results -/// </summary> -public class SeriesSearchResult : Series -{ - /// <summary> - /// Indicates whether the search result is an exact match to the query. - /// </summary> - public bool ExactMatch { get; set; } + /// <summary> + /// Represents the similarity measure between the sanitized query and the sanitized matched result. + /// This may be the sorensen-dice distance or the tag weight when comparing tags for a series. + /// A lower value indicates a more similar match. + /// </summary> + public double Distance { get; set; } - /// <summary> - /// Represents the position of the match within the sanitized string. - /// This property is only applicable when ExactMatch is set to true. - /// A lower value indicates a match that occurs earlier in the string. - /// </summary> - public int Index { get; set; } + /// <summary> + /// Represents the absolute difference in length between the sanitized query and the sanitized matched result. + /// A lower value indicates a match with a more similar length to the query. + /// </summary> + public int LengthDifference { get; set; } - /// <summary> - /// Represents the similarity measure between the sanitized query and the sanitized matched result. - /// This may be the sorensen-dice distance or the tag weight when comparing tags for a series. - /// A lower value indicates a more similar match. - /// </summary> - public double Distance { get; set; } + /// <summary> + /// Contains the original matched substring from the original string. + /// </summary> + public string Match { get; set; } = string.Empty; - /// <summary> - /// Represents the absolute difference in length between the sanitized query and the sanitized matched result. - /// A lower value indicates a match with a more similar length to the query. - /// </summary> - public int LengthDifference { get; set; } + public SearchResult(SeriesSearch.SearchResult<SVR_AnimeSeries> result, int userId = 0, bool randomizeImages = false, HashSet<DataSource>? includeDataFrom = null) + : base(result.Result, userId, randomizeImages, includeDataFrom) + { + ExactMatch = result.ExactMatch; + Index = result.Index; + Distance = result.Distance; + LengthDifference = result.LengthDifference; + Match = result.Match; + } + } /// <summary> - /// Contains the original matched substring from the original string. + /// An extended model for use with the soft duplicate endpoint. /// </summary> - public string Match { get; set; } = string.Empty; -} + public class WithMultipleReleasesResult : Series + { + /// <summary> + /// Number of episodes in the series which have multiple releases. + /// </summary> + public int EpisodeCount { get; set; } -/// <summary> -/// An extended model for use with the soft duplicate endpoint. -/// </summary> -public class SeriesWithMultipleReleasesResult : Series -{ - /// <summary> - /// Number of episodes in the series which have multiple releases. - /// </summary> - public int EpisodeCount { get; set; } + public WithMultipleReleasesResult(SVR_AnimeSeries ser, int userId = 0, HashSet<DataSource>? includeDataFrom = null, bool ignoreVariations = true) + : base(ser, userId, false, includeDataFrom) + { + EpisodeCount = RepoFactory.AnimeEpisode.GetWithMultipleReleases(ignoreVariations, ser.AniDB_ID).Count; + } + } } public enum SeriesType @@ -556,7 +907,7 @@ public SeriesSizes() : base() public int Hidden { get; set; } /// <summary> - /// Counts of each file source type available within the local colleciton + /// Counts of each file source type available within the local collection /// </summary> [Required] public FileSourceCounts FileSources { get; set; } @@ -589,6 +940,7 @@ public SeriesSizes() : base() public class ReducedEpisodeTypeCounts { public int Episodes { get; set; } + public int Specials { get; set; } } @@ -598,34 +950,40 @@ public class ReducedEpisodeTypeCounts public class EpisodeTypeCounts { public int Unknown { get; set; } + public int Episodes { get; set; } + public int Specials { get; set; } + public int Credits { get; set; } + public int Trailers { get; set; } + public int Parodies { get; set; } + public int Others { get; set; } } public class FileSourceCounts { - public int Unknown; - public int Other; - public int TV; - public int DVD; - public int BluRay; - public int Web; - public int VHS; - public int VCD; - public int LaserDisc; - public int Camera; - } -} + public int Unknown { get; set; } -public class SeriesTitleOverride -{ - /// <summary> - /// New title to be set as override for the series - /// </summary> - [Required(AllowEmptyStrings = true)] - public string Title { get; set; } + public int Other { get; set; } + + public int TV { get; set; } + + public int DVD { get; set; } + + public int BluRay { get; set; } + + public int Web { get; set; } + + public int VHS { get; set; } + + public int VCD { get; set; } + + public int LaserDisc { get; set; } + + public int Camera { get; set; } + } } diff --git a/Shoko.Server/API/v3/Models/Shoko/User.cs b/Shoko.Server/API/v3/Models/Shoko/User.cs index c3dfef4b6..85eebc634 100644 --- a/Shoko.Server/API/v3/Models/Shoko/User.cs +++ b/Shoko.Server/API/v3/Models/Shoko/User.cs @@ -50,6 +50,11 @@ public class User /// </summary> public string Avatar { get; set; } + /// <summary> + /// The user's Plex usernames. + /// </summary> + public string PlexUsernames { get; set; } + public User(SVR_JMMUser user) { ID = user.JMMUserID; @@ -77,7 +82,9 @@ public User(SVR_JMMUser user) .Select(tag => tag.TagID) .ToList(); - Avatar = user.HasAvatarImage ? ModelHelper.ToDataURL(user.AvatarImageBlob, user.AvatarImageMetadata.ContentType) : string.Empty; + Avatar = user.HasAvatarImage ? ModelHelper.ToDataURL(user.AvatarImageBlob, user.AvatarImageMetadata.ContentType) ?? string.Empty : string.Empty; + + PlexUsernames = user.PlexUsers ?? string.Empty; } public class Input @@ -139,6 +146,11 @@ public class CreateOrUpdateUserBody /// </summary> public string? Avatar { get; set; } = null; + /// <summary> + /// The new user's Plex usernames. + /// </summary> + public string? PlexUsernames { get; set; } + public CreateOrUpdateUserBody() { } private const long MaxFileSize = 8 * 1024 * 1024; // 8MiB in bytes @@ -230,6 +242,11 @@ public CreateOrUpdateUserBody() { } user.IsAniDBUser = CommunitySites.Contains(global::Shoko.Models.Enums.CommunitySites.AniDB) ? 1 : 0; } + if (PlexUsernames != null) + { + user.PlexUsers = string.IsNullOrWhiteSpace(PlexUsernames) ? null : PlexUsernames; + } + // Save the model now. RepoFactory.JMMUser.Save(user); diff --git a/Shoko.Server/API/v3/Models/Shoko/WebUI.cs b/Shoko.Server/API/v3/Models/Shoko/WebUI.cs index e8e9b34a3..327cc8491 100644 --- a/Shoko.Server/API/v3/Models/Shoko/WebUI.cs +++ b/Shoko.Server/API/v3/Models/Shoko/WebUI.cs @@ -41,11 +41,11 @@ public class WebUITheme(WebUIThemeProvider.ThemeDefinition definition, bool with /// <summary> /// The name of the author of the theme definition. /// </summary> - public string Author { get; init; } = definition.Author; + public string Author { get; init; } = definition.Author ?? "<unknown>"; /// <summary> /// Indicates this is only a preview of the theme metadata and the theme - /// might not actaully be installed yet. + /// might not actually be installed yet. /// </summary> public bool IsPreview { get; init; } = definition.IsPreview; @@ -60,14 +60,14 @@ public class WebUITheme(WebUIThemeProvider.ThemeDefinition definition, bool with public Version Version { get; init; } = definition.Version; /// <summary> - /// Author-defined tags assosiated with the theme. + /// Author-defined tags associated with the theme. /// </summary> public IReadOnlyList<string> Tags { get; init; } = definition.Tags; /// <summary> /// The URL for where the theme definition lives. Used for updates. /// </summary> - public string? URL { get; init; } = definition.URL; + public string? URL { get; init; } = definition.UpdateUrl; /// <summary> /// The CSS representation of the theme. @@ -127,7 +127,7 @@ public class WebUISeriesExtra /// The first season this show was aired in. /// </summary> /// <value></value> - public Filter? FirstAirSeason { get; set; } + public string? FirstAirSeason { get; set; } /// <summary> /// A pre-filtered list of studios for the show. @@ -167,7 +167,7 @@ public WebUISeriesFileSummary( var episodes = series.AllAnimeEpisodes .Select(shoko => { - var anidb = shoko.AniDB_Episode; + var anidb = shoko.AniDB_Episode!; var type = Episode.MapAniDBEpisodeType(anidb.GetEpisodeTypeEnum()); var airDate = anidb.GetAirDateAsDate(); return new @@ -196,7 +196,7 @@ public WebUISeriesFileSummary( .Distinct() .Where(groupID => groupID != 0) .Select(RepoFactory.AniDB_ReleaseGroup.GetByGroupID) - .Where(releaseGroup => releaseGroup != null) + .WhereNotNull() .ToDictionary(releaseGroup => releaseGroup.GroupID); // We only care about files with exist and have actual media info and with an actual physical location. (Which should hopefully exclude nothing.) var filesWithXrefAndLocation = crossRefs @@ -215,21 +215,21 @@ public WebUISeriesFileSummary( .Select(tuple => { var (file, xref, location) = tuple; - var media = new MediaInfo(file, file.MediaInfo); + var media = new MediaInfo(file, file.MediaInfo!); var episode = episodes[xref.EpisodeID]; var isAutoLinked = (CrossRefSource)xref.CrossRefSource == CrossRefSource.AniDB; var anidbFile = isAutoLinked && anidbFiles.TryGetValue(xref.Hash, out var aniFi) ? aniFi : null; var releaseGroup = anidbFile != null && anidbFile.GroupID != 0 && releaseGroups.TryGetValue(anidbFile.GroupID, out var reGr) ? reGr : null; var groupByDetails = new GroupByDetails(); - // Release group criterias + // Release group criteria if (groupByCriteria.Contains(FileSummaryGroupByCriteria.GroupName)) { groupByDetails.GroupName = isAutoLinked ? releaseGroup?.GroupName ?? "Unknown" : "None"; groupByDetails.GroupNameShort = isAutoLinked ? releaseGroup?.GroupNameShort ?? "Unk" : "-"; } - // File criterias + // File criteria if (groupByCriteria.Contains(FileSummaryGroupByCriteria.FileVersion)) groupByDetails.FileVersion = isAutoLinked ? anidbFile?.FileVersion ?? 1 : 1; if (groupByCriteria.Contains(FileSummaryGroupByCriteria.FileSource)) @@ -238,8 +238,10 @@ public WebUISeriesFileSummary( groupByDetails.FileLocation = System.IO.Path.GetDirectoryName(location.FullServerPath)!; if (groupByCriteria.Contains(FileSummaryGroupByCriteria.FileIsDeprecated)) groupByDetails.FileIsDeprecated = anidbFile?.IsDeprecated ?? false; + if (groupByCriteria.Contains(FileSummaryGroupByCriteria.ImportFolder)) + groupByDetails.ImportFolder = $"{location.ImportFolder?.ImportFolderName ?? "N/A"} (ID: {location.ImportFolderID})"; - // Video criterias + // Video criteria if (groupByCriteria.Contains(FileSummaryGroupByCriteria.VideoCodecs)) groupByDetails.VideoCodecs = string.Join(", ", media.Video .Select(stream => stream.Codec.Simplified) @@ -264,7 +266,7 @@ public WebUISeriesFileSummary( if (groupByCriteria.Contains(FileSummaryGroupByCriteria.VideoHasChapters)) groupByDetails.VideoHasChapters = media.Chapters.Count > 0; - // Audio criterias + // Audio criteria if (groupByCriteria.Contains(FileSummaryGroupByCriteria.AudioCodecs)) groupByDetails.AudioCodecs = string.Join(", ", media.Audio .Select(stream => stream.Codec.Simplified) @@ -280,7 +282,7 @@ public WebUISeriesFileSummary( if (groupByCriteria.Contains(FileSummaryGroupByCriteria.AudioStreamCount)) groupByDetails.AudioStreamCount = media.Audio.Count; - // Text criterias + // Text criteria if (groupByCriteria.Contains(FileSummaryGroupByCriteria.SubtitleCodecs)) groupByDetails.SubtitleCodecs = string.Join(", ", media.Subtitles .Select(stream => stream.Codec.Simplified) @@ -321,6 +323,8 @@ public WebUISeriesFileSummary( TotalFileSize = files.Sum(fileWrapper => fileWrapper.Episode.Size), ReleaseGroups = releaseGroups.Values .Select(group => group.GroupNameShort ?? group.GroupName) + .WhereNotNull() + .Distinct() .ToList(), SourcesByType = filesWithXrefAndLocation .Select(t => @@ -384,6 +388,7 @@ public WebUISeriesFileSummary( FileSource = details.FileSource, FileLocation = details.FileLocation, FileIsDeprecated = details.FileIsDeprecated, + ImportFolder = details.ImportFolder, VideoCodecs = details.VideoCodecs, VideoBitDepth = details.VideoBitDepth, VideoResolution = details.VideoResolution, @@ -439,6 +444,7 @@ public enum FileSummaryGroupByCriteria SubtitleStreamCount = 4096, VideoHasChapters = 8192, FileIsDeprecated = 16384, + ImportFolder = 32768, } /// <summary> @@ -487,6 +493,12 @@ public class EpisodeGroupSummary(Dictionary<EpisodeType, EpisodeRangeByType> ran [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public bool? FileIsDeprecated { get; set; } + /// <summary> + /// The import folder name of the files in this range. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string? ImportFolder { get; set; } + /// <summary> /// The video codecs used in the files of this range (e.g., h264, h265, etc.). /// </summary> @@ -613,7 +625,7 @@ public class EpisodeRangeByType public int LastEpisode { get; set; } /// <summary> - /// All episodes in the range, but as a seqence for programmatically comparrison. + /// All episodes in the range, but as a sequence for programmatically comparison. /// </summary> /// <value></value> [JsonIgnore] @@ -674,7 +686,7 @@ public class SortByCriteriaGroup(EpisodeType type, int firstEpisode, int lastEpi public int LastEpisode { get; set; } = lastEpisode; /// <summary> - /// All episodes in the range, but as a seqence for programmatically comparrison. + /// All episodes in the range, but as a sequence for programmatically comparison. /// </summary> public List<int> Sequence { get; set; } = sequence; @@ -745,6 +757,8 @@ private class GroupByDetails : IEquatable<GroupByDetails> public bool? FileIsDeprecated { get; set; } + public string? ImportFolder { get; set; } + public override bool Equals(object? obj) { return Equals(obj as GroupByDetails); @@ -762,6 +776,7 @@ public bool Equals(GroupByDetails? other) FileSource == other.FileSource && FileLocation == other.FileLocation && FileIsDeprecated == other.FileIsDeprecated && + ImportFolder == other.ImportFolder && VideoCodecs == other.VideoCodecs && VideoBitDepth == other.VideoBitDepth && @@ -790,6 +805,7 @@ public override int GetHashCode() hash = hash * 31 + FileSource.GetHashCode(); hash = hash * 31 + (FileLocation?.GetHashCode() ?? 0); hash = hash * 31 + (FileIsDeprecated?.GetHashCode() ?? 0); + hash = hash * 31 + (ImportFolder?.GetHashCode() ?? 0); hash = hash * 31 + (VideoCodecs?.GetHashCode() ?? 0); hash = hash * 31 + VideoBitDepth.GetHashCode(); @@ -852,7 +868,7 @@ public class FileSummaryOverview public long TotalFileSize { get; set; } /// <summary> - /// A summarised list of all the locally available release groups for a series. + /// A summarized list of all the locally available release groups for a series. /// </summary> public List<string> ReleaseGroups { get; set; } = []; @@ -928,11 +944,11 @@ private static string SequenceToRange(List<int> sequence) return string.Join(", ", ranges); } - private static int CompareSequences(List<int> sequenceA, List<int> seqenceB) + private static int CompareSequences(List<int> sequenceA, List<int> sequenceB) { for (var index = 0; index < sequenceA.Count; index++) { - var result = sequenceA[index].CompareTo(seqenceB[index]); + var result = sequenceA[index].CompareTo(sequenceB[index]); if (result != 0) return result; } diff --git a/Shoko.Server/API/v3/Models/TMDB/Episode.cs b/Shoko.Server/API/v3/Models/TMDB/Episode.cs new file mode 100644 index 000000000..ce3bdaffa --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Episode.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.API.v3.Helpers; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.API.v3.Models.Shoko; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; + +using MatchRatingEnum = Shoko.Models.Enums.MatchRating; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB; + +/// <summary> +/// APIv3 The Movie DataBase (TMDB) Episode Data Transfer Object (DTO). +/// </summary> +public class Episode +{ + /// <summary> + /// TMDB Episode ID. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// TMDB Season ID. + /// </summary> + public string SeasonID { get; init; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int ShowID { get; init; } + + /// <summary> + /// TVDB Episode ID, if available. + /// </summary> + public int? TvdbEpisodeID { get; init; } + + /// <summary> + /// Preferred title based upon episode title preference. + /// </summary> + public string Title { get; init; } + + /// <summary> + /// All available titles for the episode, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Title>? Titles { get; init; } + + /// <summary> + /// Preferred overview based upon episode title preference. + /// </summary> + public string Overview { get; init; } + + /// <summary> + /// All available overviews for the episode, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Overview>? Overviews { get; init; } + + /// <summary> + /// The episode number for the main ordering or alternate ordering in use. + /// </summary> + public int EpisodeNumber { get; init; } + + /// <summary> + /// The season number for the main ordering or alternate ordering in use. + /// </summary> + public int SeasonNumber { get; init; } + + /// <summary> + /// User rating of the episode from TMDB users. + /// </summary> + public Rating UserRating { get; init; } + + /// <summary> + /// The episode run-time, if it is known. + /// </summary> + public TimeSpan? Runtime { get; init; } + + /// <summary> + /// All images stored locally for this episode, if any. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Images? Images { get; init; } + + /// <summary> + /// The cast that have worked on this episode. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Cast { get; init; } + + /// <summary> + /// The crew that have worked on this episode. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Crew { get; init; } + + /// <summary> + /// All available ordering for the episode, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<OrderingInformation>? Ordering { get; init; } + + /// <summary> + /// Episode cross-references. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<CrossReference>? CrossReferences { get; init; } + + /// <summary> + /// TMDB episode to file cross-references. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<FileCrossReference>? FileCrossReferences { get; init; } + + /// <summary> + /// The date the episode first aired, if it is known. + /// </summary> + public DateOnly? AiredAt { get; init; } + + /// <summary> + /// When the local metadata was first created. + /// </summary> + public DateTime CreatedAt { get; init; } + + /// <summary> + /// When the local metadata was last updated with new changes from the + /// remote. + /// </summary> + public DateTime LastUpdatedAt { get; init; } + + public Episode(TMDB_Show show, TMDB_Episode episode, IncludeDetails? includeDetails = null, IReadOnlySet<TitleLanguage>? language = null) : + this(show, episode, null, includeDetails, language) + { } + + public Episode(TMDB_Show show, TMDB_Episode episode, TMDB_AlternateOrdering_Episode? alternateOrderingEpisode, IncludeDetails? includeDetails = null, IReadOnlySet<TitleLanguage>? language = null) + { + var include = includeDetails ?? default; + var preferredOverview = episode.GetPreferredOverview(); + var preferredTitle = episode.GetPreferredTitle(); + + ID = episode.TmdbEpisodeID; + SeasonID = alternateOrderingEpisode != null + ? alternateOrderingEpisode.TmdbEpisodeGroupID + : episode.TmdbSeasonID.ToString(); + ShowID = episode.TmdbShowID; + TvdbEpisodeID = episode.TvdbEpisodeID; + + Title = preferredTitle!.Value; + if (include.HasFlag(IncludeDetails.Titles)) + Titles = episode.GetAllTitles() + .ToDto(episode.EnglishTitle, preferredTitle, language); + + Overview = preferredOverview!.Value; + if (include.HasFlag(IncludeDetails.Overviews)) + Overviews = episode.GetAllOverviews() + .ToDto(episode.EnglishOverview, preferredOverview, language); + + if (alternateOrderingEpisode != null) + { + EpisodeNumber = alternateOrderingEpisode.EpisodeNumber; + SeasonNumber = alternateOrderingEpisode.SeasonNumber; + } + else + { + EpisodeNumber = episode.EpisodeNumber; + SeasonNumber = episode.SeasonNumber; + } + UserRating = new() + { + Value = (decimal)episode.UserRating, + MaxValue = 10, + Votes = episode.UserVotes, + Source = "TMDB", + }; + Runtime = episode.Runtime; + if (include.HasFlag(IncludeDetails.Images)) + Images = episode.GetImages() + .InLanguage(language) + .ToDto(includeThumbnails: true); + if (include.HasFlag(IncludeDetails.Cast)) + Cast = episode.Cast + .Select(cast => new Role(cast)) + .ToList(); + if (include.HasFlag(IncludeDetails.Crew)) + Crew = episode.Crew + .Select(crew => new Role(crew)) + .ToList(); + if (include.HasFlag(IncludeDetails.Ordering)) + { + var ordering = new List<OrderingInformation> + { + new(show, episode, alternateOrderingEpisode), + }; + foreach (var altOrderEp in episode.TmdbAlternateOrderingEpisodes) + ordering.Add(new(show, altOrderEp, alternateOrderingEpisode)); + Ordering = ordering + .OrderByDescending(o => o.InUse) + .ThenByDescending(o => string.IsNullOrEmpty(o.OrderingID)) + .ThenBy(o => o.OrderingName) + .ToList(); + } + if (include.HasFlag(IncludeDetails.CrossReferences)) + CrossReferences = episode.CrossReferences + .Select(xref => new CrossReference(xref)) + .ToList(); + if (include.HasFlag(IncludeDetails.FileCrossReferences)) + FileCrossReferences = FileCrossReference.From(episode.FileCrossReferences); + AiredAt = episode.AiredAt; + CreatedAt = episode.CreatedAt.ToUniversalTime(); + LastUpdatedAt = episode.LastUpdatedAt.ToUniversalTime(); + } + + public class OrderingInformation + { + /// <summary> + /// The ordering ID. + /// </summary> + public string OrderingID { get; init; } + + /// <summary> + /// The alternate ordering type. Will not be set if the main ordering is + /// used. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore), JsonConverter(typeof(StringEnumConverter))] + public AlternateOrderingType? OrderingType { get; init; } + + /// <summary> + /// English name of the alternate ordering scheme. + /// </summary> + public string OrderingName { get; init; } + + /// <summary> + /// The season id. Will be a stringified integer for the main ordering, + /// or a hex id any alternate ordering. + /// </summary> + public string SeasonID { get; init; } + + /// <summary> + /// English name of the season. + /// </summary> + public string SeasonName { get; init; } = string.Empty; + + /// <summary> + /// The season number for the ordering. + /// </summary> + public int SeasonNumber { get; init; } + + /// <summary> + /// The episode number for the ordering. + /// </summary> + public int EpisodeNumber { get; init; } + + /// <summary> + /// Indicates the current ordering is the default ordering for the episode. + /// </summary> + public bool IsDefault { get; init; } + + /// <summary> + /// Indicates the current ordering is the preferred ordering for the episode. + /// </summary> + public bool IsPreferred { get; init; } + + /// <summary> + /// Indicates the current ordering is in use for the episode. + /// </summary> + public bool InUse { get; init; } + + public OrderingInformation(TMDB_Show show, TMDB_Episode episode, TMDB_AlternateOrdering_Episode? alternateOrderingEpisodeInUse) + { + var season = episode.TmdbSeason; + OrderingID = episode.TmdbShowID.ToString(); + OrderingName = "Seasons"; + OrderingType = null; + SeasonID = episode.TmdbSeasonID.ToString(); + SeasonName = season?.EnglishTitle ?? "<unknown name>"; + SeasonNumber = episode.SeasonNumber; + EpisodeNumber = episode.EpisodeNumber; + IsDefault = true; + IsPreferred = string.IsNullOrEmpty(show.PreferredAlternateOrderingID) || string.Equals(show.PreferredAlternateOrderingID, episode.TmdbShowID.ToString()); + InUse = alternateOrderingEpisodeInUse == null; + } + + public OrderingInformation(TMDB_Show show, TMDB_AlternateOrdering_Episode episode, TMDB_AlternateOrdering_Episode? alternateOrderingEpisodeInUse) + { + var ordering = episode.TmdbAlternateOrdering; + var season = episode.TmdbAlternateOrderingSeason; + OrderingID = episode.TmdbEpisodeGroupCollectionID; + OrderingName = ordering?.EnglishTitle ?? "<unknown name>"; + OrderingType = ordering?.Type ?? AlternateOrderingType.Unknown; + SeasonID = episode.TmdbEpisodeGroupID; + SeasonName = season?.EnglishTitle ?? "<unknown name>"; + SeasonNumber = episode.SeasonNumber; + EpisodeNumber = episode.EpisodeNumber; + IsDefault = false; + IsPreferred = string.Equals(show.PreferredAlternateOrderingID, episode.TmdbEpisodeGroupCollectionID); + InUse = alternateOrderingEpisodeInUse != null && + episode.TMDB_AlternateOrdering_EpisodeID == alternateOrderingEpisodeInUse.TMDB_AlternateOrdering_EpisodeID; + } + } + + /// <summary> + /// APIv3 The Movie DataBase (TMDB) Episode Cross-Reference Data Transfer Object (DTO). + /// </summary> + public class CrossReference + { + /// <summary> + /// AniDB Anime ID. + /// </summary> + public int AnidbAnimeID { get; init; } + + /// <summary> + /// AniDB Episode ID. + /// </summary> + public int AnidbEpisodeID { get; init; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; init; } + + /// <summary> + /// TMDB Episode ID. Will be <c>0</c> if the <see cref="AnidbEpisodeID"/> + /// is not mapped to a TMDB Episode yet. + /// </summary> + public int TmdbEpisodeID { get; init; } + + /// <summary> + /// The index to order the cross-references if multiple references + /// exists for the same anidb or tmdb episode. + /// </summary> + public int Index { get; init; } + + /// <summary> + /// The match rating. + /// </summary> + public string Rating { get; init; } + + public CrossReference(CrossRef_AniDB_TMDB_Episode xref, int? index = null) + { + AnidbAnimeID = xref.AnidbAnimeID; + AnidbEpisodeID = xref.AnidbEpisodeID; + TmdbShowID = xref.TmdbShowID; + TmdbEpisodeID = xref.TmdbEpisodeID; + Index = index ?? xref.Ordering; + Rating = "None"; + // NOTE: Internal easter-eggs stays internally. + if (xref.MatchRating != MatchRatingEnum.SarahJessicaParker) + Rating = xref.MatchRating.ToString(); + } + } + + [Flags] + [JsonConverter(typeof(StringEnumConverter))] + public enum IncludeDetails + { + None = 0, + Titles = 1, + Overviews = 2, + Images = 4, + Ordering = 8, + CrossReferences = 16, + Cast = 32, + Crew = 64, + FileCrossReferences = 128, + } +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Input/TmdbBulkFetchBody.cs b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbBulkFetchBody.cs new file mode 100644 index 000000000..6edb963c1 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbBulkFetchBody.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Shoko.Plugin.Abstractions.DataModels; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB.Input; + +public class TmdbBulkFetchBody<TDetails> + where TDetails : struct, System.Enum +{ + [Required] + public List<int> IDs { get; set; } = []; + + public HashSet<TDetails>? Include { get; set; } = null; + + public HashSet<TitleLanguage>? Language { get; set; } = null; +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Input/TmdbBulkSearchBody.cs b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbBulkSearchBody.cs new file mode 100644 index 000000000..27ad3a7a2 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbBulkSearchBody.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Shoko.Server.API.v3.Models.TMDB.Input; + +/// <summary> +/// APIv3 The Movie DataBase (TMDB) Bulk Search Data Transfer Object (DTO). +/// </summary> +public class TmdbBulkSearchBody +{ + /// <summary> + /// The list of TMDB IDs to search for. + /// </summary> + /// <remarks> + /// The list can be include duplicates, in which case you want the same + /// metadata returned multiple times at different indexes, and the server + /// will take care of the de-duplication before fetching it remotely from + /// TMDB. + /// </remarks> + [Required, MinLength(1), MaxLength(25)] + public List<int> IDs { get; set; } = []; +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Input/TmdbDownloadImagesBody.cs b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbDownloadImagesBody.cs new file mode 100644 index 000000000..fab247b18 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbDownloadImagesBody.cs @@ -0,0 +1,16 @@ + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB.Input; + +public class TmdbDownloadImagesBody +{ + /// <summary> + /// Forcefully re-download existing images, even if they're already cached. + /// </summary> + public bool Force { get; set; } = false; + + /// <summary> + /// If true, the download will be ran immediately. + /// </summary> + public bool Immediate { get; set; } = false; +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Input/TmdbExportBody.cs b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbExportBody.cs new file mode 100644 index 000000000..7d0690b83 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbExportBody.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Models.CrossReference; +using static Shoko.Server.API.v3.Controllers.TmdbController; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB.Input; + +public class TmdbExportBody +{ + /// <summary> + /// Include only cross-references with the given AniDB episode. + /// </summary> + [Range(1, int.MaxValue)] + public int? AnidbEpisodeID { get; set; } = null; + + /// <summary> + /// Include only cross-references with the given AniDB anime. + /// </summary> + [Range(1, int.MaxValue)] + public int? AnidbAnimeID { get; set; } = null; + + /// <summary> + /// Include only cross-references with the given TMDB show. + /// </summary> + [Range(1, int.MaxValue)] + public int? TmdbShowID { get; set; } = null; + + /// <summary> + /// Include only cross-references with the given TMDB episode. + /// </summary> + [Range(0, int.MaxValue)] + public int? TmdbEpisodeID { get; set; } = null; + + /// <summary> + /// Include only cross-references with the given TMDB movie. + /// </summary> + [Range(1, int.MaxValue)] + public int? TmdbMovieID { get; set; } = null; + + /// <summary> + /// Include/exclude automatically made cross-references. + /// </summary> + [DefaultValue(IncludeOnlyFilter.True)] + public IncludeOnlyFilter Automatic { get; set; } = IncludeOnlyFilter.True; + + /// <summary> + /// Include/exclude cross-references with an episode. That is movie cross-references with an anidb episode set, or episode cross-references with a tmdb episode set. + /// </summary> + [DefaultValue(IncludeOnlyFilter.True)] + public IncludeOnlyFilter WithEpisodes { get; set; } = IncludeOnlyFilter.True; + + /// <summary> + /// Append human friendly comments in the output file. They serve no purpose other than to enlighten the humans reading the file what each cross-reference is for. + /// </summary> + public bool IncludeComments { get; set; } = false; + + /// <summary> + /// Sections to include in the output file, if we have anything to fill in in the selected sections. + /// </summary> + public HashSet<CrossReferenceExportType>? SectionSet { get; set; } = null; + + /// <summary> + /// Determines whether the movie filter is enabled. + /// </summary> + [JsonIgnore] + public bool MovieFilerEnabled => AnidbAnimeID is not null || AnidbEpisodeID is not null || TmdbMovieID is not null; + + /// <summary> + /// Determines whether the given movie cross-reference should be included in the export. + /// </summary> + /// <param name="xref">The movie cross-reference to check.</param> + /// <returns><c>true</c> if the cross-reference should be included, <c>false</c> otherwise.</returns> + public bool ShouldKeep(CrossRef_AniDB_TMDB_Movie xref) + { + if (!MovieFilerEnabled) + return true; + if (AnidbAnimeID is not null && AnidbAnimeID != xref.AnidbAnimeID) + return false; + if (AnidbEpisodeID is not null && AnidbEpisodeID != xref.AnidbEpisodeID) + return false; + if (TmdbMovieID is not null && TmdbMovieID != xref.TmdbMovieID) + return false; + return true; + } + + /// <summary> + /// Determines whether episode filtering is enabled. + /// </summary> + [JsonIgnore] + public bool EpisodeFilterEnabled => AnidbAnimeID is not null || AnidbEpisodeID is not null || TmdbShowID is not null || TmdbEpisodeID is not null; + + /// <summary> + /// Determines whether the given episode cross-reference should be included in the export. + /// </summary> + /// <param name="xref">The episode cross-reference to check.</param> + /// <returns><c>true</c> if the cross-reference should be included, <c>false</c> otherwise.</returns> + public bool ShouldKeep(CrossRef_AniDB_TMDB_Episode xref) + { + if (!EpisodeFilterEnabled) + return true; + if (AnidbAnimeID is not null && AnidbAnimeID != xref.AnidbAnimeID) + return false; + if (AnidbEpisodeID is not null && AnidbEpisodeID != xref.AnidbEpisodeID) + return false; + if (TmdbShowID is not null && TmdbShowID != xref.TmdbShowID) + return false; + if (TmdbEpisodeID is not null && TmdbEpisodeID != xref.TmdbEpisodeID) + return false; + return true; + } +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Input/TmdbRefreshMovieBody.cs b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbRefreshMovieBody.cs new file mode 100644 index 000000000..8271212dc --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbRefreshMovieBody.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB.Input; + +public class TmdbRefreshMovieBody +{ + /// <summary> + /// Forcefully download an update even if we updated recently. + /// </summary> + public bool Force { get; set; } = false; + + /// <summary> + /// Also download images. + /// </summary> + [DefaultValue(true)] + public bool DownloadImages { get; set; } = true; + + /// <summary> + /// Also download crew and cast. Will respect global option if not set. + /// </summary> + public bool? DownloadCrewAndCast { get; set; } = null; + + /// <summary> + /// Also download movie collection. Will respect global option if not set. + /// </summary> + public bool? DownloadCollections { get; set; } = null; + + /// <summary> + /// If true, the refresh will be ran immediately. + /// </summary> + public bool Immediate { get; set; } = false; + + /// <summary> + /// If true, the refresh will be skipped if the movie already exists. + /// </summary> + public bool SkipIfExists { get; set; } = false; +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Input/TmdbRefreshShowBody.cs b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbRefreshShowBody.cs new file mode 100644 index 000000000..ef2a35a43 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbRefreshShowBody.cs @@ -0,0 +1,44 @@ +using System.ComponentModel; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB.Input; + +/// <summary> +/// Refresh or download the metadata for a TMDB show. +/// </summary> +public class TmdbRefreshShowBody +{ + /// <summary> + /// Forcefully download an update even if we updated recently. + /// </summary> + public bool Force { get; set; } = false; + + /// <summary> + /// Also download images. + /// </summary> + [DefaultValue(true)] + public bool DownloadImages { get; set; } = true; + + /// <summary> + /// Also download crew and cast. Will respect global option if not set. + /// </summary> + public bool? DownloadCrewAndCast { get; set; } = null; + + /// <summary> + /// Also download alternate ordering information. Will respect global option if not set. + /// </summary> + public bool? DownloadAlternateOrdering { get; set; } = null; + + /// <summary> + /// If true, the refresh will be ran immediately. + /// </summary> + public bool Immediate { get; set; } = false; + + /// <summary> + /// If set to <see langword="true"/> and <see cref="Immediate"/> is also set + /// to <see langword="true"/>, then the heavy operations will be postponed + /// to run later in the background while the essential data necessary for a + /// preview will be downloaded immediately. + /// </summary> + public bool QuickRefresh { get; set; } = false; +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Input/TmdbSetPreferredOrderingBody.cs b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbSetPreferredOrderingBody.cs new file mode 100644 index 000000000..a7fe21a88 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Input/TmdbSetPreferredOrderingBody.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using Shoko.Server.API.v3.Controllers; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB.Input; + +public class TmdbSetPreferredOrderingBody +{ + /// <summary> + /// The new preferred ordering to use. + /// </summary> + [RegularExpression(TmdbController.AlternateOrderingIdRegex)] + public string? AlternateOrderingID { get; set; } +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Movie.cs b/Shoko.Server/API/v3/Models/TMDB/Movie.cs new file mode 100644 index 000000000..c2820beb1 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Movie.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.API.v3.Helpers; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.API.v3.Models.Shoko; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB; + +/// <summary> +/// APIv3 The Movie DataBase (TMDB) Movie Data Transfer Object (DTO). +/// </summary> +public class Movie +{ + /// <summary> + /// TMDB Movie ID. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// TMDB Movie Collection ID, if the movie is in a movie collection on TMDB. + /// </summary> + public int? CollectionID { get; init; } + + /// <summary> + /// IMDB Movie ID, if available. + /// </summary> + public string? ImdbMovieID { get; init; } + + /// <summary> + /// Preferred title based upon series title preference. + /// </summary> + public string Title { get; init; } + + /// <summary> + /// All available titles for the movie, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Title>? Titles { get; init; } + + /// <summary> + /// Preferred overview based upon description preference. + /// </summary> + public string Overview { get; init; } + + /// <summary> + /// All available overviews for the movie, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Overview>? Overviews { get; init; } + + /// <summary> + /// Original language the movie was shot in. + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public TitleLanguage OriginalLanguage { get; init; } + + /// <summary> + /// Indicates the movie is restricted to an age group above the legal age, + /// because it's a pornography. + /// </summary> + public bool IsRestricted { get; init; } + + /// <summary> + /// Indicates the entry is not truly a movie, including but not limited to + /// the types: + /// + /// - official compilations, + /// - best of, + /// - filmed sport events, + /// - music concerts, + /// - plays or stand-up show, + /// - fitness video, + /// - health video, + /// - live movie theater events (art, music), + /// - and how-to DVDs, + /// + /// among others. + /// </summary> + public bool IsVideo { get; init; } + + /// <summary> + /// User rating of the movie from TMDB users. + /// </summary> + public Rating UserRating { get; init; } + + /// <summary> + /// The movie run-time, if it is known. + /// </summary> + public TimeSpan? Runtime { get; init; } + + /// <summary> + /// Genres. + /// </summary> + public IReadOnlyList<string> Genres { get; init; } + + /// <summary> + /// Content ratings for different countries for this show. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<ContentRating>? ContentRatings { get; init; } + + /// <summary> + /// The production companies (studios) that produced the movie. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Studio>? Studios { get; init; } + + /// <summary> + /// Images associated with the movie, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Images? Images { get; init; } + + /// <summary> + /// The cast that have worked on this movie. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Cast { get; init; } + + /// <summary> + /// The crew that have worked on this movie. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Crew { get; init; } + + /// <summary> + /// Movie cross-references. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<CrossReference>? CrossReferences { get; init; } + + /// <summary> + /// TMDB movie to file cross-references. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<FileCrossReference>? FileCrossReferences { get; init; } + + /// <summary> + /// The date the movie first released, if it is known. + /// </summary> + public DateOnly? ReleasedAt { get; init; } + + /// <summary> + /// When the local metadata was first created. + /// </summary> + public DateTime CreatedAt { get; init; } + + /// <summary> + /// When the local metadata was last updated with new changes from the + /// remote. + /// </summary> + public DateTime LastUpdatedAt { get; init; } + + public Movie(TMDB_Movie movie, IncludeDetails? includeDetails = null, IReadOnlySet<TitleLanguage>? language = null) + { + var include = includeDetails ?? default; + var preferredTitle = movie.GetPreferredTitle(); + var preferredOverview = movie.GetPreferredOverview(); + + ID = movie.TmdbMovieID; + CollectionID = movie.TmdbCollectionID; + ImdbMovieID = movie.ImdbMovieID; + Title = preferredTitle!.Value; + if (include.HasFlag(IncludeDetails.Titles)) + Titles = movie.GetAllTitles() + .ToDto(movie.EnglishTitle, preferredTitle, language); + Overview = preferredOverview!.Value; + if (include.HasFlag(IncludeDetails.Overviews)) + Overviews = movie.GetAllOverviews() + .ToDto(movie.EnglishOverview, preferredOverview, language); + OriginalLanguage = movie.OriginalLanguage; + IsRestricted = movie.IsRestricted; + IsVideo = movie.IsVideo; + UserRating = new() + { + Value = (decimal)movie.UserRating, + MaxValue = 10, + Votes = movie.UserVotes, + Source = "TMDB", + Type = "User", + }; + Runtime = movie.Runtime; + Genres = movie.Genres; + if (include.HasFlag(IncludeDetails.ContentRatings)) + ContentRatings = movie.ContentRatings + .Select(contentRating => new ContentRating(contentRating)) + .ToList(); + if (include.HasFlag(IncludeDetails.Studios)) + Studios = movie.GetTmdbCompanies() + .Select(company => new Studio(company)) + .ToList(); + if (include.HasFlag(IncludeDetails.Images)) + Images = movie.GetImages() + .ToDto(language); + if (include.HasFlag(IncludeDetails.Cast)) + Cast = movie.Cast + .Select(cast => new Role(cast)) + .ToList(); + if (include.HasFlag(IncludeDetails.Crew)) + Crew = movie.Crew + .Select(crew => new Role(crew)) + .ToList(); + if (include.HasFlag(IncludeDetails.CrossReferences)) + CrossReferences = movie.CrossReferences + .Select(xref => new CrossReference(xref)) + .OrderBy(xref => xref.AnidbAnimeID) + .ThenBy(xref => xref.AnidbEpisodeID) + .ThenBy(xref => xref.TmdbMovieID) + .ToList(); + if (include.HasFlag(IncludeDetails.FileCrossReferences)) + FileCrossReferences = FileCrossReference.From(movie.FileCrossReferences); + ReleasedAt = movie.ReleasedAt; + CreatedAt = movie.CreatedAt.ToUniversalTime(); + LastUpdatedAt = movie.LastUpdatedAt.ToUniversalTime(); + } + + /// <summary> + /// APIv3 The Movie DataBase (TMDB) Movie Collection Data Transfer Object (DTO). + /// </summary> + public class Collection + { + /// <summary> + /// TMDB Movie Collection ID. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// Preferred title based upon series title preference. + /// </summary> + public string Title { get; init; } + + /// <summary> + /// All available titles for the movie collection, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Title>? Titles { get; init; } + + /// <summary> + /// Preferred overview based upon description preference. + /// </summary> + public string Overview { get; init; } + + /// <summary> + /// All available overviews for the movie collection, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Overview>? Overviews { get; init; } + + public int MovieCount { get; init; } + + /// <summary> + /// Images associated with the movie collection, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Images? Images { get; init; } + + /// <summary> + /// When the local metadata was first created. + /// </summary> + public DateTime CreatedAt { get; init; } + + /// <summary> + /// When the local metadata was last updated with new changes from the + /// remote. + /// </summary> + public DateTime LastUpdatedAt { get; init; } + + public Collection(TMDB_Collection collection, IncludeDetails? includeDetails = null, IReadOnlySet<TitleLanguage>? language = null) + { + var include = includeDetails ?? default; + var preferredTitle = collection.GetPreferredTitle()!; + var preferredOverview = collection.GetPreferredOverview(); + + ID = collection.TmdbCollectionID; + Title = preferredTitle.Value; + if (include.HasFlag(IncludeDetails.Titles)) + Titles = collection.GetAllTitles() + .ToDto(collection.EnglishTitle, preferredTitle, language); + Overview = preferredOverview!.Value; + if (include.HasFlag(IncludeDetails.Overviews)) + Overviews = collection.GetAllOverviews() + .ToDto(collection.EnglishOverview, preferredOverview, language); + MovieCount = collection.MovieCount; + if (include.HasFlag(IncludeDetails.Images)) + Images = collection.GetImages() + .ToDto(language, includeThumbnails: true); + CreatedAt = collection.CreatedAt.ToUniversalTime(); + LastUpdatedAt = collection.LastUpdatedAt.ToUniversalTime(); + } + + [Flags] + [JsonConverter(typeof(StringEnumConverter))] + public enum IncludeDetails + { + None = 0, + Titles = 1, + Overviews = 2, + Images = 4, + } + } + + + /// <summary> + /// APIv3 The Movie DataBase (TMDB) Movie Cross-Reference Data Transfer Object (DTO). + /// </summary> + public class CrossReference + { + /// <summary> + /// AniDB Anime ID. + /// </summary> + public int AnidbAnimeID { get; init; } + + /// <summary> + /// AniDB Episode ID. + /// </summary> + public int AnidbEpisodeID { get; init; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbMovieID { get; init; } + + /// <summary> + /// The match rating. + /// </summary> + public string Rating { get; init; } + + public CrossReference(CrossRef_AniDB_TMDB_Movie xref) + { + AnidbAnimeID = xref.AnidbAnimeID; + AnidbEpisodeID = xref.AnidbEpisodeID; + TmdbMovieID = xref.TmdbMovieID; + Rating = xref.Source is CrossRefSource.User ? "User" : "Automatic"; + } + } + + [Flags] + [JsonConverter(typeof(StringEnumConverter))] + public enum IncludeDetails + { + None = 0, + Titles = 1, + Overviews = 2, + Images = 4, + CrossReferences = 8, + Cast = 16, + Crew = 32, + Studios = 64, + ContentRatings = 128, + FileCrossReferences = 256, + } +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Search.cs b/Shoko.Server/API/v3/Models/TMDB/Search.cs new file mode 100644 index 000000000..b7033dd65 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Search.cs @@ -0,0 +1,386 @@ + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; +using TMDbLib.Objects.Search; + +using RemoteMovie = TMDbLib.Objects.Movies.Movie; +using RemoteShow = TMDbLib.Objects.TvShows.TvShow; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB; + +public static class Search +{ + /// <summary> + /// Auto-magic AniDB to TMDB match result DTO. + /// </summary> + /// <remarks> + /// The AniDB anime/episode metadata is not included since it's presumed + /// it's already available to the client when it searches for the match. + /// The <strong>remote</strong> TMDB information on the other hand is not + /// necessarily available and thus included with the match results. + /// </remarks> + public class AutoMatchResult + { + /// <summary> + /// AniDB Anime ID. + /// </summary> + public int AnimeID { get; set; } + + /// <summary> + /// AniDB Episode ID, if it's an auto-magic movie match. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public int? EpisodeID { get; set; } + + /// <summary> + /// Indicates that this is a local match using existing data instead of a + /// remote match. + /// </summary> + public bool IsLocal { get; set; } + + /// <summary> + /// Indicates that this is a remote match. + /// </summary> + public bool IsRemote { get; set; } + + /// <summary> + /// Indicates that the result is for a movie auto-magic match. + /// </summary> + [MemberNotNullWhen(true, nameof(EpisodeID))] + [MemberNotNullWhen(true, nameof(Movie))] + [MemberNotNullWhen(false, nameof(Show))] + public bool IsMovie { get; set; } + + /// <summary> + /// Remote TMDB Movie information. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public RemoteSearchMovie? Movie { get; set; } + + /// <summary> + /// Remote TMDB Show information. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public RemoteSearchShow? Show { get; set; } + + public AutoMatchResult(TmdbAutoSearchResult result) + { + AnimeID = result.AnidbAnime.AnimeID; + IsLocal = result.IsLocal; + IsRemote = result.IsRemote; + if (result.IsMovie) + { + IsMovie = true; + EpisodeID = result.AnidbEpisode.EpisodeID; + Movie = new(result.TmdbMovie); + } + else + { + Show = new(result.TmdbShow); + } + } + } + + /// <summary> + /// Remote search movie DTO. + /// </summary> + public class RemoteSearchMovie + { + /// <summary> + /// TMDB Movie ID. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// English title. + /// </summary> + public string Title { get; init; } + + /// <summary> + /// Title in the original language. + /// </summary> + public string OriginalTitle { get; init; } + + /// <summary> + /// Original language the movie was shot in. + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public TitleLanguage OriginalLanguage { get; init; } + + /// <summary> + /// Preferred overview based upon description preference. + /// </summary> + public string Overview { get; init; } + + /// <summary> + /// Indicates the movie is restricted to an age group above the legal age, + /// because it's a pornography. + /// </summary> + public bool IsRestricted { get; init; } + + /// <summary> + /// Indicates the entry is not truly a movie, including but not limited to + /// the types: + /// + /// - official compilations, + /// - best of, + /// - filmed sport events, + /// - music concerts, + /// - plays or stand-up show, + /// - fitness video, + /// - health video, + /// - live movie theater events (art, music), + /// - and how-to DVDs, + /// + /// among others. + /// </summary> + public bool IsVideo { get; init; } + + /// <summary> + /// The date the movie first released, if it is known. + /// </summary> + public DateOnly? ReleasedAt { get; init; } + + /// <summary> + /// Poster URL, if available. + /// </summary> + public string? Poster { get; init; } + + /// <summary> + /// Backdrop URL, if available. + /// </summary> + public string? Backdrop { get; init; } + + /// <summary> + /// User rating of the movie from TMDB users. + /// </summary> + public Rating UserRating { get; init; } + + /// <summary> + /// Genres. + /// </summary> + public IReadOnlyList<string> Genres { get; init; } + + public RemoteSearchMovie(TMDB_Movie movie) + { + ID = movie.Id; + Title = movie.EnglishTitle; + OriginalTitle = movie.OriginalTitle; + OriginalLanguage = movie.OriginalLanguage; + Overview = movie.EnglishOverview ?? string.Empty; + IsRestricted = movie.IsRestricted; + IsVideo = movie.IsVideo; + ReleasedAt = movie.ReleasedAt; + Poster = !string.IsNullOrEmpty(movie.PosterPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{movie.PosterPath}" + : null; + Backdrop = !string.IsNullOrEmpty(movie.BackdropPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{movie.BackdropPath}" + : null; + UserRating = new Rating() + { + Value = (decimal)movie.UserRating, + MaxValue = 10, + Source = "TMDB", + Type = "User", + Votes = movie.UserVotes, + }; + Genres = movie.Genres; + } + + public RemoteSearchMovie(RemoteMovie movie) + { + ID = movie.Id; + Title = movie.Title; + OriginalTitle = movie.OriginalTitle; + OriginalLanguage = movie.OriginalLanguage.GetTitleLanguage(); + Overview = movie.Overview ?? string.Empty; + IsRestricted = movie.Adult; + IsVideo = movie.Video; + ReleasedAt = movie.ReleaseDate.HasValue ? DateOnly.FromDateTime(movie.ReleaseDate.Value) : null; + Poster = !string.IsNullOrEmpty(movie.PosterPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{movie.PosterPath}" + : null; + Backdrop = !string.IsNullOrEmpty(movie.BackdropPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{movie.BackdropPath}" + : null; + UserRating = new Rating() + { + Value = (decimal)movie.VoteAverage, + MaxValue = 10, + Source = "TMDB", + Type = "User", + Votes = movie.VoteCount, + }; + Genres = movie.GetGenres(); + } + + public RemoteSearchMovie(SearchMovie movie) + { + ID = movie.Id; + Title = movie.Title; + OriginalTitle = movie.OriginalTitle; + OriginalLanguage = movie.OriginalLanguage.GetTitleLanguage(); + Overview = movie.Overview ?? string.Empty; + IsRestricted = movie.Adult; + IsVideo = movie.Video; + ReleasedAt = movie.ReleaseDate.HasValue ? DateOnly.FromDateTime(movie.ReleaseDate.Value) : null; + Poster = !string.IsNullOrEmpty(movie.PosterPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{movie.PosterPath}" + : null; + Backdrop = !string.IsNullOrEmpty(movie.BackdropPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{movie.BackdropPath}" + : null; + UserRating = new Rating() + { + Value = (decimal)movie.VoteAverage, + MaxValue = 10, + Source = "TMDB", + Type = "User", + Votes = movie.VoteCount, + }; + Genres = movie.GetGenres(); + } + } + + /// <summary> + /// Remote search show DTO. + /// </summary> + public class RemoteSearchShow + { + /// <summary> + /// TMDB Show ID. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// English title. + /// </summary> + public string Title { get; init; } + + /// <summary> + /// Title in the original language. + /// </summary> + public string OriginalTitle { get; init; } + + /// <summary> + /// Original language the show was shot in. + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public TitleLanguage OriginalLanguage { get; init; } + + /// <summary> + /// Preferred overview based upon description preference. + /// </summary> + public string Overview { get; init; } + + /// <summary> + /// The date the first episode aired at, if it is known. + /// </summary> + public DateOnly? FirstAiredAt { get; init; } + + /// <summary> + /// Poster URL, if available. + /// </summary> + public string? Poster { get; init; } + + /// <summary> + /// Backdrop URL, if available. + /// </summary> + public string? Backdrop { get; init; } + + /// <summary> + /// User rating of the movie from TMDB users. + /// </summary> + public Rating UserRating { get; init; } + + /// <summary> + /// Genres. + /// </summary> + public IReadOnlyList<string> Genres { get; init; } + + public RemoteSearchShow(TMDB_Show show) + { + ID = show.Id; + Title = show.EnglishTitle; + OriginalTitle = show.OriginalTitle; + OriginalLanguage = show.OriginalLanguage; + Overview = show.EnglishOverview ?? string.Empty; + FirstAiredAt = show.FirstAiredAt; + Poster = !string.IsNullOrEmpty(show.PosterPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{show.PosterPath}" + : null; + Backdrop = !string.IsNullOrEmpty(show.BackdropPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{show.BackdropPath}" + : null; + UserRating = new Rating() + { + Value = (decimal)show.UserRating, + MaxValue = 10, + Source = "TMDB", + Type = "User", + Votes = show.UserVotes, + }; + Genres = show.Genres; + } + + public RemoteSearchShow(RemoteShow show) + { + ID = show.Id; + Title = show.Name; + OriginalTitle = show.OriginalName; + OriginalLanguage = show.OriginalLanguage.GetTitleLanguage(); + Overview = show.Overview ?? string.Empty; + FirstAiredAt = show.FirstAirDate.HasValue ? DateOnly.FromDateTime(show.FirstAirDate.Value) : null; + Poster = !string.IsNullOrEmpty(show.PosterPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{show.PosterPath}" + : null; + Backdrop = !string.IsNullOrEmpty(show.BackdropPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{show.BackdropPath}" + : null; + UserRating = new Rating() + { + Value = (decimal)show.VoteAverage, + MaxValue = 10, + Source = "TMDB", + Type = "User", + Votes = show.VoteCount, + }; + Genres = show.GetGenres(); + } + + public RemoteSearchShow(SearchTv show) + { + ID = show.Id; + Title = show.Name; + OriginalTitle = show.OriginalName; + OriginalLanguage = show.OriginalLanguage.GetTitleLanguage(); + Overview = show.Overview ?? string.Empty; + FirstAiredAt = show.FirstAirDate.HasValue ? DateOnly.FromDateTime(show.FirstAirDate.Value) : null; + Poster = !string.IsNullOrEmpty(show.PosterPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{show.PosterPath}" + : null; + Backdrop = !string.IsNullOrEmpty(show.BackdropPath) && !string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) + ? $"{TmdbMetadataService.ImageServerUrl}original{show.BackdropPath}" + : null; + UserRating = new Rating() + { + Value = (decimal)show.VoteAverage, + MaxValue = 10, + Source = "TMDB", + Type = "User", + Votes = show.VoteCount, + }; + Genres = show.GetGenres(); + } + } +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Season.cs b/Shoko.Server/API/v3/Models/TMDB/Season.cs new file mode 100644 index 000000000..db7764dc6 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Season.cs @@ -0,0 +1,172 @@ + +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.API.v3.Helpers; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.API.v3.Models.TMDB; + +/// <summary> +/// APIv3 The Movie DataBase (TMDB) Season Data Transfer Object (DTO). +/// </summary> +public class Season +{ + /// <summary> + /// TMDB Season ID. + /// </summary> + public string ID { get; init; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int ShowID { get; init; } + + /// <summary> + /// The alternate ordering this season is associated with. Will be null + /// for main series seasons. + /// </summary> + public string? AlternateOrderingID { get; init; } + + /// <summary> + /// Preferred title based upon episode title preference. + /// </summary> + public string Title { get; init; } + + /// <summary> + /// All available titles for the season, if they should be included. + /// /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Title>? Titles { get; init; } + + /// <summary> + /// Preferred overview based upon episode title preference. + /// </summary> + public string Overview { get; init; } + + /// <summary> + /// All available overviews for the season, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Overview>? Overviews { get; init; } + + /// <summary> + /// Images associated with the season, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Images? Images { get; init; } + + /// <summary> + /// The cast that have worked on this season across all episodes. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Cast { get; init; } + + /// <summary> + /// The crew that have worked on this season across all episodes. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Crew { get; init; } + + /// <summary> + /// The season number for the main ordering or alternate ordering in use. + /// </summary> + public int SeasonNumber { get; init; } + + /// <summary> + /// Count of episodes associated with the season. + /// </summary> + public int EpisodeCount { get; init; } + + /// <summary> + /// Indicates the alternate ordering season is locked. Will not be set if + /// <seealso cref="AlternateOrderingID"/> is not set. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? IsLocked { get; init; } + + /// <summary> + /// When the local metadata was first created. + /// </summary> + public DateTime CreatedAt { get; init; } + + /// <summary> + /// When the local metadata was last updated with new changes from the + /// remote. + /// </summary> + public DateTime LastUpdatedAt { get; init; } + + public Season(TMDB_Season season, IncludeDetails? includeDetails = null, IReadOnlySet<TitleLanguage>? language = null) + { + var include = includeDetails ?? default; + var preferredOverview = season.GetPreferredOverview(); + var preferredTitle = season.GetPreferredTitle(); + + ID = season.TmdbSeasonID.ToString(); + ShowID = season.TmdbShowID; + AlternateOrderingID = null; + Title = preferredTitle!.Value; + if (include.HasFlag(IncludeDetails.Titles)) + Titles = season.GetAllTitles() + .ToDto(season.EnglishTitle, preferredTitle, language); + Overview = preferredOverview!.Value; + if (include.HasFlag(IncludeDetails.Overviews)) + Overviews = season.GetAllOverviews() + .ToDto(season.EnglishOverview, preferredOverview, language); + if (include.HasFlag(IncludeDetails.Images)) + Images = season.GetImages() + .ToDto(language); + if (include.HasFlag(IncludeDetails.Cast)) + Cast = season.Cast + .Select(cast => new Role(cast)) + .ToList(); + if (include.HasFlag(IncludeDetails.Crew)) + Crew = season.Crew + .Select(crew => new Role(crew)) + .ToList(); + SeasonNumber = season.SeasonNumber; + EpisodeCount = season.EpisodeCount; + IsLocked = null; + CreatedAt = season.CreatedAt.ToUniversalTime(); + LastUpdatedAt = season.LastUpdatedAt.ToUniversalTime(); + } + + public Season(TMDB_AlternateOrdering_Season season, IncludeDetails? includeDetails = null) + { + var include = includeDetails ?? default; + + ID = season.TmdbEpisodeGroupID; + ShowID = season.TmdbShowID; + AlternateOrderingID = season.TmdbEpisodeGroupCollectionID; + Title = season.EnglishTitle; + if (include.HasFlag(IncludeDetails.Titles)) + Titles = Array.Empty<Title>(); + Overview = string.Empty; + if (include.HasFlag(IncludeDetails.Overviews)) + Overviews = Array.Empty<Overview>(); + if (include.HasFlag(IncludeDetails.Images)) + Images = new(); + SeasonNumber = season.SeasonNumber; + EpisodeCount = season.EpisodeCount; + IsLocked = season.IsLocked; + CreatedAt = season.CreatedAt.ToUniversalTime(); + LastUpdatedAt = season.LastUpdatedAt.ToUniversalTime(); + } + + [Flags] + [JsonConverter(typeof(StringEnumConverter))] + public enum IncludeDetails + { + None = 0, + Titles = 1, + Overviews = 2, + Images = 4, + Cast = 8, + Crew = 16, + } +} diff --git a/Shoko.Server/API/v3/Models/TMDB/Show.cs b/Shoko.Server/API/v3/Models/TMDB/Show.cs new file mode 100644 index 000000000..4666409c5 --- /dev/null +++ b/Shoko.Server/API/v3/Models/TMDB/Show.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.API.v3.Helpers; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; + +#nullable enable +namespace Shoko.Server.API.v3.Models.TMDB; + +/// <summary> +/// APIv3 The Movie DataBase (TMDB) Show Data Transfer Object (DTO) +/// </summary> +public class Show +{ + /// <summary> + /// TMDB Show ID. + /// </summary> + public int ID { get; init; } + + /// <summary> + /// TvDB Show ID, if available. + /// </summary> + public int? TvdbID { get; init; } + + /// <summary> + /// Preferred title based upon series title preference. + /// </summary> + public string Title { get; init; } + + /// <summary> + /// All available titles, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Title>? Titles { get; init; } + + /// <summary> + /// Preferred overview based upon description preference. + /// </summary> + public string Overview { get; init; } + + /// <summary> + /// All available overviews for the series, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Overview>? Overviews { get; init; } + + /// <summary> + /// Original language the show was shot in. + /// </summary> + [JsonConverter(typeof(StringEnumConverter))] + public TitleLanguage OriginalLanguage { get; init; } + + /// <summary> + /// Indicates the show is restricted to an age group above the legal age, + /// because it's a pornography. + /// </summary> + public bool IsRestricted { get; init; } + + /// <summary> + /// User rating of the show from TMDB users. + /// </summary> + public Rating UserRating { get; init; } + + /// <summary> + /// Genres. + /// </summary> + public IReadOnlyList<string> Genres { get; init; } + + /// <summary> + /// Content ratings for different countries for this show. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<ContentRating>? ContentRatings { get; init; } + + /// <summary> + /// The production companies (studios) that produced the show. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Studio>? Studios { get; init; } + + /// <summary> + /// The television networks that aired the show. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Network>? Networks { get; init; } + + /// <summary> + /// Images associated with the show, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Images? Images { get; init; } + + /// <summary> + /// The cast that have worked on this show across all episodes and all seasons. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Cast { get; init; } + + /// <summary> + /// The crew that have worked on this show across all episodes and all seasons. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<Role>? Crew { get; init; } + + /// <summary> + /// Count of episodes associated with the show. + /// </summary> + public int EpisodeCount { get; init; } + + /// <summary> + /// Count of seasons associated with the show. + /// </summary> + public int SeasonCount { get; init; } + + /// <summary> + /// Count of locally alternate ordering schemes associated with the show. + /// </summary> + public int AlternateOrderingCount { get; init; } + + /// <summary> + /// All available ordering for the show, if they should be included. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<OrderingInformation>? Ordering { get; init; } + + /// <summary> + /// Show cross-references. + /// </summary> + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList<CrossReference>? CrossReferences { get; init; } + + /// <summary> + /// The date the first episode aired at, if it is known. + /// </summary> + public DateOnly? FirstAiredAt { get; init; } + + /// <summary> + /// The date the last episode aired at, if it is known. + /// </summary> + public DateOnly? LastAiredAt { get; init; } + + /// <summary> + /// When the local metadata was first created. + /// </summary> + public DateTime CreatedAt { get; init; } + + /// <summary> + /// When the local metadata was last updated with new changes from the + /// remote. + /// </summary> + public DateTime LastUpdatedAt { get; init; } + + public Show(TMDB_Show show, IncludeDetails? includeDetails = null, IReadOnlySet<TitleLanguage>? language = null) : + this(show, null, includeDetails, language) + { } + + public Show(TMDB_Show show, TMDB_AlternateOrdering? alternateOrdering, IncludeDetails? includeDetails = null, IReadOnlySet<TitleLanguage>? language = null) + { + var include = includeDetails ?? default; + var preferredOverview = show.GetPreferredOverview(); + var preferredTitle = show.GetPreferredTitle(); + + ID = show.TmdbShowID; + TvdbID = show.TvdbShowID; + Title = preferredTitle!.Value; + if (include.HasFlag(IncludeDetails.Titles)) + Titles = show.GetAllTitles() + .ToDto(show.EnglishOverview, preferredTitle, language); + + Overview = preferredOverview!.Value; + if (include.HasFlag(IncludeDetails.Overviews)) + Overviews = show.GetAllOverviews() + .ToDto(show.EnglishTitle, preferredOverview, language); + OriginalLanguage = show.OriginalLanguage; + IsRestricted = show.IsRestricted; + UserRating = new() + { + Value = (decimal)show.UserRating, + MaxValue = 10, + Votes = show.UserVotes, + Source = "TMDB", + }; + Genres = show.Genres; + if (include.HasFlag(IncludeDetails.ContentRatings)) + ContentRatings = show.ContentRatings.ToDto(language); + if (include.HasFlag(IncludeDetails.Studios)) + Studios = show.TmdbCompanies + .Select(company => new Studio(company)) + .ToList(); + if (include.HasFlag(IncludeDetails.Networks)) + Networks = show.TmdbNetworks + .Select(network => new Network(network)) + .ToList(); + if (include.HasFlag(IncludeDetails.Images)) + Images = show.GetImages() + .ToDto(language); + if (include.HasFlag(IncludeDetails.Cast)) + Cast = (alternateOrdering is null ? show.Cast : alternateOrdering.Cast) + .Select(cast => new Role(cast)) + .ToList(); + if (include.HasFlag(IncludeDetails.Crew)) + Crew = (alternateOrdering is null ? show.Crew : alternateOrdering.Crew) + .Select(cast => new Role(cast)) + .ToList(); + if (alternateOrdering != null) + { + EpisodeCount = alternateOrdering.EpisodeCount; + SeasonCount = alternateOrdering.SeasonCount; + } + else + { + EpisodeCount = show.EpisodeCount; + SeasonCount = show.SeasonCount; + } + AlternateOrderingCount = show.AlternateOrderingCount; + if (include.HasFlag(IncludeDetails.Ordering)) + { + var ordering = new List<OrderingInformation> + { + new(show, alternateOrdering), + }; + foreach (var altOrder in show.TmdbAlternateOrdering) + ordering.Add(new(show, altOrder, alternateOrdering)); + Ordering = ordering + .OrderByDescending(o => o.InUse) + .ThenByDescending(o => string.IsNullOrEmpty(o.OrderingID)) + .ThenBy(o => o.OrderingName) + .ToList(); + } + if (include.HasFlag(IncludeDetails.CrossReferences)) + CrossReferences = show.CrossReferences + .Select(xref => new CrossReference(xref)) + .OrderBy(xref => xref.AnidbAnimeID) + .ThenBy(xref => xref.TmdbShowID) + .ToList(); + FirstAiredAt = show.FirstAiredAt; + LastAiredAt = show.LastAiredAt; + CreatedAt = show.CreatedAt.ToUniversalTime(); + LastUpdatedAt = show.LastUpdatedAt.ToUniversalTime(); + } + + public class OrderingInformation + { + /// <summary> + /// The ordering ID. + /// </summary> + public string OrderingID { get; init; } + + /// <summary> + /// The alternate ordering type. Will not be set if the main ordering is + /// used. + /// </summary> + public AlternateOrderingType? OrderingType { get; init; } + + /// <summary> + /// English name of the ordering scheme. + /// </summary> + public string OrderingName { get; init; } + + /// <summary> + /// The number of episodes in the ordering scheme. + /// </summary> + public int EpisodeCount { get; init; } + + /// <summary> + /// The number of seasons in the ordering scheme. + /// </summary> + public int SeasonCount { get; init; } + + /// <summary> + /// Indicates the current ordering is the default ordering for the show. + /// </summary> + public bool IsDefault { get; init; } + + /// <summary> + /// Indicates the current ordering is the preferred ordering for the show. + /// </summary> + public bool IsPreferred { get; init; } + + /// <summary> + /// Indicates the current ordering is in use for the show. + /// </summary> + public bool InUse { get; init; } + + public OrderingInformation(TMDB_Show show, TMDB_AlternateOrdering? alternateOrderingInUse) + { + OrderingID = show.Id.ToString(); + OrderingName = "Seasons"; + OrderingType = null; + EpisodeCount = show.EpisodeCount; + SeasonCount = show.SeasonCount; + IsDefault = true; + IsPreferred = string.IsNullOrEmpty(show.PreferredAlternateOrderingID) || string.Equals(show.Id.ToString(), show.PreferredAlternateOrderingID); + InUse = alternateOrderingInUse == null; + } + + public OrderingInformation(TMDB_Show show, TMDB_AlternateOrdering ordering, TMDB_AlternateOrdering? alternateOrderingInUse) + { + OrderingID = ordering.TmdbEpisodeGroupCollectionID; + OrderingName = ordering.EnglishTitle; + OrderingType = ordering.Type; + EpisodeCount = ordering.EpisodeCount; + SeasonCount = ordering.SeasonCount; + IsDefault = false; + IsPreferred = string.Equals(ordering.TmdbEpisodeGroupCollectionID, show.PreferredAlternateOrderingID); + InUse = alternateOrderingInUse != null && + string.Equals(ordering.TmdbEpisodeGroupCollectionID, alternateOrderingInUse.TmdbEpisodeGroupCollectionID); + } + } + + /// <summary> + /// APIv3 The Movie DataBase (TMDB) Show Cross-Reference Data Transfer Object (DTO). + /// </summary> + public class CrossReference + { + /// <summary> + /// AniDB Anime ID. + /// </summary> + public int AnidbAnimeID { get; init; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; init; } + + /// <summary> + /// The match rating. + /// </summary> + public string Rating { get; init; } + + public CrossReference(CrossRef_AniDB_TMDB_Show xref) + { + AnidbAnimeID = xref.AnidbAnimeID; + TmdbShowID = xref.TmdbShowID; + Rating = xref.Source is CrossRefSource.User ? "User" : "Automatic"; + } + } + + [Flags] + [JsonConverter(typeof(StringEnumConverter))] + public enum IncludeDetails + { + None = 0, + Titles = 1, + Overviews = 2, + Images = 4, + Ordering = 8, + CrossReferences = 16, + Cast = 32, + Crew = 64, + Studios = 128, + Networks = 256, + ContentRatings = 512, + } +} diff --git a/Shoko.Server/Databases/BaseDatabase.cs b/Shoko.Server/Databases/BaseDatabase.cs index c074b98cc..38b8efa1c 100644 --- a/Shoko.Server/Databases/BaseDatabase.cs +++ b/Shoko.Server/Databases/BaseDatabase.cs @@ -9,6 +9,7 @@ using Shoko.Models; using Shoko.Models.Server; using Shoko.Server.Models; +using Shoko.Server.Renamer; using Shoko.Server.Repositories; using Shoko.Server.Server; using Shoko.Server.Utilities; @@ -63,7 +64,7 @@ public string GetDatabaseBackupName(int version) protected abstract Tuple<bool, string> ExecuteCommand(T connection, string command); protected abstract void Execute(T connection, string command); protected abstract long ExecuteScalar(T connection, string command); - protected abstract List<object> ExecuteReader(T connection, string command); + protected abstract List<object[]> ExecuteReader(T connection, string command); public abstract string GetConnectionString(); @@ -319,59 +320,61 @@ private void CreateInitialUsers() private void CreateInitialRenameScript() { - if (RepoFactory.RenameScript.GetAll().Any()) + if (RepoFactory.RenamerConfig.GetAll().Any()) { return; } - var initialScript = new RenameScript(); - - initialScript.ScriptName = Resources.Rename_Default; - initialScript.IsEnabledOnImport = 0; - initialScript.RenamerType = "Legacy"; - initialScript.Script = - "// Sample Output: [Coalgirls]_Highschool_of_the_Dead_-_01_(1920x1080_Blu-ray_H264)_[90CC6DC1].mkv" + - Environment.NewLine + - "// Sub group name" + Environment.NewLine + - "DO ADD '[%grp] '" + Environment.NewLine + - "// Anime Name, use english name if it exists, otherwise use the Romaji name" + Environment.NewLine + - "IF I(eng) DO ADD '%eng '" + Environment.NewLine + - "IF I(ann);I(!eng) DO ADD '%ann '" + Environment.NewLine + - "// Episode Number, don't use episode number for movies" + Environment.NewLine + - "IF T(!Movie) DO ADD '- %enr'" + Environment.NewLine + - "// If the file version is v2 or higher add it here" + Environment.NewLine + - "IF F(!1) DO ADD 'v%ver'" + Environment.NewLine + - "// Video Resolution" + Environment.NewLine + - "DO ADD ' (%res'" + Environment.NewLine + - "// Video Source (only if blu-ray or DVD)" + Environment.NewLine + - "IF R(DVD),R(Blu-ray) DO ADD ' %src'" + Environment.NewLine + - "// Video Codec" + Environment.NewLine + - "DO ADD ' %vid'" + Environment.NewLine + - "// Video Bit Depth (only if 10bit)" + Environment.NewLine + - "IF Z(10) DO ADD ' %bitbit'" + Environment.NewLine + - "DO ADD ') '" + Environment.NewLine + - "DO ADD '[%CRC]'" + Environment.NewLine + - string.Empty + Environment.NewLine + - "// Replacement rules (cleanup)" + Environment.NewLine + - "DO REPLACE ' ' '_' // replace spaces with underscores" + Environment.NewLine + - "DO REPLACE 'H264/AVC' 'H264'" + Environment.NewLine + - "DO REPLACE '0x0' ''" + Environment.NewLine + - "DO REPLACE '__' '_'" + Environment.NewLine + - "DO REPLACE '__' '_'" + Environment.NewLine + - string.Empty + Environment.NewLine + - "// Replace all illegal file name characters" + Environment.NewLine + - "DO REPLACE '<' '('" + Environment.NewLine + - "DO REPLACE '>' ')'" + Environment.NewLine + - "DO REPLACE ':' '-'" + Environment.NewLine + - "DO REPLACE '" + (char)34 + "' '`'" + Environment.NewLine + - "DO REPLACE '/' '_'" + Environment.NewLine + - "DO REPLACE '/' '_'" + Environment.NewLine + - "DO REPLACE '\\' '_'" + Environment.NewLine + - "DO REPLACE '|' '_'" + Environment.NewLine + - "DO REPLACE '?' '_'" + Environment.NewLine + - "DO REPLACE '*' '_'" + Environment.NewLine; - - RepoFactory.RenameScript.Save(initialScript); + var initialScript = new RenamerConfig(); + + initialScript.Name = Resources.Rename_Default; + initialScript.Type = typeof(WebAOMRenamer); + initialScript.Settings = new WebAOMSettings + { + Script = + "// Sample Output: [Coalgirls]_Highschool_of_the_Dead_-_01_(1920x1080_Blu-ray_H264)_[90CC6DC1].mkv" + + Environment.NewLine + + "// Sub group name" + Environment.NewLine + + "DO ADD '[%grp] '" + Environment.NewLine + + "// Anime Name, use english name if it exists, otherwise use the Romaji name" + Environment.NewLine + + "IF I(eng) DO ADD '%eng '" + Environment.NewLine + + "IF I(ann);I(!eng) DO ADD '%ann '" + Environment.NewLine + + "// Episode Number, don't use episode number for movies" + Environment.NewLine + + "IF T(!Movie) DO ADD '- %enr'" + Environment.NewLine + + "// If the file version is v2 or higher add it here" + Environment.NewLine + + "IF F(!1) DO ADD 'v%ver'" + Environment.NewLine + + "// Video Resolution" + Environment.NewLine + + "DO ADD ' (%res'" + Environment.NewLine + + "// Video Source (only if blu-ray or DVD)" + Environment.NewLine + + "IF R(DVD),R(Blu-ray) DO ADD ' %src'" + Environment.NewLine + + "// Video Codec" + Environment.NewLine + + "DO ADD ' %vid'" + Environment.NewLine + + "// Video Bit Depth (only if 10bit)" + Environment.NewLine + + "IF Z(10) DO ADD ' %bitbit'" + Environment.NewLine + + "DO ADD ') '" + Environment.NewLine + + "DO ADD '[%CRC]'" + Environment.NewLine + + string.Empty + Environment.NewLine + + "// Replacement rules (cleanup)" + Environment.NewLine + + "DO REPLACE ' ' '_' // replace spaces with underscores" + Environment.NewLine + + "DO REPLACE 'H264/AVC' 'H264'" + Environment.NewLine + + "DO REPLACE '0x0' ''" + Environment.NewLine + + "DO REPLACE '__' '_'" + Environment.NewLine + + "DO REPLACE '__' '_'" + Environment.NewLine + + string.Empty + Environment.NewLine + + "// Replace all illegal file name characters" + Environment.NewLine + + "DO REPLACE '<' '('" + Environment.NewLine + + "DO REPLACE '>' ')'" + Environment.NewLine + + "DO REPLACE ':' '-'" + Environment.NewLine + + "DO REPLACE '" + (char)34 + "' '`'" + Environment.NewLine + + "DO REPLACE '/' '_'" + Environment.NewLine + + "DO REPLACE '/' '_'" + Environment.NewLine + + "DO REPLACE '\\' '_'" + Environment.NewLine + + "DO REPLACE '|' '_'" + Environment.NewLine + + "DO REPLACE '?' '_'" + Environment.NewLine + + "DO REPLACE '*' '_'" + Environment.NewLine, + }; + + RepoFactory.RenamerConfig.Save(initialScript); } public void CreateInitialCustomTags() diff --git a/Shoko.Server/Databases/DatabaseFixes.cs b/Shoko.Server/Databases/DatabaseFixes.cs index 7bd86fecc..37647919b 100644 --- a/Shoko.Server/Databases/DatabaseFixes.cs +++ b/Shoko.Server/Databases/DatabaseFixes.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; @@ -14,10 +13,11 @@ using Shoko.Models.Server; using Shoko.Server.Extensions; using Shoko.Server.Filters.Legacy; -using Shoko.Server.ImageDownload; using Shoko.Server.Models; +using Shoko.Server.Models.CrossReference; using Shoko.Server.Providers.AniDB; using Shoko.Server.Providers.AniDB.HTTP; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Actions; @@ -28,19 +28,23 @@ using Shoko.Server.Tasks; using Shoko.Server.Utilities; +#pragma warning disable CA2012 +#pragma warning disable CS0618 namespace Shoko.Server.Databases; public class DatabaseFixes { - private static Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + public static void NoOperation() { } public static void UpdateAllStats() { - var scheduler = Utils.ServiceContainer.GetRequiredService<ISchedulerFactory>().GetScheduler().GetAwaiter().GetResult(); + var scheduler = Utils.ServiceContainer.GetRequiredService<ISchedulerFactory>().GetScheduler().ConfigureAwait(false).GetAwaiter().GetResult(); Task.WhenAll(RepoFactory.AnimeSeries.GetAll().Select(a => scheduler.StartJob<RefreshAnimeStatsJob>(b => b.AnimeID = a.AniDB_ID))).GetAwaiter() .GetResult(); } - + public static void MigrateGroupFilterToFilterPreset() { var legacyConverter = Utils.ServiceContainer.GetRequiredService<LegacyFilterConverter>(); @@ -119,10 +123,8 @@ public static void DropGroupFilter() using var session = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().SessionFactory.OpenSession(); session.CreateSQLQuery("DROP TABLE GroupFilter; DROP TABLE GroupFilterCondition").ExecuteUpdate(); } - - public static void MigrateAniDBToNet() { } - public static void DeleteSerieUsersWithoutSeries() + public static void DeleteSeriesUsersWithoutSeries() { //DB Fix Series not deleting series_user var list = new HashSet<int>(RepoFactory.AnimeSeries.Cache.Keys); @@ -159,316 +161,67 @@ public static void FixHashes() if (fixedHash) { RepoFactory.VideoLocal.Save(vid, false); - logger.Info("Fixed hashes on file: {0}", vid.FileName); + _logger.Info("Fixed hashes on file: {0}", vid.FileName); } } } catch (Exception ex) { - logger.Error(ex, ex.ToString()); - } - } - - public static void FixEmptyVideoInfos() - { - // List<SVR_VideoLocal> locals = RepoFactory.VideoLocal.GetAll() - // .Where(a => string.IsNullOrEmpty(a.FileName)) - // .ToList(); - // foreach (SVR_VideoLocal v in locals) - // { - // SVR_VideoLocal_Place p = v.Places.OrderBy(a => a.ImportFolderType).FirstOrDefault(); - // if (!string.IsNullOrEmpty(p?.FilePath) && v.Media != null) - // { - // v.FileName = p.FilePath; - // int a = p.FilePath.LastIndexOf($"{Path.DirectorySeparatorChar}", StringComparison.InvariantCulture); - // if (a > 0) - // v.FileName = p.FilePath.Substring(a + 1); - // SVR_VideoLocal_Place.FillVideoInfoFromMedia(v, v.Media); - // RepoFactory.VideoLocal.Save(v, false); - // } - // } - } - - public static void RemoveOldMovieDBImageRecords() - { - try - { - RepoFactory.MovieDB_Fanart.Delete(RepoFactory.MovieDB_Fanart.GetAll()); - RepoFactory.MovieDB_Poster.Delete(RepoFactory.MovieDB_Poster.GetAll()); - } - catch (Exception ex) - { - logger.Error(ex, "Could not RemoveOldMovieDBImageRecords: " + ex); - } - } - - - public static void FixContinueWatchingGroupFilter_20160406() { } - - public static void MigrateTraktLinks_V1_to_V2() - { - // Empty to preserve version info - } - - public static void MigrateTvDBLinks_V1_to_V2() - { - // Empty to preserve version info - } - - public static void MigrateTvDBLinks_v2_to_V3() - { - using (var session = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().SessionFactory.OpenSession()) - { - // Clean up possibly failed migration - RepoFactory.CrossRef_AniDB_TvDB_Episode.DeleteAllUnverifiedLinks(); - - // This method doesn't need mappings, and it's simple enough to work on all DB types - // Migrate Special's overrides - var specials = session - .CreateSQLQuery( - @"SELECT DISTINCT AnimeID, AniDBStartEpisodeType, AniDBStartEpisodeNumber, TvDBID, TvDBSeasonNumber, TvDBStartEpisodeNumber FROM CrossRef_AniDB_TvDBV2 WHERE TvDBSeasonNumber = 0") - .AddScalar("AnimeID", NHibernateUtil.Int32) - .AddScalar("AniDBStartEpisodeType", NHibernateUtil.Int32) - .AddScalar("AniDBStartEpisodeNumber", NHibernateUtil.Int32) - .AddScalar("TvDBID", NHibernateUtil.Int32) - .AddScalar("TvDBSeasonNumber", NHibernateUtil.Int32) - .AddScalar("TvDBStartEpisodeNumber", NHibernateUtil.Int32) - .List<object[]>().Select(a => new CrossRef_AniDB_TvDBV2 - { - AnimeID = (int)a[0], - AniDBStartEpisodeType = (int)a[1], - AniDBStartEpisodeNumber = (int)a[2], - TvDBID = (int)a[3], - TvDBSeasonNumber = (int)a[4], - TvDBStartEpisodeNumber = (int)a[5] - }).ToLookup(a => a.AnimeID); - - // Split them by series so that we can escape on error more easily - foreach (var special in specials) - { - var overrides = TvDBLinkingHelper.GetSpecialsOverridesFromLegacy(special.ToList()); - foreach (var episodeOverride in overrides) - { - var exists = - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBAndTvDBEpisodeIDs( - episodeOverride.AniDBEpisodeID, episodeOverride.TvDBEpisodeID); - if (exists != null) - { - continue; - } - - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.Save(episodeOverride); - } - } - - // override OVAs if they don't have default links - var ovas = session - .CreateSQLQuery( - @"SELECT DISTINCT AniDB_Anime.AnimeID, AniDBStartEpisodeType, AniDBStartEpisodeNumber, TvDBID, TvDBSeasonNumber, TvDBStartEpisodeNumber FROM CrossRef_AniDB_TvDBV2 INNER JOIN AniDB_Anime on AniDB_Anime.AnimeID = CrossRef_AniDB_TvDBV2.AnimeID WHERE AnimeType = 1 OR AnimeType = 3") - .AddScalar("AnimeID", NHibernateUtil.Int32) - .AddScalar("AniDBStartEpisodeType", NHibernateUtil.Int32) - .AddScalar("AniDBStartEpisodeNumber", NHibernateUtil.Int32) - .AddScalar("TvDBID", NHibernateUtil.Int32) - .AddScalar("TvDBSeasonNumber", NHibernateUtil.Int32) - .AddScalar("TvDBStartEpisodeNumber", NHibernateUtil.Int32) - .List<object[]>().Select(a => new CrossRef_AniDB_TvDBV2 - { - AnimeID = (int)a[0], - AniDBStartEpisodeType = (int)a[1], - AniDBStartEpisodeNumber = (int)a[2], - TvDBID = (int)a[3], - TvDBSeasonNumber = (int)a[4], - TvDBStartEpisodeNumber = (int)a[5] - }).ToLookup(a => a.AnimeID); - - // Split them by series so that we can escape on error more easily - foreach (var special in ovas) - { - var overrides = TvDBLinkingHelper.GetSpecialsOverridesFromLegacy(special.ToList()); - foreach (var episodeOverride in overrides) - { - var exists = - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBAndTvDBEpisodeIDs( - episodeOverride.AniDBEpisodeID, episodeOverride.TvDBEpisodeID); - if (exists != null) - { - continue; - } - - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.Save(episodeOverride); - } - } - - // Series Links - var links = session - .CreateSQLQuery( - @"SELECT AnimeID, TvDBID, CrossRefSource FROM CrossRef_AniDB_TvDBV2") - .AddScalar("AnimeID", NHibernateUtil.Int32) - .AddScalar("TvDBID", NHibernateUtil.Int32) - .AddScalar("CrossRefSource", NHibernateUtil.Int32) - .List<object[]>().Select(a => new CrossRef_AniDB_TvDB - { - AniDBID = (int)a[0], TvDBID = (int)a[1], CrossRefSource = (CrossRefSource)a[2] - }).DistinctBy(a => new[] - { - a.AniDBID, a.TvDBID - }).ToList(); - foreach (var link in links) - { - var exists = - RepoFactory.CrossRef_AniDB_TvDB.GetByAniDBAndTvDBID( - link.AniDBID, link.TvDBID); - if (exists != null) - { - continue; - } - - RepoFactory.CrossRef_AniDB_TvDB.Save(link); - } - - // Scan Series Without links for prequel/sequel links - var list = RepoFactory.CrossRef_AniDB_TvDB.GetSeriesWithoutLinks(); - - // AniDB_Anime_Relation is a direct repository, so GetFullLinearRelationTree will be slow - // Using a visited node set to skip processed nodes should be faster - var visitedNodes = new HashSet<int>(); - var seriesWithoutLinksLookup = list.ToDictionary(a => a.AniDB_ID); - - foreach (var animeseries in list) - { - if (visitedNodes.Contains(animeseries.AniDB_ID)) - { - continue; - } - - var relations = RepoFactory.AniDB_Anime_Relation.GetFullLinearRelationTree(animeseries.AniDB_ID); - var tvDBID = relations.SelectMany(a => RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(a)) - .FirstOrDefault(a => a != null)?.TvDBID; - // No link was found in the entire relation tree - if (tvDBID == null) - { - relations.ForEach(a => visitedNodes.Add(a)); - continue; - } - - var seriesToUpdate = relations.Where(a => seriesWithoutLinksLookup.ContainsKey(a)) - .Select(a => seriesWithoutLinksLookup[a]).ToList(); - foreach (var series in seriesToUpdate) - { - var link = new CrossRef_AniDB_TvDB - { - AniDBID = series.AniDB_ID, TvDBID = tvDBID.Value, CrossRefSource = CrossRefSource.Automatic - }; - // No need to check for existence - RepoFactory.CrossRef_AniDB_TvDB.Save(link); - visitedNodes.Add(series.AniDB_ID); - } - } - - list = RepoFactory.AnimeSeries.GetAll().ToList(); - var count = 0; - - list.AsParallel().ForAll(animeseries => - { - Interlocked.Increment(ref count); - if (count % 50 == 0) - { - ServerState.Instance.ServerStartingStatus = string.Format( - Resources.Database_Validating, "Generating TvDB Episode Matchings", - $" {count}/{list.Count}"); - } - - TvDBLinkingHelper.GenerateTvDBEpisodeMatches(animeseries.AniDB_ID, true); - }); - - var dropV2 = "DROP TABLE CrossRef_AniDB_TvDBV2"; - session.CreateSQLQuery(dropV2).ExecuteUpdate(); + _logger.Error(ex, ex.ToString()); } } - public static void RegenTvDBMatches() - { - RepoFactory.CrossRef_AniDB_TvDB_Episode.DeleteAllUnverifiedLinks(); - - var list = RepoFactory.AnimeSeries.GetAll().ToList(); - var count = 0; - - list.AsParallel().ForAll(animeseries => - { - Interlocked.Increment(ref count); - if (count % 50 == 0) - { - ServerState.Instance.ServerStartingStatus = string.Format( - Resources.Database_Validating, "Generating TvDB Episode Matchings", - $" {count}/{list.Count}"); - } - - TvDBLinkingHelper.GenerateTvDBEpisodeMatches(animeseries.AniDB_ID, true); - }); - } - - public static void FixAniDB_EpisodesWithMissingTitles() - { - // Deprecated. It's been a while since this was relevant - } - - public static void FixDuplicateTraktLinks() - { - // Empty to preserve version info - } - - public static void FixDuplicateTvDBLinks() - { - // Empty to preserve version info - } - public static void PopulateCharactersAndStaff() { - var allcharacters = RepoFactory.AniDB_Character.GetAll(); - var allstaff = RepoFactory.AniDB_Seiyuu.GetAll(); - var allanimecharacters = RepoFactory.AniDB_Anime_Character.GetAll().ToLookup(a => a.CharID, b => b); - var allcharacterstaff = RepoFactory.AniDB_Character_Seiyuu.GetAll(); + var allCharacters = RepoFactory.AniDB_Character.GetAll(); + var allStaff = RepoFactory.AniDB_Creator.GetAll(); + var allAnimeCharacters = RepoFactory.AniDB_Anime_Character.GetAll().ToLookup(a => a.CharID, b => b); + var allCharacterStaff = RepoFactory.AniDB_Character_Creator.GetAll(); var charBasePath = ImageUtils.GetBaseAniDBCharacterImagesPath() + Path.DirectorySeparatorChar; var creatorBasePath = ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar; - var charstosave = allcharacters.Select(character => new AnimeCharacter + var charsToSave = allCharacters.Select(character => new AnimeCharacter { Name = character.CharName?.Replace("`", "'"), AniDBID = character.CharID, Description = character.CharDescription?.Replace("`", "'"), - ImagePath = character.GetPosterPath()?.Replace(charBasePath, "") + ImagePath = character.GetFullImagePath()?.Replace(charBasePath, ""), }).ToList(); - RepoFactory.AnimeCharacter.Save(charstosave); + RepoFactory.AnimeCharacter.Save(charsToSave); - var stafftosave = allstaff.Select(a => new AnimeStaff + var staffToSave = allStaff.Select(a => new AnimeStaff { - Name = a.SeiyuuName?.Replace("`", "'"), AniDBID = a.SeiyuuID, ImagePath = a.GetPosterPath()?.Replace(creatorBasePath, "") + Name = a.Name?.Replace("`", "'"), + AniDBID = a.CreatorID, + ImagePath = a.GetFullImagePath()?.Replace(creatorBasePath, ""), }).ToList(); - RepoFactory.AnimeStaff.Save(stafftosave); + RepoFactory.AnimeStaff.Save(staffToSave); // This is not accurate. There was a mistake in DB design - var xrefstosave = (from xref in allcharacterstaff - let animes = allanimecharacters[xref.CharID].ToList() - from anime in animes + var xrefsToSave = ( + from xref in allCharacterStaff + let animeList = allAnimeCharacters[xref.CharacterID].ToList() + from anime in animeList select new CrossRef_Anime_Staff { AniDB_AnimeID = anime.AnimeID, Language = "Japanese", RoleType = (int)StaffRoleType.Seiyuu, Role = anime.CharType, - RoleID = RepoFactory.AnimeCharacter.GetByAniDBID(xref.CharID).CharacterID, - StaffID = RepoFactory.AnimeStaff.GetByAniDBID(xref.SeiyuuID).StaffID - }).ToList(); - RepoFactory.CrossRef_Anime_Staff.Save(xrefstosave); + RoleID = RepoFactory.AnimeCharacter.GetByAniDBID(xref.CharacterID).CharacterID, + StaffID = RepoFactory.AnimeStaff.GetByAniDBID(xref.CreatorID).StaffID + } + ).ToList(); + RepoFactory.CrossRef_Anime_Staff.Save(xrefsToSave); } public static void FixCharactersWithGrave() { var list = RepoFactory.AnimeCharacter.GetAll() - .Where(character => character.Description != null && character.Description.Contains("`")).ToList(); + .Where(character => character.Description != null && character.Description.Contains('`')).ToList(); foreach (var character in list) { - character.Description = character.Description.Replace("`", "'"); + character.Description = character.Description.Replace('`', '\''); RepoFactory.AnimeCharacter.Save(character); } } @@ -484,12 +237,12 @@ public static void RemoveBasePathsFromStaffAndCharacters() character.ImagePath = character.ImagePath.Replace(charBasePath, ""); while (character.ImagePath.StartsWith("" + Path.DirectorySeparatorChar)) { - character.ImagePath = character.ImagePath.Substring(1); + character.ImagePath = character.ImagePath[1..]; } while (character.ImagePath.StartsWith("" + Path.AltDirectorySeparatorChar)) { - character.ImagePath = character.ImagePath.Substring(1); + character.ImagePath = character.ImagePath[1..]; } RepoFactory.AnimeCharacter.Save(character); @@ -503,23 +256,18 @@ public static void RemoveBasePathsFromStaffAndCharacters() creator.ImagePath = creator.ImagePath.Replace(charBasePath, ""); while (creator.ImagePath.StartsWith("" + Path.DirectorySeparatorChar)) { - creator.ImagePath = creator.ImagePath.Substring(1); + creator.ImagePath = creator.ImagePath[1..]; } while (creator.ImagePath.StartsWith("" + Path.AltDirectorySeparatorChar)) { - creator.ImagePath = creator.ImagePath.Substring(1); + creator.ImagePath = creator.ImagePath[1..]; } RepoFactory.AnimeStaff.Save(creator); } } - public static void PopulateMyListIDs() - { - // nah - } - public static void RefreshAniDBInfoFromXML() { var i = 0; @@ -550,7 +298,7 @@ public static void RefreshAniDBInfoFromXML() } catch (Exception e) { - logger.Error( + _logger.Error( $"There was an error Populating AniDB Info for AniDB_Anime {animeID}, Update the Series' AniDB Info for a full stack: {e.Message}"); } } @@ -558,75 +306,57 @@ public static void RefreshAniDBInfoFromXML() public static void MigrateAniDB_AnimeUpdates() { - var tosave = RepoFactory.AniDB_Anime.GetAll() + var updates = RepoFactory.AniDB_Anime.GetAll() .Select(anime => new AniDB_AnimeUpdate { - AnimeID = anime.AnimeID, UpdatedAt = anime.DateTimeUpdated + AnimeID = anime.AnimeID, + UpdatedAt = anime.DateTimeUpdated, }) .ToList(); - RepoFactory.AniDB_AnimeUpdate.Save(tosave); + RepoFactory.AniDB_AnimeUpdate.Save(updates); } public static void MigrateAniDB_FileUpdates() { - var tosave = RepoFactory.AniDB_File.GetAll() + var updates = RepoFactory.AniDB_File.GetAll() .Select(file => new AniDB_FileUpdate { - FileSize = file.FileSize, Hash = file.Hash, HasResponse = true, UpdatedAt = file.DateTimeUpdated + FileSize = file.FileSize, + Hash = file.Hash, + HasResponse = true, + UpdatedAt = file.DateTimeUpdated, }) .ToList(); - tosave.AddRange(RepoFactory.CrossRef_File_Episode.GetAll().Where(a => RepoFactory.AniDB_File.GetByHash(a.Hash) == null) + updates.AddRange(RepoFactory.CrossRef_File_Episode.GetAll().Where(a => RepoFactory.AniDB_File.GetByHash(a.Hash) == null) .Select(a => (xref: a, vl: RepoFactory.VideoLocal.GetByHash(a.Hash))).Where(a => a.vl != null).Select(a => new AniDB_FileUpdate { - FileSize = a.xref.FileSize, Hash = a.xref.Hash, HasResponse = false, UpdatedAt = a.vl.DateTimeCreated + FileSize = a.xref.FileSize, + Hash = a.xref.Hash, + HasResponse = false, + UpdatedAt = a.vl.DateTimeCreated, })); - RepoFactory.AniDB_FileUpdate.Save(tosave); - } - - public static void FixDuplicateTagFiltersAndUpdateSeasons() { } - - public static void RecalculateYears() { } - - public static void PopulateResourceLinks() - { - // deprecated + RepoFactory.AniDB_FileUpdate.Save(updates); } public static void PopulateTagWeight() { try { - foreach (var atag in RepoFactory.AniDB_Anime_Tag.GetAll()) + foreach (var tag in RepoFactory.AniDB_Anime_Tag.GetAll()) { - atag.Weight = 0; - RepoFactory.AniDB_Anime_Tag.Save(atag); + tag.Weight = 0; + RepoFactory.AniDB_Anime_Tag.Save(tag); } } catch (Exception ex) { - logger.Error(ex, "Could not PopulateTagWeight: " + ex); + _logger.Error(ex, "Could not PopulateTagWeight: " + ex); } } - public static void FixTagsWithInclude() { } - - public static void MakeTagsApplyToSeries() { } - - public static void MakeYearsApplyToSeries() { } - - public static void UpdateAllTvDBSeries() - { - var service = Utils.ServiceContainer.GetRequiredService<ActionService>(); - service.RunImport_UpdateTvDB(true).GetAwaiter().GetResult(); - } - - public static void DummyMigrationOfObsolescence() - { - } - public static void EnsureNoOrphanedGroupsOrSeries() { var emptyGroups = RepoFactory.AnimeGroup.GetAll().Where(a => a.AllSeries.Count == 0).ToArray(); @@ -647,14 +377,14 @@ public static void EnsureNoOrphanedGroupsOrSeries() var name = ""; try { - name = series.SeriesName; + name = series.PreferredTitle; } catch { // ignore } - logger.Error(e, + _logger.Error(e, $"Unable to update group for orphaned series: AniDB ID: {series.AniDB_ID} SeriesID: {series.AnimeSeriesID} Series Name: {name}"); } } @@ -663,8 +393,8 @@ public static void EnsureNoOrphanedGroupsOrSeries() public static void FixWatchDates() { // Reset incorrectly parsed watch dates for anidb file. - logger.Debug($"Looking for faulty anidb file entries..."); - logger.Debug($"Looking for faulty episode user records..."); + _logger.Debug($"Looking for faulty anidb file entries..."); + _logger.Debug($"Looking for faulty episode user records..."); // Fetch every episode user record stored to both remove orphaned records and to make sure the watch date is correct. var userDict = RepoFactory.JMMUser.GetAll().ToDictionary(user => user.JMMUserID); var fileListDict = RepoFactory.AnimeEpisode.GetAll() @@ -673,7 +403,7 @@ public static void FixWatchDates() var episodeURsToRemove = new List<SVR_AnimeEpisode_User>(); foreach (var episodeUserRecord in RepoFactory.AnimeEpisode_User.GetAll()) { - // Remove any unkown episode user records. + // Remove any unknown episode user records. if (!fileListDict.ContainsKey(episodeUserRecord.AnimeEpisodeID) || !userDict.ContainsKey(episodeUserRecord.JMMUserID)) { @@ -710,10 +440,10 @@ public static void FixWatchDates() } } - logger.Debug($"Found {episodesURsToSave.Count} episode user records to fix."); + _logger.Debug($"Found {episodesURsToSave.Count} episode user records to fix."); RepoFactory.AnimeEpisode_User.Delete(episodeURsToRemove); RepoFactory.AnimeEpisode_User.Save(episodesURsToSave); - logger.Debug($"Updating series user records and series stats."); + _logger.Debug($"Updating series user records and series stats."); // Update all the series and groups to use the new watch dates. var seriesList = episodesURsToSave .GroupBy(record => record.AnimeSeriesID) @@ -733,7 +463,7 @@ public static void FixWatchDates() { var seriesUserRecord = seriesService.GetOrCreateUserRecord(series.AnimeSeriesID, userID); seriesUserRecord.LastEpisodeUpdate = DateTime.Now; - logger.Debug( + _logger.Debug( $"Updating series user contract for user \"{userDict[seriesUserRecord.JMMUserID].Username}\". (UserID={seriesUserRecord.JMMUserID},SeriesID={seriesUserRecord.AnimeSeriesID})"); RepoFactory.AnimeSeries_User.Save(seriesUserRecord); } @@ -755,18 +485,18 @@ public static void FixTagParentIDsAndNameOverrides() var xmlUtils = Utils.ServiceContainer.GetRequiredService<HttpXmlUtils>(); var animeParser = Utils.ServiceContainer.GetRequiredService<HttpAnimeParser>(); var animeList = RepoFactory.AniDB_Anime.GetAll(); - logger.Info($"Updating anidb tags for {animeList.Count} local anidb anime entries..."); + _logger.Info($"Updating anidb tags for {animeList.Count} local anidb anime entries..."); var count = 0; foreach (var anime in animeList) { if (++count % 10 == 0) - logger.Info($"Updating tags for local anidb anime entries... ({count}/{animeList.Count})"); + _logger.Info($"Updating tags for local anidb anime entries... ({count}/{animeList.Count})"); var xml = xmlUtils.LoadAnimeHTTPFromFile(anime.AnimeID).Result; if (string.IsNullOrEmpty(xml)) { - logger.Warn($"Unable to load cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); + _logger.Warn($"Unable to load cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); continue; } @@ -778,46 +508,46 @@ public static void FixTagParentIDsAndNameOverrides() } catch (Exception e) { - logger.Error(e, $"Unable to parse cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); + _logger.Error(e, $"Unable to parse cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); continue; } AnimeCreator.CreateTags(response.Tags, anime); - RepoFactory.AniDB_Anime.Save(anime, false); + RepoFactory.AniDB_Anime.Save(anime); } // One last time, clean up any unreferenced tags after we've processed // all the tags and their cross-references. var tagsToDelete = RepoFactory.AniDB_Tag.GetAll() - .Where(a => !RepoFactory.AniDB_Anime_Tag.GetByTagID(a.TagID).Any()) + .Where(a => RepoFactory.AniDB_Anime_Tag.GetByTagID(a.TagID).Count is 0) .ToList(); RepoFactory.AniDB_Tag.Delete(tagsToDelete); - logger.Info($"Done updating anidb tags for {animeList.Count} anidb anime entries."); + _logger.Info($"Done updating anidb tags for {animeList.Count} anidb anime entries."); } public static void FixAnimeSourceLinks() { - var animesToSave = new HashSet<SVR_AniDB_Anime>(); + var animeToSave = new HashSet<SVR_AniDB_Anime>(); foreach (var anime in RepoFactory.AniDB_Anime.GetAll()) { if (!string.IsNullOrEmpty(anime.Site_JP)) { - animesToSave.Add(anime); + animeToSave.Add(anime); anime.Site_JP = string.Join("|", anime.Site_JP.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct()); } if (!string.IsNullOrEmpty(anime.Site_EN)) { - animesToSave.Add(anime); + animeToSave.Add(anime); anime.Site_EN = string.Join("|", anime.Site_EN.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct()); } } - logger.Trace($"Found {animesToSave.Count} animes with faulty source links. Updating…"); + _logger.Trace($"Found {animeToSave.Count} anime with faulty source links. Updating…"); - RepoFactory.AniDB_Anime.Save(animesToSave); + RepoFactory.AniDB_Anime.Save(animeToSave); - logger.Trace($"Updated {animesToSave.Count} animes with faulty source links."); + _logger.Trace($"Updated {animeToSave.Count} anime with faulty source links."); } public static void FixEpisodeDateTimeUpdated() @@ -843,7 +573,7 @@ public static void FixEpisodeDateTimeUpdated() .Distinct() .ToHashSet(); - logger.Info($"Updating last updated episode timestamps for {anidbAnimeIDs.Count} local anidb anime entries..."); + _logger.Info($"Updating last updated episode timestamps for {anidbAnimeIDs.Count} local anidb anime entries..."); // …but if we do have any, then reset their timestamp now. foreach (var faultyEpisode in episodesToSave) @@ -856,12 +586,12 @@ public static void FixEpisodeDateTimeUpdated() foreach (var (anime, episodeList) in anidbAnimeIDs) { if (++progressCount % 10 == 0) - logger.Info($"Updating last updated episode timestamps for local anidb anime entries... ({progressCount}/{anidbAnimeIDs.Count})"); + _logger.Info($"Updating last updated episode timestamps for local anidb anime entries... ({progressCount}/{anidbAnimeIDs.Count})"); var xml = xmlUtils.LoadAnimeHTTPFromFile(anime.AnimeID).Result; if (string.IsNullOrEmpty(xml)) { - logger.Warn($"Unable to load cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); + _logger.Warn($"Unable to load cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); // We're unable to find the xml file, so the safest thing to do for future-proofing is to reset the dates. foreach (var episode in episodeList) { @@ -880,7 +610,7 @@ public static void FixEpisodeDateTimeUpdated() } catch (Exception e) { - logger.Error(e, $"Unable to parse cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); + _logger.Error(e, $"Unable to parse cached Anime_HTTP xml dump for anime: {anime.AnimeID}/{anime.MainTitle}"); // We're unable to parse the xml file, so the safest thing to do for future-proofing is to reset the dates. foreach (var episode in episodeList) { @@ -927,7 +657,7 @@ public static void FixEpisodeDateTimeUpdated() }).GetAwaiter().GetResult(); } - logger.Info($"Done updating last updated episode timestamps for {anidbAnimeIDs.Count} local anidb anime entries. Updated {updatedCount} episodes, reset {resetCount} episodes and queued anime {animeToUpdateSet.Count} updates for {faultyCount} faulty episodes."); + _logger.Info($"Done updating last updated episode timestamps for {anidbAnimeIDs.Count} local anidb anime entries. Updated {updatedCount} episodes, reset {resetCount} episodes and queued anime {animeToUpdateSet.Count} updates for {faultyCount} faulty episodes."); } public static void UpdateSeriesWithHiddenEpisodes() @@ -970,7 +700,7 @@ public static void FixOrphanedShokoEpisodes() .ToHashSet(); // Validate existing shoko episodes. - logger.Trace($"Checking {allAniDBEpisodes.Values.Count} anidb episodes for broken or incorrect links…"); + _logger.Trace($"Checking {allAniDBEpisodes.Values.Count} anidb episodes for broken or incorrect links…"); var shokoEpisodesToSave = new List<SVR_AnimeEpisode>(); foreach (var episode in allAniDBEpisodes.Values) { @@ -996,16 +726,15 @@ public static void FixOrphanedShokoEpisodes() // episode. shokoEpisodesToRemove.Add(shokoEpisode); } - logger.Trace($"Checked {allAniDBEpisodes.Values.Count} anidb episodes for broken or incorrect links. Found {shokoEpisodesToSave.Count} shoko episodes to fix and {shokoEpisodesToRemove.Count} to remove."); + _logger.Trace($"Checked {allAniDBEpisodes.Values.Count} anidb episodes for broken or incorrect links. Found {shokoEpisodesToSave.Count} shoko episodes to fix and {shokoEpisodesToRemove.Count} to remove."); RepoFactory.AnimeEpisode.Save(shokoEpisodesToSave); // Remove any existing links to the episodes that will be removed. - logger.Trace($"Checking {shokoEpisodesToRemove.Count} orphaned shoko episodes before deletion."); + _logger.Trace($"Checking {shokoEpisodesToRemove.Count} orphaned shoko episodes before deletion."); var anidbFilesToRemove = new List<SVR_AniDB_File>(); var xrefsToRemove = new List<SVR_CrossRef_File_Episode>(); var videosToRefetch = new List<SVR_VideoLocal>(); - var tvdbXRefsToRemove = new List<CrossRef_AniDB_TvDB_Episode>(); - var tvdbXRefOverridesToRemove = new List<CrossRef_AniDB_TvDB_Episode_Override>(); + var tmdbXrefsToRemove = new List<CrossRef_AniDB_TMDB_Episode>(); foreach (var shokoEpisode in shokoEpisodesToRemove) { var xrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(shokoEpisode.AniDB_EpisodeID); @@ -1018,19 +747,17 @@ public static void FixOrphanedShokoEpisodes() .Select(xref => RepoFactory.AniDB_File.GetByHashAndFileSize(xref.Hash, xref.FileSize)) .Where(anidbFile => anidbFile != null) .ToList(); - var tvdbXRefs = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBEpisodeID(shokoEpisode.AniDB_EpisodeID); - var tvdbXRefOverrides = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBEpisodeID(shokoEpisode.AniDB_EpisodeID); + var tmdbXrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(shokoEpisode.AniDB_EpisodeID); xrefsToRemove.AddRange(xrefs); videosToRefetch.AddRange(videos); anidbFilesToRemove.AddRange(anidbFiles); - tvdbXRefsToRemove.AddRange(tvdbXRefs); - tvdbXRefOverridesToRemove.AddRange(tvdbXRefOverrides); + tmdbXrefsToRemove.AddRange(tmdbXrefs); } // Schedule a refetch of any video files affected by the removal of the // episodes. They were likely moved to another episode entry so let's // try and fetch that. - logger.Trace($"Scheduling {videosToRefetch.Count} videos for a re-fetch."); + _logger.Trace($"Scheduling {videosToRefetch.Count} videos for a re-fetch."); foreach (var video in videosToRefetch) { scheduler.StartJob<ProcessFileJob>(c => @@ -1041,19 +768,61 @@ public static void FixOrphanedShokoEpisodes() }).GetAwaiter().GetResult(); } - logger.Trace($"Deleting {shokoEpisodesToRemove.Count} orphaned shoko episodes."); + _logger.Trace($"Deleting {shokoEpisodesToRemove.Count} orphaned shoko episodes."); RepoFactory.AnimeEpisode.Delete(shokoEpisodesToRemove); - logger.Trace($"Deleting {anidbFilesToRemove.Count} orphaned anidb files."); + _logger.Trace($"Deleting {anidbFilesToRemove.Count} orphaned anidb files."); RepoFactory.AniDB_File.Delete(anidbFilesToRemove); - logger.Trace($"Deleting {tvdbXRefsToRemove.Count} orphaned anidb/tvdb episode cross-references."); - RepoFactory.CrossRef_AniDB_TvDB_Episode.Delete(tvdbXRefsToRemove); - - logger.Trace($"Deleting {tvdbXRefOverridesToRemove.Count} orphaned anidb/tvdb episode cross-reference overrides."); - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.Delete(tvdbXRefOverridesToRemove); + _logger.Trace($"Deleting {tmdbXrefsToRemove.Count} orphaned tmdb xrefs."); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(tmdbXrefsToRemove); - logger.Trace($"Deleting {xrefsToRemove.Count} orphaned file/episode cross-references."); + _logger.Trace($"Deleting {xrefsToRemove.Count} orphaned file/episode cross-references."); RepoFactory.CrossRef_File_Episode.Delete(xrefsToRemove); } + + public static void CleanupAfterAddingTMDB() + { + var service = Utils.ServiceContainer.GetRequiredService<TmdbMetadataService>(); + + // Remove the "MovieDB" directory in the image directory, since it's no longer used, + var dir = new DirectoryInfo(Path.Join(ImageUtils.GetBaseImagesPath(), "MovieDB")); + if (dir.Exists) + dir.Delete(true); + + // Schedule commands to get the new movie info for existing cross-reference + service.UpdateAllMovies(true, true).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Schedule tmdb searches if we have auto linking enabled. + service.ScanForMatches().ConfigureAwait(false).GetAwaiter().GetResult(); + } + + public static void CreateDefaultRenamerConfig() + { + var existingRenamer = RepoFactory.RenamerConfig.GetByName("Default"); + if (existingRenamer != null) + return; + + var config = new RenamerConfig + { + Name = "Default", + Type = typeof(Renamer.WebAOMRenamer), + Settings = new Renamer.WebAOMSettings(), + }; + + RepoFactory.RenamerConfig.Save(config); + } + + public static void CleanupAfterRemovingTvDB() + { + var dir = new DirectoryInfo(Path.Join(ImageUtils.GetBaseImagesPath(), "TvDB")); + if (dir.Exists) + dir.Delete(true); + } + + public static void ClearQuartzQueue() + { + var queueHandler = Utils.ServiceContainer.GetRequiredService<QueueHandler>(); + queueHandler.Clear().ConfigureAwait(false).GetAwaiter().GetResult(); + } } diff --git a/Shoko.Server/Databases/MySQL.cs b/Shoko.Server/Databases/MySQL.cs index 05e9f94f7..588499569 100644 --- a/Shoko.Server/Databases/MySQL.cs +++ b/Shoko.Server/Databases/MySQL.cs @@ -1,14 +1,20 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using FluentNHibernate.Cfg; using FluentNHibernate.Cfg.Db; +using MessagePack; using Microsoft.Extensions.DependencyInjection; using MySqlConnector; using NHibernate; using NHibernate.Driver.MySqlConnector; +using Shoko.Commons.Extensions; using Shoko.Commons.Properties; -using Shoko.Server.Databases.NHIbernate; +using Shoko.Plugin.Abstractions; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models; +using Shoko.Server.Renamer; using Shoko.Server.Repositories; using Shoko.Server.Server; using Shoko.Server.Utilities; @@ -21,8 +27,7 @@ namespace Shoko.Server.Databases; public class MySQL : BaseDatabase<MySqlConnection> { public override string Name { get; } = "MySQL"; - public override int RequiredVersion { get; } = 127; - + public override int RequiredVersion { get; } = 139; private List<DatabaseCommand> createVersionTable = new() { @@ -248,7 +253,7 @@ public class MySQL : BaseDatabase<MySqlConnection> new DatabaseCommand(1, 109, "CREATE TABLE `Trakt_Season` ( `Trakt_SeasonID` INT NOT NULL AUTO_INCREMENT, `Trakt_ShowID` int NOT NULL, `Season` int NOT NULL, `URL` text character set utf8, PRIMARY KEY (`Trakt_SeasonID`) ) ; "), new DatabaseCommand(1, 110, - "CREATE TABLE `CrossRef_AniDB_Trakt` ( `CrossRef_AniDB_TraktID` INT NOT NULL AUTO_INCREMENT, `AnimeID` int NOT NULL, `TraktID` varchar(100) character set utf8, `TraktSeasonNumber` int NOT NULL, `CrossRefSource` int NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TraktID`) ) ; ") + "CREATE TABLE `CrossRef_AniDB_Trakt` ( `CrossRef_AniDB_TraktID` INT NOT NULL AUTO_INCREMENT, `AnimeID` int NOT NULL, `TraktID` varchar(100) character set utf8, `TraktSeasonNumber` int NOT NULL, `CrossRefSource` int NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TraktID`) ) ; "), }; private List<DatabaseCommand> patchCommands = new() @@ -265,8 +270,8 @@ public class MySQL : BaseDatabase<MySqlConnection> new(4, 1, "ALTER TABLE AnimeGroup ADD DefaultAnimeSeriesID int NULL"), new(5, 1, "ALTER TABLE JMMUser ADD CanEditServerSettings int NULL"), new(6, 1, "ALTER TABLE VideoInfo ADD VideoBitDepth varchar(100) NULL"), - new(7, 1, DatabaseFixes.FixDuplicateTvDBLinks), - new(7, 2, DatabaseFixes.FixDuplicateTraktLinks), + new(7, 1, DatabaseFixes.NoOperation), + new(7, 2, DatabaseFixes.NoOperation), new(7, 3, "ALTER TABLE `CrossRef_AniDB_TvDB` ADD UNIQUE INDEX `UIX_CrossRef_AniDB_TvDB_Season` (`TvDBID` ASC, `TvDBSeasonNumber` ASC) ;"), new(7, 4, @@ -413,19 +418,19 @@ public class MySQL : BaseDatabase<MySqlConnection> "CREATE TABLE CrossRef_AniDB_TvDBV2( CrossRef_AniDB_TvDBV2ID INT NOT NULL AUTO_INCREMENT, AnimeID int NOT NULL, AniDBStartEpisodeType int NOT NULL, AniDBStartEpisodeNumber int NOT NULL, TvDBID int NOT NULL, TvDBSeasonNumber int NOT NULL, TvDBStartEpisodeNumber int NOT NULL, TvDBTitle text character set utf8, CrossRefSource int NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TvDBV2ID`) ) ; "), new(29, 2, "ALTER TABLE `CrossRef_AniDB_TvDBV2` ADD UNIQUE INDEX `UIX_CrossRef_AniDB_TvDBV2` (`AnimeID` ASC, `TvDBID` ASC, `TvDBSeasonNumber` ASC, `TvDBStartEpisodeNumber` ASC, `AniDBStartEpisodeType` ASC, `AniDBStartEpisodeNumber` ASC) ;"), - new(29, 3, DatabaseFixes.MigrateTvDBLinks_V1_to_V2), + new(29, 3, DatabaseFixes.NoOperation), new(30, 1, "ALTER TABLE `GroupFilter` ADD `Locked` int NULL ;"), new(31, 1, "ALTER TABLE VideoInfo ADD FullInfo varchar(10000) NULL"), new(32, 1, "CREATE TABLE CrossRef_AniDB_TraktV2( CrossRef_AniDB_TraktV2ID INT NOT NULL AUTO_INCREMENT, AnimeID int NOT NULL, AniDBStartEpisodeType int NOT NULL, AniDBStartEpisodeNumber int NOT NULL, TraktID varchar(100) character set utf8, TraktSeasonNumber int NOT NULL, TraktStartEpisodeNumber int NOT NULL, TraktTitle text character set utf8, CrossRefSource int NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TraktV2ID`) ) ; "), new(32, 2, "ALTER TABLE `CrossRef_AniDB_TraktV2` ADD UNIQUE INDEX `UIX_CrossRef_AniDB_TraktV2` (`AnimeID` ASC, `TraktSeasonNumber` ASC, `TraktStartEpisodeNumber` ASC, `AniDBStartEpisodeType` ASC, `AniDBStartEpisodeNumber` ASC) ;"), - new(32, 3, DatabaseFixes.MigrateTraktLinks_V1_to_V2), + new(32, 3, DatabaseFixes.NoOperation), new(33, 1, "CREATE TABLE `CrossRef_AniDB_Trakt_Episode` ( `CrossRef_AniDB_Trakt_EpisodeID` INT NOT NULL AUTO_INCREMENT, `AnimeID` int NOT NULL, `AniDBEpisodeID` int NOT NULL, `TraktID` varchar(100) character set utf8, `Season` int NOT NULL, `EpisodeNumber` int NOT NULL, PRIMARY KEY (`CrossRef_AniDB_Trakt_EpisodeID`) ) ; "), new(33, 2, "ALTER TABLE `CrossRef_AniDB_Trakt_Episode` ADD UNIQUE INDEX `UIX_CrossRef_AniDB_Trakt_Episode_AniDBEpisodeID` (`AniDBEpisodeID` ASC) ;"), - new(34, 1, DatabaseFixes.RemoveOldMovieDBImageRecords), + new(34, 1, DatabaseFixes.NoOperation), new(35, 1, "CREATE TABLE `CustomTag` ( `CustomTagID` INT NOT NULL AUTO_INCREMENT, `TagName` text character set utf8, `TagDescription` text character set utf8, PRIMARY KEY (`CustomTagID`) ) ; "), new(35, 2, @@ -450,7 +455,7 @@ public class MySQL : BaseDatabase<MySqlConnection> new(45, 2, "UPDATE GroupFilter SET FilterType = 1 ;"), new(45, 3, "ALTER TABLE `GroupFilter` CHANGE COLUMN `FilterType` `FilterType` int NOT NULL ;"), - new(45, 4, DatabaseFixes.FixContinueWatchingGroupFilter_20160406), + new(45, 4, DatabaseFixes.NoOperation), new(46, 1, "ALTER TABLE `AniDB_Anime` ADD `ContractVersion` int NOT NULL DEFAULT 0"), new(46, 2, "ALTER TABLE `AniDB_Anime` ADD `ContractString` mediumtext character set utf8 NULL"), @@ -526,7 +531,7 @@ public class MySQL : BaseDatabase<MySqlConnection> new(51, 23, "ALTER TABLE `AnimeGroup` ADD `ContractSize` int NOT NULL DEFAULT 0"), new(51, 24, "ALTER TABLE `AnimeGroup` DROP COLUMN `ContractString`"), new(52, 1, "ALTER TABLE `AniDB_Anime` DROP COLUMN `AllCategories`"), - new(53, 1, DatabaseFixes.DeleteSerieUsersWithoutSeries), + new(53, 1, DatabaseFixes.DeleteSeriesUsersWithoutSeries), new(54, 1, "CREATE TABLE `VideoLocal_Place` ( `VideoLocal_Place_ID` INT NOT NULL AUTO_INCREMENT, `VideoLocalID` int NOT NULL, `FilePath` text character set utf8 NOT NULL, `ImportFolderID` int NOT NULL, `ImportFolderType` int NOT NULL, PRIMARY KEY (`VideoLocal_Place_ID`) ) ; "), new(54, 2, "ALTER TABLE `VideoLocal` ADD `FileName` text character set utf8 NOT NULL"), @@ -563,11 +568,11 @@ public class MySQL : BaseDatabase<MySqlConnection> "CREATE TABLE `ScanFile` ( `ScanFileID` INT NOT NULL AUTO_INCREMENT, `ScanID` int NOT NULL, `ImportFolderID` int NOT NULL, `VideoLocal_Place_ID` int NOT NULL, `FullName` text character set utf8, `FileSize` bigint NOT NULL, `Status` int NOT NULL, `CheckDate` datetime NULL, `Hash` text character set utf8, `HashResult` text character set utf8 NULL, PRIMARY KEY (`ScanFileID`) ) ; "), new(57, 3, "ALTER TABLE `ScanFile` ADD INDEX `UIX_ScanFileStatus` (`ScanID` ASC, `Status` ASC, `CheckDate` ASC) ;"), - new(58, 1, DatabaseFixes.FixEmptyVideoInfos), + new(58, 1, DatabaseFixes.NoOperation), new(59, 1, "ALTER TABLE `GroupFilter` ADD INDEX `IX_groupfilter_GroupFilterName` (`GroupFilterName`(250));"), - new(60, 1, DatabaseFixes.FixTagsWithInclude), - new(61, 1, DatabaseFixes.MakeYearsApplyToSeries), + new(60, 1, DatabaseFixes.NoOperation), + new(61, 1, DatabaseFixes.NoOperation), new(62, 1, "ALTER TABLE JMMUser ADD PlexToken text character set utf8"), new(63, 1, "ALTER TABLE AniDB_File ADD IsChaptered INT NOT NULL DEFAULT -1"), new(64, 1, "ALTER TABLE `CrossRef_File_Episode` ADD INDEX `IX_Xref_Epid` (`episodeid` ASC) ;"), @@ -581,7 +586,7 @@ public class MySQL : BaseDatabase<MySqlConnection> new(67, 1, "ALTER TABLE `TvDB_Episode` ADD `Rating` int NULL"), new(67, 2, "ALTER TABLE `TvDB_Episode` ADD `AirDate` datetime NULL"), new(67, 3, "ALTER TABLE `TvDB_Episode` DROP COLUMN `FirstAired`"), - new(67, 4, DatabaseFixes.UpdateAllTvDBSeries), + new(67, 4, DatabaseFixes.NoOperation), new(68, 1, "ALTER TABLE `AnimeSeries` ADD `AirsOn` TEXT character set utf8 NULL"), new(69, 1, "DROP TABLE `Trakt_ImageFanart`"), new(69, 2, "DROP TABLE `Trakt_ImagePoster`"), @@ -597,7 +602,7 @@ public class MySQL : BaseDatabase<MySqlConnection> new(72, 1, "ALTER TABLE `AniDB_Episode` ADD `Description` text character set utf8 NOT NULL"), new(72, 2, DatabaseFixes.FixCharactersWithGrave), new(73, 1, DatabaseFixes.RefreshAniDBInfoFromXML), - new(74, 1, DatabaseFixes.MakeTagsApplyToSeries), + new(74, 1, DatabaseFixes.NoOperation), new(74, 2, DatabaseFixes.UpdateAllStats), new(75, 1, DatabaseFixes.RemoveBasePathsFromStaffAndCharacters), new(76, 1, @@ -605,20 +610,20 @@ public class MySQL : BaseDatabase<MySqlConnection> new(76, 2, "ALTER TABLE `AniDB_AnimeUpdate` ADD INDEX `UIX_AniDB_AnimeUpdate` (`AnimeID` ASC) ;"), new(76, 3, DatabaseFixes.MigrateAniDB_AnimeUpdates), new(77, 1, DatabaseFixes.RemoveBasePathsFromStaffAndCharacters), - new(78, 1, DatabaseFixes.FixDuplicateTagFiltersAndUpdateSeasons), - new(79, 1, DatabaseFixes.RecalculateYears), + new(78, 1, DatabaseFixes.NoOperation), + new(79, 1, DatabaseFixes.NoOperation), new(80, 1, "ALTER TABLE `CrossRef_AniDB_MAL` DROP INDEX `UIX_CrossRef_AniDB_MAL_Anime` ;"), new(80, 2, "ALTER TABLE `AniDB_Anime` ADD ( `Site_JP` text character set utf8 null, `Site_EN` text character set utf8 null, `Wikipedia_ID` text character set utf8 null, `WikipediaJP_ID` text character set utf8 null, `SyoboiID` INT NULL, `AnisonID` INT NULL, `CrunchyrollID` text character set utf8 null );"), - new(80, 3, DatabaseFixes.PopulateResourceLinks), + new(80, 3, DatabaseFixes.NoOperation), new(81, 1, "ALTER TABLE `VideoLocal` ADD `MyListID` INT NOT NULL DEFAULT 0"), - new(81, 2, DatabaseFixes.PopulateMyListIDs), + new(81, 2, DatabaseFixes.NoOperation), new(82, 1, MySQLFixUTF8), new(83, 1, "ALTER TABLE `AniDB_Episode` DROP COLUMN `EnglishName`"), new(83, 2, "ALTER TABLE `AniDB_Episode` DROP COLUMN `RomajiName`"), new(83, 3, "CREATE TABLE `AniDB_Episode_Title` ( `AniDB_Episode_TitleID` INT NOT NULL AUTO_INCREMENT, `AniDB_EpisodeID` int NOT NULL, `Language` varchar(50) character set utf8 NOT NULL, `Title` varchar(500) character set utf8 NOT NULL, PRIMARY KEY (`AniDB_Episode_TitleID`) ) ; "), - new(83, 4, DatabaseFixes.DummyMigrationOfObsolescence), + new(83, 4, DatabaseFixes.NoOperation), new(84, 1, "ALTER TABLE `CrossRef_AniDB_TvDB_Episode` DROP INDEX `UIX_CrossRef_AniDB_TvDB_Episode_AniDBEpisodeID`;"), new(84, 2, "RENAME TABLE `CrossRef_AniDB_TvDB_Episode` TO `CrossRef_AniDB_TvDB_Episode_Override`;"), @@ -637,13 +642,13 @@ public class MySQL : BaseDatabase<MySqlConnection> "CREATE TABLE `CrossRef_AniDB_TvDB_Episode` ( `CrossRef_AniDB_TvDB_EpisodeID` INT NOT NULL AUTO_INCREMENT, `AniDBEpisodeID` int NOT NULL, `TvDBEpisodeID` int NOT NULL, `MatchRating` INT NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TvDB_EpisodeID`) );"), new(84, 10, "ALTER TABLE `CrossRef_AniDB_TvDB_Episode` ADD UNIQUE INDEX `UIX_CrossRef_AniDB_TvDB_Episode_AniDBID_TvDBID` ( `AniDBEpisodeID` ASC, `TvDBEpisodeID` ASC);"), - new(84, 11, DatabaseFixes.MigrateTvDBLinks_v2_to_V3), + new(84, 11, DatabaseFixes.NoOperation), // DatabaseFixes.MigrateTvDBLinks_v2_to_V3() drops the CrossRef_AniDB_TvDBV2 table. We do it after init to migrate - new(85, 1, DatabaseFixes.FixAniDB_EpisodesWithMissingTitles), - new(86, 1, DatabaseFixes.RegenTvDBMatches), + new(85, 1, DatabaseFixes.NoOperation), + new(86, 1, DatabaseFixes.NoOperation), new(87, 1, "ALTER TABLE `AniDB_File` CHANGE COLUMN `File_AudioCodec` `File_AudioCodec` VARCHAR(500) NOT NULL;"), new(88, 1, "ALTER TABLE `AnimeSeries` ADD `UpdatedAt` datetime NOT NULL DEFAULT '2000-01-01 00:00:00';"), - new(89, 1, DatabaseFixes.MigrateAniDBToNet), + new(89, 1, DatabaseFixes.NoOperation), new(90, 1, "ALTER TABLE VideoLocal DROP COLUMN VideoCodec, DROP COLUMN VideoBitrate, DROP COLUMN VideoFrameRate, DROP COLUMN VideoResolution, DROP COLUMN AudioCodec, DROP COLUMN AudioBitrate, DROP COLUMN Duration;"), new(91, 1, DropMALIndex), @@ -760,7 +765,90 @@ public class MySQL : BaseDatabase<MySqlConnection> new(126, 1, "ALTER TABLE AniDB_Anime DROP COLUMN ContractVersion;ALTER TABLE AniDB_Anime DROP COLUMN ContractBlob;ALTER TABLE AniDB_Anime DROP COLUMN ContractSize;"), new(126, 2, "ALTER TABLE AnimeSeries DROP COLUMN ContractVersion;ALTER TABLE AnimeSeries DROP COLUMN ContractBlob;ALTER TABLE AnimeSeries DROP COLUMN ContractSize;"), new(126, 3, "ALTER TABLE AnimeGroup DROP COLUMN ContractVersion;ALTER TABLE AnimeGroup DROP COLUMN ContractBlob;ALTER TABLE AnimeGroup DROP COLUMN ContractSize;"), - new (127, 1, "ALTER TABLE VideoLocal DROP COLUMN MediaSize;"), + new(127, 1, "ALTER TABLE VideoLocal DROP COLUMN MediaSize;"), + new(128, 1, "CREATE TABLE `AniDB_NotifyQueue` ( `AniDB_NotifyQueueID` INT NOT NULL AUTO_INCREMENT, `Type` int NOT NULL, `ID` int NOT NULL, `AddedAt` datetime NOT NULL, PRIMARY KEY (`AniDB_NotifyQueueID`) ) ; "), + new(128, 2, "CREATE TABLE `AniDB_Message` ( `AniDB_MessageID` INT NOT NULL AUTO_INCREMENT, `MessageID` int NOT NULL, `FromUserID` int NOT NULL, `FromUserName` varchar(100) character set utf8 NOT NULL, `SentAt` datetime NOT NULL, `FetchedAt` datetime NOT NULL, `Type` int NOT NULL, `Title` text character set utf8 NOT NULL, `Body` text character set utf8 NOT NULL, `Flags` int NOT NULL DEFAULT 0, PRIMARY KEY (`AniDB_MessageID`) ) ;"), + new(129, 1, "CREATE TABLE `CrossRef_AniDB_TMDB_Episode` ( `CrossRef_AniDB_TMDB_EpisodeID` INT NOT NULL AUTO_INCREMENT, `AnidbAnimeID` INT NOT NULL, `AnidbEpisodeID` INT NOT NULL, `TmdbShowID` INT NOT NULL, `TmdbEpisodeID` INT NOT NULL, `Ordering` INT NOT NULL, `MatchRating` INT NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TMDB_EpisodeID`) );"), + new(129, 2, "CREATE TABLE `CrossRef_AniDB_TMDB_Movie` ( `CrossRef_AniDB_TMDB_MovieID` INT NOT NULL AUTO_INCREMENT, `AnidbAnimeID` INT NOT NULL, `AnidbEpisodeID` INT NULL, `TmdbMovieID` INT NOT NULL, `Source` INT NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TMDB_MovieID`) );"), + new(129, 3, "CREATE TABLE `CrossRef_AniDB_TMDB_Show` ( `CrossRef_AniDB_TMDB_ShowID` INT NOT NULL AUTO_INCREMENT, `AnidbAnimeID` INT NOT NULL, `TmdbShowID` INT NOT NULL, `Source` INT NOT NULL, PRIMARY KEY (`CrossRef_AniDB_TMDB_ShowID`) );"), + new(129, 4, "CREATE TABLE `TMDB_Image` ( `TMDB_ImageID` INT NOT NULL AUTO_INCREMENT, `TmdbMovieID` INT NULL, `TmdbEpisodeID` INT NULL, `TmdbSeasonID` INT NULL, `TmdbShowID` INT NULL, `TmdbCollectionID` INT NULL, `TmdbNetworkID` INT NULL, `TmdbCompanyID` INT NULL, `TmdbPersonID` INT NULL, `ForeignType` INT NOT NULL, `ImageType` INT NOT NULL, `IsEnabled` INT NOT NULL, `Width` INT NOT NULL, `Height` INT NOT NULL, `Language` VARCHAR(32) CHARACTER SET UTF8 NOT NULL, `RemoteFileName` VARCHAR(128) CHARACTER SET UTF8 NOT NULL, `UserRating` decimal(6,2) NOT NULL, `UserVotes` INT NOT NULL, PRIMARY KEY (`TMDB_ImageID`) );"), + new(129, 5, "CREATE TABLE `AniDB_Anime_PreferredImage` ( `AniDB_Anime_PreferredImageID` INT NOT NULL AUTO_INCREMENT, `AnidbAnimeID` INT NOT NULL, `ImageID` INT NOT NULL, `ImageType` INT NOT NULL, `ImageSource` INT NOT NULL, PRIMARY KEY (`AniDB_Anime_PreferredImageID`) );"), + new(129, 6, "CREATE TABLE `TMDB_Title` ( `TMDB_TitleID` INT NOT NULL AUTO_INCREMENT, `ParentID` INT NOT NULL, `ParentType` INT NOT NULL, `LanguageCode` VARCHAR(5) CHARACTER SET UTF8 NOT NULL, `CountryCode` VARCHAR(5) CHARACTER SET UTF8 NOT NULL, `Value` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, PRIMARY KEY (`TMDB_TitleID`) );"), + new(129, 7, "CREATE TABLE `TMDB_Overview` ( `TMDB_OverviewID` INT NOT NULL AUTO_INCREMENT, `ParentID` INT NOT NULL, `ParentType` INT NOT NULL, `LanguageCode` VARCHAR(5) CHARACTER SET UTF8 NOT NULL, `CountryCode` VARCHAR(5) CHARACTER SET UTF8 NOT NULL, `Value` TEXT CHARACTER SET UTF8 NOT NULL, PRIMARY KEY (`TMDB_OverviewID`) );"), + new(129, 8, "CREATE TABLE `TMDB_Company` ( `TMDB_CompanyID` INT NOT NULL AUTO_INCREMENT, `TmdbCompanyID` INT NOT NULL, `Name` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `CountryOfOrigin` VARCHAR(3) CHARACTER SET UTF8 NOT NULL, PRIMARY KEY (`TMDB_CompanyID`) );"), + new(129, 9, "CREATE TABLE `TMDB_Network` ( `TMDB_NetworkID` INT NOT NULL AUTO_INCREMENT, `TmdbNetworkID` INT NOT NULL, `Name` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `CountryOfOrigin` VARCHAR(3) CHARACTER SET UTF8 NOT NULL, PRIMARY KEY (`TMDB_NetworkID`) );"), + new(129, 10, "CREATE TABLE `TMDB_Person` ( `TMDB_PersonID` INT NOT NULL AUTO_INCREMENT, `TmdbPersonID` INT NOT NULL, `EnglishName` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `EnglishBiography` TEXT CHARACTER SET UTF8 NOT NULL, `Aliases` TEXT CHARACTER SET UTF8 NOT NULL, `Gender` INT NOT NULL, `IsRestricted` BIT NOT NULL, `BirthDay` DATE NULL, `DeathDay` DATE NULL, `PlaceOfBirth` VARCHAR(128) CHARACTER SET UTF8 NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_PersonID`) );"), + new(129, 11, "CREATE TABLE `TMDB_Movie` ( `TMDB_MovieID` INT NOT NULL AUTO_INCREMENT, `TmdbMovieID` INT NOT NULL, `TmdbCollectionID` INT NULL, `EnglishTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `EnglishOverview` TEXT CHARACTER SET UTF8 NOT NULL, `OriginalTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `OriginalLanguageCode` VARCHAR(5) CHARACTER SET UTF8 NOT NULL, `IsRestricted` BIT NOT NULL, `IsVideo` BIT NOT NULL, `Genres` VARCHAR(128) CHARACTER SET UTF8 NOT NULL, `ContentRatings` VARCHAR(128) CHARACTER SET UTF8 NOT NULL, `Runtime` INT NULL, `UserRating` decimal(6,2) NOT NULL, `UserVotes` INT NOT NULL, `ReleasedAt` DATE NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_MovieID`) );"), + new(129, 12, "CREATE TABLE `TMDB_Movie_Cast` ( `TMDB_Movie_CastID` INT NOT NULL AUTO_INCREMENT, `TmdbMovieID` INT NOT NULL, `TmdbPersonID` INT NOT NULL, `TmdbCreditID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `CharacterName` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `Ordering` INT NOT NULL, PRIMARY KEY (`TMDB_Movie_CastID`) );"), + new(129, 13, "CREATE TABLE `TMDB_Company_Entity` ( `TMDB_Company_EntityID` INT NOT NULL AUTO_INCREMENT, `TmdbCompanyID` INT NOT NULL, `TmdbEntityType` INT NOT NULL, `TmdbEntityID` INT NOT NULL, `Ordering` INT NOT NULL, `ReleasedAt` DATE NULL, PRIMARY KEY (`TMDB_Company_EntityID`) );"), + new(129, 14, "CREATE TABLE `TMDB_Movie_Crew` ( `TMDB_Movie_CrewID` INT NOT NULL AUTO_INCREMENT, `TmdbMovieID` INT NOT NULL, `TmdbPersonID` INT NOT NULL, `TmdbCreditID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `Job` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `Department` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, PRIMARY KEY (`TMDB_Movie_CrewID`) );"), + new(129, 15, "CREATE TABLE `TMDB_Show` ( `TMDB_ShowID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `EnglishTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `EnglishOverview` TEXT CHARACTER SET UTF8 NOT NULL, `OriginalTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `OriginalLanguageCode` VARCHAR(5) CHARACTER SET UTF8 NOT NULL, `IsRestricted` BIT NOT NULL, `Genres` VARCHAR(128) CHARACTER SET UTF8 NOT NULL, `ContentRatings` VARCHAR(128) CHARACTER SET UTF8 NOT NULL, `EpisodeCount` INT NOT NULL, `SeasonCount` INT NOT NULL, `AlternateOrderingCount` INT NOT NULL, `UserRating` decimal(6,2) NOT NULL, `UserVotes` INT NOT NULL, `FirstAiredAt` DATE, `LastAiredAt` DATE NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_ShowID`) );"), + new(129, 16, "CREATE TABLE `Tmdb_Show_Network` ( `TMDB_Show_NetworkID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbNetworkID` INT NOT NULL, `Ordering` INT NOT NULL, PRIMARY KEY (`TMDB_Show_NetworkID`) );"), + new(129, 17, "CREATE TABLE `TMDB_Season` ( `TMDB_SeasonID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbSeasonID` INT NOT NULL, `EnglishTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `EnglishOverview` TEXT CHARACTER SET UTF8 NOT NULL, `EpisodeCount` INT NOT NULL, `SeasonNumber` INT NOT NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_SeasonID`) );"), + new(129, 18, "CREATE TABLE `TMDB_Episode` ( `TMDB_EpisodeID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbSeasonID` INT NOT NULL, `TmdbEpisodeID` INT NOT NULL, `EnglishTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, EnglishOverview TEXT CHARACTER SET UTF8 NOT NULL, `SeasonNumber` INT NOT NULL, `EpisodeNumber` INT NOT NULL, `Runtime` INT NULL, `UserRating` decimal(6,2) NOT NULL, `UserVotes` INT NOT NULL, `AiredAt` DATE NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_EpisodeID`) );"), + new(129, 19, "CREATE TABLE `TMDB_Episode_Cast` ( `TMDB_Episode_CastID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbSeasonID` INT NOT NULL, `TmdbEpisodeID` INT NOT NULL, `TmdbPersonID` INT NOT NULL, `TmdbCreditID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `CharacterName` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `IsGuestRole` BIT NOT NULL, `Ordering` INT NOT NULL, PRIMARY KEY (`TMDB_Episode_CastID`) );"), + new(129, 20, "CREATE TABLE `TMDB_Episode_Crew` ( `TMDB_Episode_CrewID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbSeasonID` INT NOT NULL, `TmdbEpisodeID` INT NOT NULL, `TmdbPersonID` INT NOT NULL, `TmdbCreditID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `Job` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `Department` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, PRIMARY KEY (`TMDB_Episode_CrewID`) );"), + new(129, 21, "CREATE TABLE `TMDB_AlternateOrdering` ( `TMDB_AlternateOrderingID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbNetworkID` INT NULL, `TmdbEpisodeGroupCollectionID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `EnglishTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `EnglishOverview` TEXT CHARACTER SET UTF8 NOT NULL, `EpisodeCount` INT NOT NULL, `SeasonCount` INT NOT NULL, `Type` INT NOT NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_AlternateOrderingID`) );"), + new(129, 22, "CREATE TABLE `TMDB_AlternateOrdering_Season` ( `TMDB_AlternateOrdering_SeasonID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbEpisodeGroupCollectionID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `TmdbEpisodeGroupID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `EnglishTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `SeasonNumber` INT NOT NULL, `EpisodeCount` INT NOT NULL, `IsLocked` BIT NOT NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_AlternateOrdering_SeasonID`) );"), + new(129, 23, "CREATE TABLE `TMDB_AlternateOrdering_Episode` ( `TMDB_AlternateOrdering_EpisodeID` INT NOT NULL AUTO_INCREMENT, `TmdbShowID` INT NOT NULL, `TmdbEpisodeGroupCollectionID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `TmdbEpisodeGroupID` VARCHAR(64) CHARACTER SET UTF8 NOT NULL, `TmdbEpisodeID` INT NOT NULL, `SeasonNumber` INT NOT NULL, `EpisodeNumber` INT NOT NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_AlternateOrdering_EpisodeID`) );"), + new(129, 24, "CREATE TABLE `TMDB_Collection` ( `TMDB_CollectionID` INT NOT NULL AUTO_INCREMENT, `TmdbCollectionID` INT NOT NULL, `EnglishTitle` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `EnglishOverview` TEXT CHARACTER SET UTF8 NOT NULL, `MovieCount` INT NOT NULL, `CreatedAt` DATETIME NOT NULL, `LastUpdatedAt` DATETIME NOT NULL, PRIMARY KEY (`TMDB_CollectionID`) );"), + new(129, 25, "CREATE TABLE `TMDB_Collection_Movie` ( `TMDB_Collection_MovieID` INT NOT NULL AUTO_INCREMENT, `TmdbCollectionID` INT NOT NULL, `TmdbMovieID` INT NOT NULL, `Ordering` INT NOT NULL, PRIMARY KEY (`TMDB_Collection_MovieID`) );"), + new(129, 26, "INSERT INTO `CrossRef_AniDB_TMDB_Movie` ( AnidbAnimeID, TmdbMovieID, Source ) SELECT AnimeID, CrossRefID, CrossRefSource FROM `CrossRef_AniDB_Other` WHERE CrossRefType = 1;"), + new(129, 27, "DROP TABLE `CrossRef_AniDB_Other`;"), + new(129, 28, "DROP TABLE `MovieDB_Fanart`;"), + new(129, 29, "DROP TABLE `MovieDB_Movie`;"), + new(129, 30, "DROP TABLE `MovieDB_Poster`;"), + new(129, 31, "DROP TABLE `AniDB_Anime_DefaultImage`;"), + new(129, 32, "CREATE TABLE `AniDB_Episode_PreferredImage` ( `AniDB_Episode_PreferredImageID` INT NOT NULL AUTO_INCREMENT, `AnidbAnimeID` INT NOT NULL, `AnidbEpisodeID` INT NOT NULL, `ImageID` INT NOT NULL, `ImageType` INT NOT NULL, `ImageSource` INT NOT NULL, PRIMARY KEY (`AniDB_Episode_PreferredImageID`) );"), + new(129, 33, DatabaseFixes.CleanupAfterAddingTMDB), + new(129, 34, "UPDATE FilterPreset SET Expression = REPLACE(Expression, 'HasTMDbLinkExpression', 'HasTmdbLinkExpression');"), + new(129, 35, "SET @exist_Check := (SELECT count(1) FROM information_schema.columns WHERE TABLE_NAME='TMDB_Movie' AND COLUMN_NAME='EnglishOvervie' AND TABLE_SCHEMA=database()) ; SET @sqlstmt := IF(@exist_Check>0,'ALTER TABLE TMDB_Movie CHANGE COLUMN `EnglishOvervie` `EnglishOverview` TEXT CHARACTER SET UTF8 NOT NULL', 'SELECT ''''') ; PREPARE stmt FROM @sqlstmt ; EXECUTE stmt ;"), + new(129, 36, "UPDATE `TMDB_Image` SET `IsEnabled` = 1;"), + new(130, 1, MigrateRenamers), + new(131, 1, "DELETE FROM RenamerInstance WHERE NAME = 'AAA_WORKINGFILE_TEMP_AAA';"), + new(131, 2, DatabaseFixes.CreateDefaultRenamerConfig), + new(132, 1, "ALTER TABLE `TMDB_Show` ADD COLUMN `TvdbShowID` INT NULL DEFAULT NULL;"), + new(132, 2, "ALTER TABLE `TMDB_Episode` ADD COLUMN `TvdbEpisodeID` INT NULL DEFAULT NULL;"), + new(132, 3, "ALTER TABLE `TMDB_Movie` ADD COLUMN `ImdbMovieID` INT NULL DEFAULT NULL;"), + new(132, 4, "ALTER TABLE `TMDB_Movie` DROP COLUMN `ImdbMovieID`;"), + new(132, 5, "ALTER TABLE `TMDB_Movie` ADD COLUMN `ImdbMovieID` VARCHAR(12) NULL DEFAULT NULL;"), + new(132, 6, "ALTER TABLE `TMDB_Overview` ADD INDEX `IX_TMDB_Overview` (ParentType, ParentID)"), + new(132, 7, "ALTER TABLE `TMDB_Title` ADD INDEX `IX_TMDB_Title` (ParentType, ParentID)"), + new(132, 8, "ALTER TABLE `TMDB_Episode` ADD UNIQUE INDEX `UIX_TMDB_Episode_TmdbEpisodeID` (TmdbEpisodeID)"), + new(132, 9, "ALTER TABLE `TMDB_Show` ADD UNIQUE INDEX `UIX_TMDB_Show_TmdbShowID` (TmdbShowID)"), + new(133, 1, "UPDATE CrossRef_AniDB_TMDB_Movie SET AnidbEpisodeID = (SELECT EpisodeID FROM AniDB_Episode WHERE AniDB_Episode.AnimeID = CrossRef_AniDB_TMDB_Movie.AnidbAnimeID ORDER BY EpisodeType, EpisodeNumber LIMIT 1) WHERE AnidbEpisodeID IS NULL AND EXISTS (SELECT 1 FROM AniDB_Episode WHERE AniDB_Episode.AnimeID = CrossRef_AniDB_TMDB_Movie.AnidbAnimeID);"), + new(133, 2, "DELETE FROM CrossRef_AniDB_TMDB_Movie WHERE AnidbEpisodeID IS NULL;"), + new(133, 3, "ALTER TABLE CrossRef_AniDB_TMDB_Movie CHANGE COLUMN AnidbEpisodeID AnidbEpisodeID INT NOT NULL DEFAULT 0;"), + new(134, 1, "ALTER TABLE `TMDB_Movie` ADD COLUMN `PosterPath` VARCHAR(64) NULL DEFAULT NULL;"), + new(134, 2, "ALTER TABLE `TMDB_Movie` ADD COLUMN `BackdropPath` VARCHAR(64) NULL DEFAULT NULL;"), + new(134, 3, "ALTER TABLE `TMDB_Show` ADD COLUMN `PosterPath` VARCHAR(64) NULL DEFAULT NULL;"), + new(134, 4, "ALTER TABLE `TMDB_Show` ADD COLUMN `BackdropPath` VARCHAR(64) NULL DEFAULT NULL;"), + new(135, 1, "UPDATE FilterPreset SET Expression = REPLACE(Expression, 'MissingTMDbLinkExpression', 'MissingTmdbLinkExpression');"), + new(136, 1, "CREATE TABLE `AniDB_Creator` (`AniDB_CreatorID` INT NOT NULL AUTO_INCREMENT, `CreatorID` INT NOT NULL, `Name` VARCHAR(512) CHARACTER SET UTF8 NOT NULL, `OriginalName` VARCHAR(512) CHARACTER SET UTF8 NULL, `Type` INT NOT NULL DEFAULT 0, `ImagePath` VARCHAR(512) CHARACTER SET UTF8 NULL, `EnglishHomepageUrl` VARCHAR(512) CHARACTER SET UTF8 NULL, `JapaneseHomepageUrl` VARCHAR(512) CHARACTER SET UTF8 NULL, `EnglishWikiUrl` VARCHAR(512) CHARACTER SET UTF8 NULL, `JapaneseWikiUrl` VARCHAR(512) CHARACTER SET UTF8 NULL, `LastUpdatedAt` DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (`AniDB_CreatorID`) );"), + new(136, 2, "CREATE TABLE `AniDB_Character_Creator` (`AniDB_Character_CreatorID` INT NOT NULL AUTO_INCREMENT, `CharacterID` INT NOT NULL, `CreatorID` INT NOT NULL, PRIMARY KEY (`AniDB_Character_CreatorID`) );"), + new(136, 3, "CREATE UNIQUE INDEX `UIX_AniDB_Creator_CreatorID` ON `AniDB_Creator`(`CreatorID`);"), + new(136, 4, "CREATE INDEX `UIX_AniDB_Character_Creator_CreatorID` ON `AniDB_Character_Creator`(`CreatorID`);"), + new(136, 5, "CREATE INDEX `UIX_AniDB_Character_Creator_CharacterID` ON `AniDB_Character_Creator`(`CharacterID`);"), + new(136, 6, "CREATE UNIQUE INDEX `UIX_AniDB_Character_Creator_CharacterID_CreatorID` ON `AniDB_Character_Creator`(`CharacterID`, `CreatorID`);"), + new(136, 7, "INSERT INTO `AniDB_Creator` (`CreatorID`, `Name`, `ImagePath`) SELECT `SeiyuuID`, `SeiyuuName`, `PicName` FROM `AniDB_Seiyuu`;"), + new(136, 8, "INSERT INTO `AniDB_Character_Creator` (`CharacterID`, `CreatorID`) SELECT `CharID`, `SeiyuuID` FROM `AniDB_Character_Seiyuu`;"), + new(136, 9, "DROP TABLE IF EXISTS `AniDB_Seiyuu`"), + new(136, 10, "DROP TABLE IF EXISTS `AniDB_Character_Seiyuu`"), + new(137, 1, "ALTER TABLE `TMDB_Show` ADD COLUMN `PreferredAlternateOrderingID` VARCHAR(64) CHARACTER SET UTF8 NULL DEFAULT NULL;"), + new(138, 1, "ALTER TABLE `TMDB_Show` CHANGE COLUMN `ContentRatings` `ContentRatings` VARCHAR(512) CHARACTER SET UTF8 NOT NULL"), + new(138, 2, "ALTER TABLE `TMDB_Movie` CHANGE COLUMN `ContentRatings` `ContentRatings` VARCHAR(512) CHARACTER SET UTF8 NOT NULL"), + new(139, 1, "DROP TABLE TvDB_Episode;"), + new(139, 2, "DROP TABLE TvDB_Series;"), + new(139, 3, "DROP TABLE TvDB_ImageFanart;"), + new(139, 4, "DROP TABLE TvDB_ImagePoster;"), + new(139, 5, "DROP TABLE TvDB_ImageWideBanner;"), + new(139, 6, "DROP TABLE CrossRef_AniDB_TvDB;"), + new(139, 7, "DROP TABLE CrossRef_AniDB_TvDB_Episode;"), + new(139, 8, "DROP TABLE CrossRef_AniDB_TvDB_Episode_Override;"), + new(139, 9, "ALTER TABLE Trakt_Show DROP COLUMN TvDB_ID;"), + new(139, 10, "ALTER TABLE Trakt_Show ADD COLUMN TmdbShowID INT NULL;"), + new(139, 11, DatabaseFixes.CleanupAfterRemovingTvDB), + new(139, 12, DatabaseFixes.ClearQuartzQueue), }; private DatabaseCommand linuxTableVersionsFix = new("RENAME TABLE versions TO Versions;"); @@ -873,6 +961,128 @@ public override void BackupDatabase(string fullfilename) } } + private static Tuple<bool, string> MigrateRenamers(object connection) + { + var factory = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().Instance; + var renamerService = Utils.ServiceContainer.GetRequiredService<RenameFileService>(); + var settingsProvider = Utils.SettingsProvider; + + var sessionFactory = factory.CreateSessionFactory(); + using var session = sessionFactory.OpenSession(); + using var transaction = session.BeginTransaction(); + try + { + const string createCommand = """ + CREATE TABLE IF NOT EXISTS RenamerInstance (ID INT NOT NULL AUTO_INCREMENT, Name text NOT NULL, Type text NOT NULL, Settings mediumblob, PRIMARY KEY (ID)); + ALTER TABLE RenamerInstance ADD INDEX IX_RenamerInstance_Name (Name(255)); + ALTER TABLE RenamerInstance ADD INDEX IX_RenamerInstance_Type (Type(255)); + """; + + session.CreateSQLQuery(createCommand).ExecuteUpdate(); + + const string selectCommand = "SELECT ScriptName, RenamerType, IsEnabledOnImport, Script FROM RenameScript;"; + var reader = session.CreateSQLQuery(selectCommand) + .AddScalar("ScriptName", NHibernateUtil.String) + .AddScalar("RenamerType", NHibernateUtil.String) + .AddScalar("IsEnabledOnImport", NHibernateUtil.Int32) + .AddScalar("Script", NHibernateUtil.String) + .List<object[]>(); + string defaultName = null; + var renamerInstances = reader.Select(a => + { + try + { + var type = ((string)a[1]).Equals("Legacy") + ? typeof(WebAOMRenamer) + : renamerService.RenamersByKey.ContainsKey((string)a[1]) + ? renamerService.RenamersByKey[(string)a[1]].GetType() + : Type.GetType((string)a[1]); + if (type == null) + { + if ((string)a[1] == "GroupAwareRenamer") + return (Renamer: new RenamerConfig + { + Name = (string)a[0], + Type = typeof(WebAOMRenamer), + Settings = new WebAOMSettings + { + Script = (string)a[3], GroupAwareSorting = true + } + }, IsDefault: (int)a[2] == 1); + + Logger.Warn("A RenameScipt could not be converted to RenamerConfig. Renamer name: " + (string)a[0] + " Renamer type: " + (string)a[1] + + " Script: " + (string)a[3]); + return default; + } + + var settingsType = type.GetInterfaces().FirstOrDefault(b => b.IsGenericType && b.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetGenericArguments().FirstOrDefault(); + object settings = null; + if (settingsType != null) + { + settings = ActivatorUtilities.CreateInstance(Utils.ServiceContainer, settingsType); + settingsType.GetProperties(BindingFlags.Instance | BindingFlags.Public).FirstOrDefault(b => b.Name == "Script") + ?.SetValue(settings, (string)a[3]); + } + + return (Renamer: new RenamerConfig + { + Name = (string)a[0], Type = type, Settings = settings + }, IsDefault: (int)a[2] == 1); + } + catch (Exception ex) + { + if (a is { Length: >= 4 }) + { + Logger.Warn(ex, "A RenameScipt could not be converted to RenamerConfig. Renamer name: " + a[0] + " Renamer type: " + a[1] + + " Script: " + a[3]); + } + else + { + Logger.Warn(ex, "A RenameScipt could not be converted to RenamerConfig, but there wasn't enough data to log"); + } + + return default; + } + }).WhereNotDefault().GroupBy(a => a.Renamer.Name).SelectMany(a => a.Select((b, i) => + { + // Names are distinct + var renamer = b.Renamer; + if (i > 0) renamer.Name = renamer.Name + "_" + (i + 1); + if (b.IsDefault) defaultName = renamer.Name; + return renamer; + })); + + if (defaultName != null) + { + var settings = settingsProvider.GetSettings(); + settings.Plugins.Renamer.DefaultRenamer = defaultName; + settingsProvider.SaveSettings(settings); + } + + const string insertCommand = "INSERT INTO RenamerInstance (Name, Type, Settings) VALUES (:Name, :Type, :Settings);"; + foreach (var renamer in renamerInstances) + { + var command = session.CreateSQLQuery(insertCommand); + command.SetParameter("Name", renamer.Name); + command.SetParameter("Type", renamer.Type.ToString()); + command.SetParameter("Settings", renamer.Settings == null ? null : MessagePackSerializer.Typeless.Serialize(renamer.Settings)); + command.ExecuteUpdate(); + } + + const string dropCommand = "DROP TABLE RenameScript;"; + session.CreateSQLQuery(dropCommand).ExecuteUpdate(); + transaction.Commit(); + } + catch (Exception e) + { + transaction.Rollback(); + return new Tuple<bool, string>(false, e.ToString()); + } + + return new Tuple<bool, string>(true, null); + } + public static void DropMALIndex() { MySQL mysql = new(); @@ -966,12 +1176,12 @@ protected override long ExecuteScalar(MySqlConnection connection, string command } } - protected override List<object> ExecuteReader(MySqlConnection connection, string command) + protected override List<object[]> ExecuteReader(MySqlConnection connection, string command) { using var cmd = new MySqlCommand(command, connection); cmd.CommandTimeout = 0; using var reader = cmd.ExecuteReader(); - var rows = new List<object>(); + var rows = new List<object[]>(); while (reader.Read()) { var values = new object[reader.FieldCount]; diff --git a/Shoko.Server/Databases/NHIbernate/DateOnlyConverter.cs b/Shoko.Server/Databases/NHIbernate/DateOnlyConverter.cs new file mode 100644 index 000000000..f7300caeb --- /dev/null +++ b/Shoko.Server/Databases/NHIbernate/DateOnlyConverter.cs @@ -0,0 +1,115 @@ +using System; +using System.ComponentModel; +using NHibernate.SqlTypes; +using NHibernate.UserTypes; +using System.Data; +using System.Data.Common; +using NHibernate; +using NHibernate.Engine; +using System.Globalization; +using System.Collections; + +#nullable enable +namespace Shoko.Server.Databases.NHibernate; + +public class DateOnlyConverter : TypeConverter, IUserType +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type? sourceType) + => sourceType?.FullName switch + { + "System.DateOnly" => true, + "System.DateTime" => true, + "System.String" => true, + "System.Int32" => true, + "System.Int64" => true, + _ => false + }; + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + => destinationType?.FullName switch + { + "System.DateTime" => true, + "System.String" => true, + "System.Int32" => true, + "System.Int64" => true, + _ => false, + }; + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value) + => value switch + { + DateOnly i => i, + DateTime i => DateOnly.FromDateTime(i), + int i => DateOnly.FromDateTime(new(i)), + long i => DateOnly.FromDateTime(new(i)), + string i => DateOnly.FromDateTime(DateTime.Parse(i)), + null => null, + _ => throw new ArgumentException("DestinationType must be System.DateOnly.") + }; + + public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type? destinationType) + => destinationType?.FullName switch + { + "System.Int32" => value switch + { + DateOnly i => (int)i.ToDateTime(TimeOnly.MinValue).Ticks, + _ => null, + }, + "System.Int64" => value switch + { + DateOnly i => (long)i.ToDateTime(TimeOnly.MinValue).Ticks, + _ => null, + }, + "System.String" => value switch + { + DateOnly i => i.ToLongDateString(), + _ => null, + }, + "System.DateTime" => value switch + { + DateOnly i => i.ToDateTime(TimeOnly.MinValue), + _ => null, + }, + _ => throw new ArgumentException("DestinationType must be System.Int32, System.Int64, System.String, or System.DateTime."), + }; + + public override object CreateInstance(ITypeDescriptorContext? context, IDictionary? propertyValues) + => true; + + #region IUserType Members + + public object Assemble(object cached, object owner) + => DeepCopy(cached); + + public object DeepCopy(object value) + => value; + + public object Disassemble(object value) + => DeepCopy(value); + + public int GetHashCode(object x) + => x == null ? base.GetHashCode() : x.GetHashCode(); + + public bool IsMutable + => true; + + public object? NullSafeGet(DbDataReader rs, string[] names, ISessionImplementor impl, object owner) + => ConvertFrom(null, null, NHibernateUtil.String.NullSafeGet(rs, names[0], impl)); + + public void NullSafeSet(DbCommand cmd, object value, int index, ISessionImplementor session) + => ((IDataParameter)cmd.Parameters[index]).Value = value == null ? DBNull.Value : ConvertTo(null, null, value, typeof(DateTime)); + + public object Replace(object original, object target, object owner) + => original; + + public Type ReturnedType + => typeof(DateTime); + + public SqlType[] SqlTypes + => new[] { NHibernateUtil.Date.SqlType }; + + bool IUserType.Equals(object x, object y) + => ReferenceEquals(x, y) || (x != null && y != null && x.Equals(y)); + + #endregion +} diff --git a/Shoko.Server/Databases/NHIbernate/FilterExpressionConverter.cs b/Shoko.Server/Databases/NHIbernate/FilterExpressionConverter.cs index 1e5803ac6..bf10594b3 100644 --- a/Shoko.Server/Databases/NHIbernate/FilterExpressionConverter.cs +++ b/Shoko.Server/Databases/NHIbernate/FilterExpressionConverter.cs @@ -11,7 +11,7 @@ using NLog; using Shoko.Server.Filters; -namespace Shoko.Server.Databases.NHIbernate; +namespace Shoko.Server.Databases.NHibernate; public class FilterExpressionConverter : TypeConverter, IUserType { diff --git a/Shoko.Server/Databases/NHIbernate/NHibernateDependencyInjector.cs b/Shoko.Server/Databases/NHIbernate/NHibernateDependencyInjector.cs index 80e79a3d1..dc4eb7622 100644 --- a/Shoko.Server/Databases/NHIbernate/NHibernateDependencyInjector.cs +++ b/Shoko.Server/Databases/NHIbernate/NHibernateDependencyInjector.cs @@ -5,7 +5,7 @@ using NHibernate; using NHibernate.Type; -namespace Shoko.Server.Databases.NHIbernate; +namespace Shoko.Server.Databases.NHibernate; public class NHibernateDependencyInjector : EmptyInterceptor { diff --git a/Shoko.Server/Databases/NHIbernate/NLogInterceptor.cs b/Shoko.Server/Databases/NHIbernate/NLogInterceptor.cs index 92b8138f7..488061466 100644 --- a/Shoko.Server/Databases/NHIbernate/NLogInterceptor.cs +++ b/Shoko.Server/Databases/NHIbernate/NLogInterceptor.cs @@ -2,7 +2,7 @@ using NHibernate.SqlCommand; using NLog; -namespace Shoko.Server.Databases.NHIbernate; +namespace Shoko.Server.Databases.NHibernate; public class NLogInterceptor : EmptyInterceptor { diff --git a/Shoko.Server/Databases/NHIbernate/SimpleNameSerializationBinder.cs b/Shoko.Server/Databases/NHIbernate/SimpleNameSerializationBinder.cs index a9b3688a4..72c63a020 100644 --- a/Shoko.Server/Databases/NHIbernate/SimpleNameSerializationBinder.cs +++ b/Shoko.Server/Databases/NHIbernate/SimpleNameSerializationBinder.cs @@ -3,7 +3,7 @@ using Newtonsoft.Json.Serialization; using NLog; -namespace Shoko.Server.Databases.NHIbernate; +namespace Shoko.Server.Databases.NHibernate; public class SimpleNameSerializationBinder : DefaultSerializationBinder { @@ -14,7 +14,7 @@ public SimpleNameSerializationBinder(Type baseType = null) { _baseType = baseType; } - + public override void BindToName( Type serializedType, out string assemblyName, out string typeName) { diff --git a/Shoko.Server/Databases/NHIbernate/StringListConverter.cs b/Shoko.Server/Databases/NHIbernate/StringListConverter.cs new file mode 100644 index 000000000..69e1cac3e --- /dev/null +++ b/Shoko.Server/Databases/NHIbernate/StringListConverter.cs @@ -0,0 +1,91 @@ +using System; +using System.ComponentModel; +using NHibernate.SqlTypes; +using NHibernate.UserTypes; +using System.Data; +using System.Data.Common; +using NHibernate; +using NHibernate.Engine; +using System.Globalization; +using System.Collections; +using NHibernate.Mapping; +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Extensions; + +#nullable enable +namespace Shoko.Server.Databases.NHibernate; + +public class StringListConverter : TypeConverter, IUserType +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type? sourceType) + => sourceType?.FullName switch + { + nameof(List<string>) => true, + nameof(String) => true, + _ => false + }; + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + => destinationType?.FullName switch + { + nameof(String) => true, + _ => false, + }; + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value) + => value switch + { + string i => i.Split("|||").ToList(), + List<string> l => l, + _ => throw new ArgumentException($"DestinationType must be {nameof(String)}.") + }; + + public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type? destinationType) + => value switch + { + string i => i, + List<string> l => l.Join("|||"), + _ => throw new ArgumentException($"DestinationType must be {typeof(List<string>).FullName}."), + }; + + public override object CreateInstance(ITypeDescriptorContext? context, IDictionary? propertyValues) + => true; + + #region IUserType Members + + public object Assemble(object cached, object owner) + => DeepCopy(cached); + + public object DeepCopy(object value) + => value; + + public object Disassemble(object value) + => DeepCopy(value); + + public int GetHashCode(object x) + => x == null ? base.GetHashCode() : x.GetHashCode(); + + public bool IsMutable + => true; + + public object? NullSafeGet(DbDataReader rs, string[] names, ISessionImplementor impl, object owner) + => ConvertFrom(null, null, NHibernateUtil.String.NullSafeGet(rs, names[0], impl)); + + public void NullSafeSet(DbCommand cmd, object value, int index, ISessionImplementor session) + => ((IDataParameter)cmd.Parameters[index]).Value = value == null ? DBNull.Value : ConvertTo(null, null, value, typeof(List<string>)); + + public object Replace(object original, object target, object owner) + => original; + + public Type ReturnedType + => typeof(List<string>); + + public SqlType[] SqlTypes + => new[] { NHibernateUtil.String.SqlType }; + + bool IUserType.Equals(object x, object y) + => ReferenceEquals(x, y) || (x != null && y != null && x.Equals(y)); + + #endregion +} diff --git a/Shoko.Server/Databases/NHIbernate/TitleLanguageConverter.cs b/Shoko.Server/Databases/NHIbernate/TitleLanguageConverter.cs index 30ae41ef1..0247c619d 100644 --- a/Shoko.Server/Databases/NHIbernate/TitleLanguageConverter.cs +++ b/Shoko.Server/Databases/NHIbernate/TitleLanguageConverter.cs @@ -9,7 +9,7 @@ using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.Extensions; -namespace Shoko.Server.Databases.NHIbernate; +namespace Shoko.Server.Databases.NHibernate; public class TitleLanguageConverter : TypeConverter, IUserType { diff --git a/Shoko.Server/Databases/NHIbernate/TitleTypeConverter.cs b/Shoko.Server/Databases/NHIbernate/TitleTypeConverter.cs index 3a04954e0..27ad05aeb 100644 --- a/Shoko.Server/Databases/NHIbernate/TitleTypeConverter.cs +++ b/Shoko.Server/Databases/NHIbernate/TitleTypeConverter.cs @@ -9,7 +9,7 @@ using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.Extensions; -namespace Shoko.Server.Databases.NHIbernate; +namespace Shoko.Server.Databases.NHibernate; public class TitleTypeConverter : TypeConverter, IUserType { @@ -142,7 +142,7 @@ public object Disassemble(object value) /// </summary> /// <param name="x">The x.</param> /// <returns> - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. + /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. /// </returns> public int GetHashCode(object x) { diff --git a/Shoko.Server/Databases/NHIbernate/TmdbContentRatingConverter.cs b/Shoko.Server/Databases/NHIbernate/TmdbContentRatingConverter.cs new file mode 100644 index 000000000..0cba0cfbd --- /dev/null +++ b/Shoko.Server/Databases/NHIbernate/TmdbContentRatingConverter.cs @@ -0,0 +1,91 @@ +using System; +using System.ComponentModel; +using NHibernate.SqlTypes; +using NHibernate.UserTypes; +using System.Data; +using System.Data.Common; +using NHibernate; +using NHibernate.Engine; +using System.Globalization; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Extensions; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Databases.NHibernate; + +public class TmdbContentRatingConverter : TypeConverter, IUserType +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type? sourceType) + => sourceType?.FullName switch + { + nameof(List<TMDB_ContentRating>) => true, + nameof(String) => true, + _ => false + }; + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + => destinationType?.FullName switch + { + nameof(String) => true, + _ => false, + }; + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value) + => value switch + { + string i => i.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(s => TMDB_ContentRating.FromString(s)).ToList(), + List<TMDB_ContentRating> l => l, + _ => throw new ArgumentException($"DestinationType must be {nameof(String)}.") + }; + + public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type? destinationType) + => value switch + { + string i => i, + List<TMDB_ContentRating> l => l.Select(r => r.ToString()).Join('|'), + _ => throw new ArgumentException($"DestinationType must be {typeof(List<TMDB_ContentRating>).FullName}."), + }; + + public override object CreateInstance(ITypeDescriptorContext? context, IDictionary? propertyValues) + => true; + + #region IUserType Members + + public object Assemble(object cached, object owner) + => DeepCopy(cached); + + public object DeepCopy(object value) + => value; + + public object Disassemble(object value) + => DeepCopy(value); + + public int GetHashCode(object x) + => x == null ? base.GetHashCode() : x.GetHashCode(); + + public bool IsMutable + => true; + + public object? NullSafeGet(DbDataReader rs, string[] names, ISessionImplementor impl, object owner) + => ConvertFrom(null, null, NHibernateUtil.String.NullSafeGet(rs, names[0], impl)); + + public void NullSafeSet(DbCommand cmd, object value, int index, ISessionImplementor session) + => ((IDataParameter)cmd.Parameters[index]).Value = value == null ? DBNull.Value : ConvertTo(null, null, value, typeof(List<TMDB_ContentRating>)); + + public object Replace(object original, object target, object owner) + => original; + + public Type ReturnedType + => typeof(List<TMDB_ContentRating>); + + public SqlType[] SqlTypes + => new[] { NHibernateUtil.String.SqlType }; + + bool IUserType.Equals(object x, object y) + => ReferenceEquals(x, y) || (x != null && y != null && x.Equals(y)); + + #endregion +} diff --git a/Shoko.Server/Databases/NHIbernate/TypeStringConverter.cs b/Shoko.Server/Databases/NHIbernate/TypeStringConverter.cs new file mode 100644 index 000000000..0617741ea --- /dev/null +++ b/Shoko.Server/Databases/NHIbernate/TypeStringConverter.cs @@ -0,0 +1,201 @@ +using System; +using System.ComponentModel; +using System.Data; +using System.Data.Common; +using System.Linq; +using NHibernate; +using NHibernate.Engine; +using NHibernate.SqlTypes; +using NHibernate.UserTypes; + +namespace Shoko.Server.Databases.NHibernate; + +public class TypeStringConverter : TypeConverter, IUserType +{ + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return typeof(Type).IsAssignableFrom(sourceType); + } + + public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) + { + return destinationType == typeof(string) || destinationType == typeof(Type); + } + + public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, + object value) + { + var s = value as string ?? throw new ArgumentException("Can only convert from string"); + return Type.GetType(s) ?? AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).FirstOrDefault(a => a.Name.Equals(s) || Equals(a.FullName, s)); + } + + /// <summary> + /// Converts the given value object to the specified type + /// </summary> + /// <param name="context">Ignored</param> + /// <param name="culture">Ignored</param> + /// <param name="value">The <see cref="T:System.Object"/> to convert.</param> + /// <param name="destinationType">The <see cref="T:System.Type"/> to convert the <paramref name="value"/> parameter to.</param> + /// <returns> + /// An <see cref="T:System.Object"/> that represents the converted value. The value will be 1 if <paramref name="value"/> is true, otherwise 0 + /// </returns> + /// <exception cref="T:System.ArgumentNullException">The <paramref name="destinationType"/> parameter is <see langword="null"/>.</exception> + /// <exception cref="T:System.NotSupportedException">The conversion could not be performed.</exception> + public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, + object value, Type destinationType) + { + if (value == null) return null; + return value.ToString(); + } + + + /// <summary> + /// Creates an instance of the Type that this <see cref="T:System.ComponentModel.TypeConverter"/> is associated with (bool) + /// </summary> + /// <param name="context">ignored.</param> + /// <param name="propertyValues">ignored.</param> + /// <returns> + /// An <see cref="T:System.Object"/> of type bool. It always returns 'true' for this converter. + /// </returns> + public override object CreateInstance(ITypeDescriptorContext context, System.Collections.IDictionary propertyValues) + { + return true; + } + + #region IUserType Members + + /// <summary> + /// Reconstruct an object from the cacheable representation. At the very least this + /// method should perform a deep copy if the type is mutable. (optional operation) + /// </summary> + /// <param name="cached">the object to be cached</param> + /// <param name="owner">the owner of the cached object</param> + /// <returns> + /// a reconstructed object from the cacheable representation + /// </returns> + public object Assemble(object cached, object owner) + { + return DeepCopy(cached); + } + + /// <summary> + /// Return a deep copy of the persistent state, stopping at entities and at collections. + /// </summary> + /// <param name="value">generally a collection element or entity field</param> + /// <returns>a copy</returns> + public object DeepCopy(object value) + { + return value; + } + + /// <summary> + /// Transform the object into its cacheable representation. At the very least this + /// method should perform a deep copy if the type is mutable. That may not be enough + /// for some implementations, however; for example, associations must be cached as + /// identifier values. (optional operation) + /// </summary> + /// <param name="value">the object to be cached</param> + /// <returns>a cacheable representation of the object</returns> + public object Disassemble(object value) + { + return DeepCopy(value); + } + + /// <summary> + /// Returns a hash code for this instance. + /// </summary> + /// <param name="x">The x.</param> + /// <returns> + /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. + /// </returns> + public int GetHashCode(object x) + { + return x == null ? base.GetHashCode() : x.GetHashCode(); + } + + /// <summary> + /// Are objects of this type mutable? + /// </summary> + /// <value></value> + public bool IsMutable => true; + + /// <summary> + /// Retrieve an instance of the mapped class from a JDBC resultset. + /// Implementors should handle possibility of null values. + /// </summary> + /// <param name="rs">a IDataReader</param> + /// <param name="names">column names</param> + /// <param name="impl"></param> + /// <param name="owner">the containing entity</param> + /// <returns></returns> + /// <exception cref="T:NHibernate.HibernateException">HibernateException</exception> + public object NullSafeGet(DbDataReader rs, string[] names, ISessionImplementor impl, object owner) + { + var rawValue = NHibernateUtil.String.NullSafeGet(rs, names[0], impl); + return rawValue == null ? null : ConvertFrom(null!, null!, rawValue); + } + + /// <summary> + /// Write an instance of the mapped class to a prepared statement. + /// Implementors should handle possibility of null values. + /// A multi-column type should be written to parameters starting from index. + /// </summary> + /// <param name="cmd">a IDbCommand</param> + /// <param name="value">the object to write</param> + /// <param name="index">command parameter index</param> + /// <param name="session"></param> + /// <exception cref="T:NHibernate.HibernateException">HibernateException</exception> + public void NullSafeSet(DbCommand cmd, object value, int index, ISessionImplementor session) + { + ((IDataParameter)cmd.Parameters[index]).Value = + value == null ? DBNull.Value : ConvertTo(null, null, value, typeof(string)); + } + + /// <summary> + /// During merge, replace the existing (<paramref name="target"/>) value in the entity + /// we are merging to with a new (<paramref name="original"/>) value from the detached + /// entity we are merging. For immutable objects, or null values, it is safe to simply + /// return the first parameter. For mutable objects, it is safe to return a copy of the + /// first parameter. For objects with component values, it might make sense to + /// recursively replace component values. + /// </summary> + /// <param name="original">the value from the detached entity being merged</param> + /// <param name="target">the value in the managed entity</param> + /// <param name="owner">the managed entity</param> + /// <returns>the value to be merged</returns> + public object Replace(object original, object target, object owner) + { + return original; + } + + /// <summary> + /// The type returned by <c>NullSafeGet()</c> + /// </summary> + public Type ReturnedType => typeof(string); + + /// <summary> + /// The SQL types for the columns mapped by this type. + /// </summary> + /// <value></value> + public SqlType[] SqlTypes => new[] { NHibernateUtil.String.SqlType }; + + /// <summary> + /// Determines whether the specified <see cref="System.Object"/> is equal to this instance. + /// </summary> + /// <param name="x">The <see cref="System.Object"/> to compare with this instance.</param> + /// <param name="y">The y.</param> + /// <returns> + /// <c>true</c> if the specified <see cref="System.Object"/> is equal to this instance; otherwise, <c>false</c>. + /// </returns> + bool IUserType.Equals(object x, object y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + return x != null && y != null && x.Equals(y); + } + + #endregion +} diff --git a/Shoko.Server/Databases/NHIbernate/TypelessMessagePackConverter.cs b/Shoko.Server/Databases/NHIbernate/TypelessMessagePackConverter.cs new file mode 100644 index 000000000..23b46392a --- /dev/null +++ b/Shoko.Server/Databases/NHIbernate/TypelessMessagePackConverter.cs @@ -0,0 +1,222 @@ +using System; +using System.ComponentModel; +using System.Data; +using System.Data.Common; +using MessagePack; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NHibernate; +using NHibernate.Engine; +using NHibernate.SqlTypes; +using NHibernate.UserTypes; +using Shoko.Server.Utilities; + +namespace Shoko.Server.Databases.NHibernate; + +public class TypelessMessagePackConverter : TypeConverter, IUserType +{ + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return true; + } + + public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) + { + return destinationType == typeof(byte[]) || destinationType == typeof(TypelessMessagePackConverter); + } + + public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, + object value) + { + var s = value as byte[] ?? throw new ArgumentException("Can only convert from byte[]"); + try + { + return MessagePackSerializer.Typeless.Deserialize(s); + } + catch(Exception ex) + { + Utils.ServiceContainer.GetRequiredService<ILogger<TypelessMessagePackConverter>>().LogError(ex, "Failed to deserialize {Type} from {Value}", + value.GetType(), Convert.ToBase64String((byte[])value)); + return null; + } + } + + /// <summary> + /// Converts the given value object to the specified type + /// </summary> + /// <param name="context">Ignored</param> + /// <param name="culture">Ignored</param> + /// <param name="value">The <see cref="T:System.Object"/> to convert.</param> + /// <param name="destinationType">The <see cref="T:System.Type"/> to convert the <paramref name="value"/> parameter to.</param> + /// <returns> + /// An <see cref="T:System.Object"/> that represents the converted value. The value will be 1 if <paramref name="value"/> is true, otherwise 0 + /// </returns> + /// <exception cref="T:System.ArgumentNullException">The <paramref name="destinationType"/> parameter is <see langword="null"/>.</exception> + /// <exception cref="T:System.NotSupportedException">The conversion could not be performed.</exception> + public override object ConvertTo(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, + object value, Type destinationType) + { + if (value == null) return null; + try + { + return MessagePackSerializer.Typeless.Serialize(value); + } + catch(Exception ex) + { + Utils.ServiceContainer.GetRequiredService<ILogger<TypelessMessagePackConverter>>().LogError(ex, "Failed to serialize {Type} from {Value}", + value.GetType(), Convert.ToBase64String((byte[])value)); + return null; + } + } + + + /// <summary> + /// Creates an instance of the Type that this <see cref="T:System.ComponentModel.TypeConverter"/> is associated with (bool) + /// </summary> + /// <param name="context">ignored.</param> + /// <param name="propertyValues">ignored.</param> + /// <returns> + /// An <see cref="T:System.Object"/> of type bool. It always returns 'true' for this converter. + /// </returns> + public override object CreateInstance(ITypeDescriptorContext context, System.Collections.IDictionary propertyValues) + { + return true; + } + + #region IUserType Members + + /// <summary> + /// Reconstruct an object from the cacheable representation. At the very least this + /// method should perform a deep copy if the type is mutable. (optional operation) + /// </summary> + /// <param name="cached">the object to be cached</param> + /// <param name="owner">the owner of the cached object</param> + /// <returns> + /// a reconstructed object from the cacheable representation + /// </returns> + public object Assemble(object cached, object owner) + { + return DeepCopy(cached); + } + + /// <summary> + /// Return a deep copy of the persistent state, stopping at entities and at collections. + /// </summary> + /// <param name="value">generally a collection element or entity field</param> + /// <returns>a copy</returns> + public object DeepCopy(object value) + { + return value; + } + + /// <summary> + /// Transform the object into its cacheable representation. At the very least this + /// method should perform a deep copy if the type is mutable. That may not be enough + /// for some implementations, however; for example, associations must be cached as + /// identifier values. (optional operation) + /// </summary> + /// <param name="value">the object to be cached</param> + /// <returns>a cacheable representation of the object</returns> + public object Disassemble(object value) + { + return DeepCopy(value); + } + + /// <summary> + /// Returns a hash code for this instance. + /// </summary> + /// <param name="x">The x.</param> + /// <returns> + /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. + /// </returns> + public int GetHashCode(object x) + { + return x == null ? base.GetHashCode() : x.GetHashCode(); + } + + /// <summary> + /// Are objects of this type mutable? + /// </summary> + /// <value></value> + public bool IsMutable => true; + + /// <summary> + /// Retrieve an instance of the mapped class from a JDBC resultset. + /// Implementors should handle possibility of null values. + /// </summary> + /// <param name="rs">a IDataReader</param> + /// <param name="names">column names</param> + /// <param name="impl"></param> + /// <param name="owner">the containing entity</param> + /// <returns></returns> + /// <exception cref="T:NHibernate.HibernateException">HibernateException</exception> + public object NullSafeGet(DbDataReader rs, string[] names, ISessionImplementor impl, object owner) + { + var rawValue = NHibernateUtil.BinaryBlob.NullSafeGet(rs, names[0], impl); + return rawValue == null ? null : ConvertFrom(null!, null!, rawValue); + } + + /// <summary> + /// Write an instance of the mapped class to a prepared statement. + /// Implementors should handle possibility of null values. + /// A multi-column type should be written to parameters starting from index. + /// </summary> + /// <param name="cmd">a IDbCommand</param> + /// <param name="value">the object to write</param> + /// <param name="index">command parameter index</param> + /// <param name="session"></param> + /// <exception cref="T:NHibernate.HibernateException">HibernateException</exception> + public void NullSafeSet(DbCommand cmd, object value, int index, ISessionImplementor session) + { + ((IDataParameter)cmd.Parameters[index]).Value = + value == null ? DBNull.Value : ConvertTo(null, null, value, typeof(byte[])); + } + + /// <summary> + /// During merge, replace the existing (<paramref name="target"/>) value in the entity + /// we are merging to with a new (<paramref name="original"/>) value from the detached + /// entity we are merging. For immutable objects, or null values, it is safe to simply + /// return the first parameter. For mutable objects, it is safe to return a copy of the + /// first parameter. For objects with component values, it might make sense to + /// recursively replace component values. + /// </summary> + /// <param name="original">the value from the detached entity being merged</param> + /// <param name="target">the value in the managed entity</param> + /// <param name="owner">the managed entity</param> + /// <returns>the value to be merged</returns> + public object Replace(object original, object target, object owner) + { + return original; + } + + /// <summary> + /// The type returned by <c>NullSafeGet()</c> + /// </summary> + public Type ReturnedType => typeof(byte[]); + + /// <summary> + /// The SQL types for the columns mapped by this type. + /// </summary> + /// <value></value> + public SqlType[] SqlTypes => new[] { NHibernateUtil.BinaryBlob.SqlType }; + + /// <summary> + /// Determines whether the specified <see cref="System.Object"/> is equal to this instance. + /// </summary> + /// <param name="x">The <see cref="System.Object"/> to compare with this instance.</param> + /// <param name="y">The y.</param> + /// <returns> + /// <c>true</c> if the specified <see cref="System.Object"/> is equal to this instance; otherwise, <c>false</c>. + /// </returns> + bool IUserType.Equals(object x, object y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + return x != null && y != null && x.Equals(y); + } + + #endregion +} diff --git a/Shoko.Server/Databases/SQLServer.cs b/Shoko.Server/Databases/SQLServer.cs index 07540dc1b..45aa05dc7 100644 --- a/Shoko.Server/Databases/SQLServer.cs +++ b/Shoko.Server/Databases/SQLServer.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using FluentNHibernate.Cfg; using FluentNHibernate.Cfg.Db; +using MessagePack; using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; using NHibernate; @@ -11,7 +13,10 @@ using NHibernate.Driver; using Shoko.Commons.Extensions; using Shoko.Commons.Properties; -using Shoko.Server.Databases.NHIbernate; +using Shoko.Plugin.Abstractions; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models; +using Shoko.Server.Renamer; using Shoko.Server.Repositories; using Shoko.Server.Server; using Shoko.Server.Utilities; @@ -23,7 +28,7 @@ namespace Shoko.Server.Databases; public class SQLServer : BaseDatabase<SqlConnection> { public override string Name { get; } = "SQLServer"; - public override int RequiredVersion { get; } = 120; + public override int RequiredVersion { get; } = 131; public override void BackupDatabase(string fullfilename) { @@ -343,8 +348,8 @@ public override bool HasVersionsTable() new DatabaseCommand(4, 1, "ALTER TABLE AnimeGroup ADD DefaultAnimeSeriesID int NULL"), new DatabaseCommand(5, 1, "ALTER TABLE JMMUser ADD CanEditServerSettings int NULL"), new DatabaseCommand(6, 1, "ALTER TABLE VideoInfo ADD VideoBitDepth varchar(max) NULL"), - new DatabaseCommand(7, 1, DatabaseFixes.FixDuplicateTvDBLinks), - new DatabaseCommand(7, 2, DatabaseFixes.FixDuplicateTraktLinks), + new DatabaseCommand(7, 1, DatabaseFixes.NoOperation), + new DatabaseCommand(7, 2, DatabaseFixes.NoOperation), new DatabaseCommand(7, 3, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TvDB_Season ON CrossRef_AniDB_TvDB(TvDBID, TvDBSeasonNumber)"), new DatabaseCommand(7, 4, @@ -422,19 +427,19 @@ public override bool HasVersionsTable() "CREATE TABLE CrossRef_AniDB_TvDBV2( CrossRef_AniDB_TvDBV2ID int IDENTITY(1,1) NOT NULL, AnimeID int NOT NULL, AniDBStartEpisodeType int NOT NULL, AniDBStartEpisodeNumber int NOT NULL, TvDBID int NOT NULL, TvDBSeasonNumber int NOT NULL, TvDBStartEpisodeNumber int NOT NULL, TvDBTitle nvarchar(MAX), CrossRefSource int NOT NULL, CONSTRAINT [PK_CrossRef_AniDB_TvDBV2] PRIMARY KEY CLUSTERED ( CrossRef_AniDB_TvDBV2ID ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] "), new DatabaseCommand(27, 2, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TvDBV2 ON CrossRef_AniDB_TvDBV2(AnimeID, TvDBID, TvDBSeasonNumber, TvDBStartEpisodeNumber, AniDBStartEpisodeType, AniDBStartEpisodeNumber)"), - new DatabaseCommand(27, 3, DatabaseFixes.MigrateTvDBLinks_V1_to_V2), + new DatabaseCommand(27, 3, DatabaseFixes.NoOperation), new DatabaseCommand(28, 1, "ALTER TABLE GroupFilter ADD Locked int NULL"), new DatabaseCommand(29, 1, "ALTER TABLE VideoInfo ADD FullInfo varchar(max) NULL"), new DatabaseCommand(30, 1, "CREATE TABLE CrossRef_AniDB_TraktV2( CrossRef_AniDB_TraktV2ID int IDENTITY(1,1) NOT NULL, AnimeID int NOT NULL, AniDBStartEpisodeType int NOT NULL, AniDBStartEpisodeNumber int NOT NULL, TraktID nvarchar(500), TraktSeasonNumber int NOT NULL, TraktStartEpisodeNumber int NOT NULL, TraktTitle nvarchar(MAX), CrossRefSource int NOT NULL, CONSTRAINT [PK_CrossRef_AniDB_TraktV2] PRIMARY KEY CLUSTERED ( CrossRef_AniDB_TraktV2ID ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] "), new DatabaseCommand(30, 2, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TraktV2 ON CrossRef_AniDB_TraktV2(AnimeID, TraktSeasonNumber, TraktStartEpisodeNumber, AniDBStartEpisodeType, AniDBStartEpisodeNumber)"), - new DatabaseCommand(30, 3, DatabaseFixes.MigrateTraktLinks_V1_to_V2), + new DatabaseCommand(30, 3, DatabaseFixes.NoOperation), new DatabaseCommand(31, 1, "CREATE TABLE CrossRef_AniDB_Trakt_Episode( CrossRef_AniDB_Trakt_EpisodeID int IDENTITY(1,1) NOT NULL, AnimeID int NOT NULL, AniDBEpisodeID int NOT NULL, TraktID nvarchar(500), Season int NOT NULL, EpisodeNumber int NOT NULL, CONSTRAINT [PK_CrossRef_AniDB_Trakt_Episode] PRIMARY KEY CLUSTERED ( CrossRef_AniDB_Trakt_EpisodeID ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] "), new DatabaseCommand(31, 2, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_Trakt_Episode_AniDBEpisodeID ON CrossRef_AniDB_Trakt_Episode(AniDBEpisodeID)"), - new DatabaseCommand(32, 3, DatabaseFixes.RemoveOldMovieDBImageRecords), + new DatabaseCommand(32, 3, DatabaseFixes.NoOperation), new DatabaseCommand(33, 1, "CREATE TABLE CustomTag( CustomTagID int IDENTITY(1,1) NOT NULL, TagName nvarchar(500), TagDescription nvarchar(MAX), CONSTRAINT [PK_CustomTag] PRIMARY KEY CLUSTERED ( CustomTagID ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] "), new DatabaseCommand(33, 2, @@ -449,7 +454,7 @@ public override bool HasVersionsTable() new DatabaseCommand(41, 1, "ALTER TABLE GroupFilter ADD FilterType int NULL"), new DatabaseCommand(41, 2, "UPDATE GroupFilter SET FilterType = 1"), new DatabaseCommand(41, 3, "ALTER TABLE GroupFilter ALTER COLUMN FilterType int NOT NULL"), - new DatabaseCommand(41, 4, DatabaseFixes.FixContinueWatchingGroupFilter_20160406), + new DatabaseCommand(41, 4, DatabaseFixes.NoOperation), new DatabaseCommand(42, 1, "ALTER TABLE AniDB_Anime ADD ContractVersion int NOT NULL DEFAULT(0), ContractString nvarchar(MAX) NULL"), new DatabaseCommand(42, 2, @@ -507,7 +512,7 @@ public override bool HasVersionsTable() new DatabaseCommand(47, 23, "ALTER TABLE AnimeGroup ADD ContractSize int NOT NULL DEFAULT(0)"), new DatabaseCommand(47, 24, "ALTER TABLE AnimeGroup DROP COLUMN ContractString"), new DatabaseCommand(48, 1, "ALTER TABLE AniDB_Anime DROP COLUMN AllCategories"), - new DatabaseCommand(49, 1, DatabaseFixes.DeleteSerieUsersWithoutSeries), + new DatabaseCommand(49, 1, DatabaseFixes.DeleteSeriesUsersWithoutSeries), new DatabaseCommand(50, 1, "CREATE TABLE VideoLocal_Place ( VideoLocal_Place_ID int IDENTITY(1,1) NOT NULL, VideoLocalID int NOT NULL, FilePath nvarchar(MAX) NOT NULL, ImportFolderID int NOT NULL, ImportFolderType int NOT NULL, CONSTRAINT [PK_VideoLocal_Place] PRIMARY KEY CLUSTERED ( VideoLocal_Place_ID ASC ) WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY]"), new DatabaseCommand(50, 2, @@ -532,9 +537,9 @@ public override bool HasVersionsTable() new DatabaseCommand(53, 2, "CREATE TABLE ScanFile ( ScanFileID int IDENTITY(1,1) NOT NULL, ScanID int NOT NULL, ImportFolderID int NOT NULL, VideoLocal_Place_ID int NOT NULL, FullName nvarchar(MAX) NOT NULL, FileSize bigint NOT NULL, Status int NOT NULL, CheckDate datetime NULL, Hash nvarchar(100) NOT NULL, HashResult nvarchar(100) NULL )"), new DatabaseCommand(53, 3, "CREATE INDEX UIX_ScanFileStatus ON ScanFile(ScanID,Status,CheckDate);"), - new DatabaseCommand(54, 1, DatabaseFixes.FixTagsWithInclude), - new DatabaseCommand(55, 1, DatabaseFixes.MakeYearsApplyToSeries), - new DatabaseCommand(56, 1, DatabaseFixes.FixEmptyVideoInfos), + new DatabaseCommand(54, 1, DatabaseFixes.NoOperation), + new DatabaseCommand(55, 1, DatabaseFixes.NoOperation), + new DatabaseCommand(56, 1, DatabaseFixes.NoOperation), new DatabaseCommand(57, 1, "ALTER TABLE JMMUser ADD PlexToken nvarchar(max) NULL"), new DatabaseCommand(58, 1, "ALTER TABLE AniDB_File ADD IsChaptered INT NOT NULL DEFAULT(-1)"), new DatabaseCommand(59, 1, "ALTER TABLE RenameScript ADD RenamerType nvarchar(max) NOT NULL DEFAULT('Legacy')"), @@ -544,7 +549,7 @@ public override bool HasVersionsTable() new DatabaseCommand(61, 1, "ALTER TABLE TvDB_Episode ADD Rating INT NULL"), new DatabaseCommand(61, 2, "ALTER TABLE TvDB_Episode ADD AirDate datetime NULL"), new DatabaseCommand(61, 3, "ALTER TABLE TvDB_Episode DROP COLUMN FirstAired"), - new DatabaseCommand(61, 4, DatabaseFixes.UpdateAllTvDBSeries), + new DatabaseCommand(61, 4, DatabaseFixes.NoOperation), new DatabaseCommand(62, 1, "ALTER TABLE AnimeSeries ADD AirsOn varchar(10) NULL"), new DatabaseCommand(63, 1, "DROP TABLE Trakt_ImageFanart"), new DatabaseCommand(63, 2, "DROP TABLE Trakt_ImagePoster"), @@ -557,7 +562,7 @@ public override bool HasVersionsTable() new DatabaseCommand(66, 1, "ALTER TABLE AniDB_Episode ADD Description nvarchar(max) NOT NULL DEFAULT('')"), new DatabaseCommand(66, 2, DatabaseFixes.FixCharactersWithGrave), new DatabaseCommand(67, 1, DatabaseFixes.RefreshAniDBInfoFromXML), - new DatabaseCommand(68, 1, DatabaseFixes.MakeTagsApplyToSeries), + new DatabaseCommand(68, 1, DatabaseFixes.NoOperation), new DatabaseCommand(68, 2, DatabaseFixes.UpdateAllStats), new DatabaseCommand(69, 1, DatabaseFixes.RemoveBasePathsFromStaffAndCharacters), new DatabaseCommand(70, 1, "ALTER TABLE AniDB_Character ALTER COLUMN CharName nvarchar(max) NOT NULL"), @@ -565,17 +570,17 @@ public override bool HasVersionsTable() new DatabaseCommand(71, 2, "CREATE UNIQUE INDEX UIX_AniDB_AnimeUpdate ON AniDB_AnimeUpdate(AnimeID)"), new DatabaseCommand(71, 3, DatabaseFixes.MigrateAniDB_AnimeUpdates), new DatabaseCommand(72, 1, DatabaseFixes.RemoveBasePathsFromStaffAndCharacters), - new DatabaseCommand(73, 1, DatabaseFixes.FixDuplicateTagFiltersAndUpdateSeasons), - new DatabaseCommand(74, 1, DatabaseFixes.RecalculateYears), + new DatabaseCommand(73, 1, DatabaseFixes.NoOperation), + new DatabaseCommand(74, 1, DatabaseFixes.NoOperation), new DatabaseCommand(75, 1, "DROP INDEX UIX_CrossRef_AniDB_MAL_Anime ON CrossRef_AniDB_MAL;"), new DatabaseCommand(75, 2, "ALTER TABLE AniDB_Anime ADD Site_JP nvarchar(max), Site_EN nvarchar(max), Wikipedia_ID nvarchar(max), WikipediaJP_ID nvarchar(max), SyoboiID int, AnisonID int, CrunchyrollID nvarchar(max)"), - new DatabaseCommand(75, 3, DatabaseFixes.PopulateResourceLinks), + new DatabaseCommand(75, 3, DatabaseFixes.NoOperation), new DatabaseCommand(76, 1, "ALTER TABLE VideoLocal ADD MyListID INT NOT NULL DEFAULT(0)"), - new DatabaseCommand(76, 2, DatabaseFixes.PopulateMyListIDs), + new DatabaseCommand(76, 2, DatabaseFixes.NoOperation), new DatabaseCommand(77, 1, "ALTER TABLE AniDB_Episode DROP COLUMN EnglishName"), new DatabaseCommand(77, 2, "ALTER TABLE AniDB_Episode DROP COLUMN RomajiName"), new DatabaseCommand(77, 3, "CREATE TABLE AniDB_Episode_Title ( AniDB_Episode_TitleID int IDENTITY(1,1) NOT NULL, AniDB_EpisodeID int NOT NULL, Language nvarchar(50) NOT NULL, Title nvarchar(500) NOT NULL )"), - new DatabaseCommand(77, 4, DatabaseFixes.DummyMigrationOfObsolescence), + new DatabaseCommand(77, 4, DatabaseFixes.NoOperation), new DatabaseCommand(78, 1, "DROP INDEX UIX_CrossRef_AniDB_TvDB_Episode_AniDBEpisodeID ON CrossRef_AniDB_TvDB_Episode;"), new DatabaseCommand(78, 2, "exec sp_rename CrossRef_AniDB_TvDB_Episode, CrossRef_AniDB_TvDB_Episode_Override;"), new DatabaseCommand(78, 3, "ALTER TABLE CrossRef_AniDB_TvDB_Episode_Override DROP COLUMN AnimeID"), @@ -587,12 +592,12 @@ public override bool HasVersionsTable() new DatabaseCommand(78, 8, "CREATE UNIQUE INDEX UIX_AniDB_TvDB_AniDBID_TvDBID ON CrossRef_AniDB_TvDB(AniDBID,TvDBID);"), new DatabaseCommand(78, 9, "CREATE TABLE CrossRef_AniDB_TvDB_Episode(CrossRef_AniDB_TvDB_EpisodeID int IDENTITY(1,1) NOT NULL, AniDBEpisodeID int NOT NULL, TvDBEpisodeID int NOT NULL, MatchRating INT NOT NULL);"), new DatabaseCommand(78, 10, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TvDB_Episode_AniDBID_TvDBID ON CrossRef_AniDB_TvDB_Episode(AniDBEpisodeID,TvDBEpisodeID);"), - new DatabaseCommand(78, 11, DatabaseFixes.MigrateTvDBLinks_v2_to_V3), + new DatabaseCommand(78, 11, DatabaseFixes.NoOperation), // DatabaseFixes.MigrateTvDBLinks_v2_to_V3() drops the CrossRef_AniDB_TvDBV2 table. We do it after init to migrate - new DatabaseCommand(79, 1, DatabaseFixes.FixAniDB_EpisodesWithMissingTitles), - new DatabaseCommand(80, 1, DatabaseFixes.RegenTvDBMatches), + new DatabaseCommand(79, 1, DatabaseFixes.NoOperation), + new DatabaseCommand(80, 1, DatabaseFixes.NoOperation), new DatabaseCommand(81, 1, "ALTER TABLE AnimeSeries ADD UpdatedAt datetime NOT NULL DEFAULT '2000-01-01 00:00:00';"), - new DatabaseCommand(82, 1, DatabaseFixes.MigrateAniDBToNet), + new DatabaseCommand(82, 1, DatabaseFixes.NoOperation), new DatabaseCommand(83, 1, DropVideoLocalMediaColumns), new DatabaseCommand(84, 1, "DROP INDEX IF EXISTS UIX_CrossRef_AniDB_MAL_MALID ON CrossRef_AniDB_MAL;"), new DatabaseCommand(85, 1, "DROP INDEX IF EXISTS UIX_AniDB_File_FileID ON AniDB_File;"), @@ -691,9 +696,224 @@ public override bool HasVersionsTable() new DatabaseCommand(118, 1, "DELETE FROM FilterPreset WHERE FilterType IN (16, 24, 32, 40, 64, 72)"), new DatabaseCommand(119, 1, DropContracts), new DatabaseCommand(120, 1, DropVideoLocalMediaSize), + new DatabaseCommand(121, 1, "CREATE TABLE AniDB_NotifyQueue( AniDB_NotifyQueueID int IDENTITY(1,1) NOT NULL, Type int NOT NULL, ID int NOT NULL, AddedAt datetime NOT NULL ); "), + new DatabaseCommand(121, 2, "CREATE TABLE AniDB_Message( AniDB_MessageID int IDENTITY(1,1) NOT NULL, MessageID int NOT NULL, FromUserID int NOT NULL, FromUserName nvarchar(100), SentAt datetime NOT NULL, FetchedAt datetime NOT NULL, Type int NOT NULL, Title nvarchar(MAX), Body nvarchar(MAX), Flags int NOT NULL DEFAULT(0) ); "), + new DatabaseCommand(122, 1, "CREATE TABLE CrossRef_AniDB_TMDB_Episode ( CrossRef_AniDB_TMDB_EpisodeID INT IDENTITY(1,1) NOT NULL, AnidbAnimeID INT NOT NULL, AnidbEpisodeID INT NOT NULL, TmdbShowID INT NOT NULL, TmdbEpisodeID INT NOT NULL, Ordering INT NOT NULL, MatchRating INT NOT NULL );"), + new DatabaseCommand(122, 2, "CREATE TABLE CrossRef_AniDB_TMDB_Movie ( CrossRef_AniDB_TMDB_MovieID INT IDENTITY(1,1) NOT NULL, AnidbAnimeID INT NOT NULL, AnidbEpisodeID INT NULL, TmdbMovieID INT NOT NULL, Source INT NOT NULL );"), + new DatabaseCommand(122, 3, "CREATE TABLE CrossRef_AniDB_TMDB_Show ( CrossRef_AniDB_TMDB_ShowID INT IDENTITY(1,1) NOT NULL, AnidbAnimeID INT NOT NULL, TmdbShowID INT NOT NULL, Source INT NOT NULL );"), + new DatabaseCommand(122, 4, "CREATE TABLE TMDB_Image ( TMDB_ImageID INT IDENTITY(1,1) NOT NULL, TmdbMovieID INT NULL, TmdbEpisodeID INT NULL, TmdbSeasonID INT NULL, TmdbShowID INT NULL, TmdbCollectionID INT NULL, TmdbNetworkID INT NULL, TmdbCompanyID INT NULL, TmdbPersonID INT NULL, ForeignType INT NOT NULL, ImageType INT NOT NULL, IsEnabled INT NOT NULL, Width INT NOT NULL, Height INT NOT NULL, Language NVARCHAR(32) NOT NULL, RemoteFileName NVARCHAR(128) NOT NULL, UserRating decimal(6,2) NOT NULL, UserVotes INT NOT NULL );"), + new DatabaseCommand(122, 5, "CREATE TABLE AniDB_Anime_PreferredImage ( AniDB_Anime_PreferredImageID INT IDENTITY(1,1) NOT NULL, AnidbAnimeID INT NOT NULL, ImageID INT NOT NULL, ImageType INT NOT NULL, ImageSource INT NOT NULL );"), + new DatabaseCommand(122, 6, "CREATE TABLE TMDB_Title ( TMDB_TitleID INT IDENTITY(1,1) NOT NULL, ParentID INT NOT NULL, ParentType INT NOT NULL, LanguageCode NVARCHAR(5) NOT NULL, CountryCode NVARCHAR(5) NOT NULL, Value NVARCHAR(512) NOT NULL );"), + new DatabaseCommand(122, 7, "CREATE TABLE TMDB_Overview ( TMDB_OverviewID INT IDENTITY(1,1) NOT NULL, ParentID INT NOT NULL, ParentType INT NOT NULL, LanguageCode NVARCHAR(5) NOT NULL, CountryCode NVARCHAR(5) NOT NULL, Value NVARCHAR(MAX) NOT NULL );"), + new DatabaseCommand(122, 8, "CREATE TABLE TMDB_Company ( TMDB_CompanyID INT IDENTITY(1,1) NOT NULL, TmdbCompanyID INT NOT NULL, Name NVARCHAR(512) NOT NULL, CountryOfOrigin NVARCHAR(3) NOT NULL );"), + new DatabaseCommand(122, 9, "CREATE TABLE TMDB_Network ( TMDB_NetworkID INT IDENTITY(1,1) NOT NULL, TmdbNetworkID INT NOT NULL, Name NVARCHAR(512) NOT NULL, CountryOfOrigin NVARCHAR(3) NOT NULL );"), + new DatabaseCommand(122, 10, "CREATE TABLE TMDB_Person ( TMDB_PersonID INT IDENTITY(1,1) NOT NULL, TmdbPersonID INT NOT NULL, EnglishName NVARCHAR(512) NOT NULL, EnglishBiography NVARCHAR(MAX) NOT NULL, Aliases NVARCHAR(MAX) NOT NULL, Gender INT NOT NULL, IsRestricted BIT NOT NULL, BirthDay DATE NULL, DeathDay DATE NULL, PlaceOfBirth NVARCHAR(MAX) NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 11, "CREATE TABLE TMDB_Movie ( TMDB_MovieID INT IDENTITY(1,1) NOT NULL, TmdbMovieID INT NOT NULL, TmdbCollectionID INT NULL, EnglishTitle NVARCHAR(512) NOT NULL, EnglishOvervie NVARCHAR(MAX) NOT NULL, OriginalTitle NVARCHAR(512) NOT NULL, OriginalLanguageCode NVARCHAR(5) NOT NULL, IsRestricted BIT NOT NULL, IsVideo BIT NOT NULL, Genres NVARCHAR(128) NOT NULL, ContentRatings NVARCHAR(128) NOT NULL, Runtime INT NULL, UserRating decimal(6,2) NOT NULL, UserVotes INT NOT NULL, ReleasedAt DATE NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 12, "CREATE TABLE TMDB_Movie_Cast ( TMDB_Movie_CastID INT IDENTITY(1,1) NOT NULL, TmdbMovieID INT NOT NULL, TmdbPersonID INT NOT NULL, TmdbCreditID NVARCHAR(64) NOT NULL, CharacterName NVARCHAR(512) NOT NULL, Ordering INT NOT NULL );"), + new DatabaseCommand(122, 13, "CREATE TABLE TMDB_Company_Entity ( TMDB_Company_EntityID INT IDENTITY(1,1) NOT NULL, TmdbCompanyID INT NOT NULL, TmdbEntityType INT NOT NULL, TmdbEntityID INT NOT NULL, Ordering INT NOT NULL, ReleasedAt DATE NULL );"), + new DatabaseCommand(122, 14, "CREATE TABLE TMDB_Movie_Crew ( TMDB_Movie_CrewID INT IDENTITY(1,1) NOT NULL, TmdbMovieID INT NOT NULL, TmdbPersonID INT NOT NULL, TmdbCreditID NVARCHAR(64) NOT NULL, Job NVARCHAR(64) NOT NULL, Department NVARCHAR(64) NOT NULL );"), + new DatabaseCommand(122, 15, "CREATE TABLE TMDB_Show ( TMDB_ShowID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, EnglishTitle NVARCHAR(512) NOT NULL, EnglishOverview NVARCHAR(MAX) NOT NULL, OriginalTitle NVARCHAR(512) NOT NULL, OriginalLanguageCode NVARCHAR(5) NOT NULL, IsRestricted BIT NOT NULL, Genres NVARCHAR(128) NOT NULL, ContentRatings NVARCHAR(128) NOT NULL, EpisodeCount INT NOT NULL, SeasonCount INT NOT NULL, AlternateOrderingCount INT NOT NULL, UserRating decimal(6,2) NOT NULL, UserVotes INT NOT NULL, FirstAiredAt DATE, LastAiredAt DATE NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 16, "CREATE TABLE Tmdb_Show_Network ( TMDB_Show_NetworkID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbNetworkID INT NOT NULL, Ordering INT NOT NULL );"), + new DatabaseCommand(122, 17, "CREATE TABLE TMDB_Season ( TMDB_SeasonID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbSeasonID INT NOT NULL, EnglishTitle NVARCHAR(512) NOT NULL, EnglishOverview NVARCHAR(MAX) NOT NULL, EpisodeCount INT NOT NULL, SeasonNumber INT NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 18, "CREATE TABLE TMDB_Episode ( TMDB_EpisodeID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbSeasonID INT NOT NULL, TmdbEpisodeID INT NOT NULL, EnglishTitle NVARCHAR(512) NOT NULL, EnglishOverview NVARCHAR(MAX) NOT NULL, SeasonNumber INT NOT NULL, EpisodeNumber INT NOT NULL, Runtime INT NULL, UserRating decimal(6, 2) NOT NULL, UserVotes INT NOT NULL, AiredAt DATE NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 19, "CREATE TABLE TMDB_Episode_Cast ( TMDB_Episode_CastID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbSeasonID INT NOT NULL, TmdbEpisodeID INT NOT NULL, TmdbPersonID INT NOT NULL, TmdbCreditID NVARCHAR(64) NOT NULL, CharacterName NVARCHAR(512) NOT NULL, IsGuestRole BIT NOT NULL, Ordering INT NOT NULL );"), + new DatabaseCommand(122, 20, "CREATE TABLE TMDB_Episode_Crew ( TMDB_Episode_CrewID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbSeasonID INT NOT NULL, TmdbEpisodeID INT NOT NULL, TmdbPersonID INT NOT NULL, TmdbCreditID NVARCHAR(64) NOT NULL, Job NVARCHAR(512) NOT NULL, Department NVARCHAR(512) NOT NULL );"), + new DatabaseCommand(122, 21, "CREATE TABLE TMDB_AlternateOrdering ( TMDB_AlternateOrderingID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbNetworkID INT NULL, TmdbEpisodeGroupCollectionID NVARCHAR(64) NOT NULL, EnglishTitle NVARCHAR(512) NOT NULL, EnglishOverview NVARCHAR(MAX) NOT NULL, EpisodeCount INT NOT NULL, SeasonCount INT NOT NULL, Type INT NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 22, "CREATE TABLE TMDB_AlternateOrdering_Season ( TMDB_AlternateOrdering_SeasonID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbEpisodeGroupCollectionID NVARCHAR(64) NOT NULL, TmdbEpisodeGroupID NVARCHAR(64) NOT NULL, EnglishTitle NVARCHAR(512) NOT NULL, SeasonNumber INT NOT NULL, EpisodeCount INT NOT NULL, IsLocked BIT NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 23, "CREATE TABLE TMDB_AlternateOrdering_Episode ( TMDB_AlternateOrdering_EpisodeID INT IDENTITY(1,1) NOT NULL, TmdbShowID INT NOT NULL, TmdbEpisodeGroupCollectionID NVARCHAR(64) NOT NULL, TmdbEpisodeGroupID NVARCHAR(64) NOT NULL, TmdbEpisodeID INT NOT NULL, SeasonNumber INT NOT NULL, EpisodeNumber INT NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 24, "CREATE TABLE TMDB_Collection ( TMDB_CollectionID INT IDENTITY(1,1) NOT NULL, TmdbCollectionID INT NOT NULL, EnglishTitle NVARCHAR(512) NOT NULL, EnglishOverview NVARCHAR(MAX) NOT NULL, MovieCount INT NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new DatabaseCommand(122, 25, "CREATE TABLE TMDB_Collection_Movie ( TMDB_Collection_MovieID INT IDENTITY(1,1) NOT NULL, TmdbCollectionID INT NOT NULL, TmdbMovieID INT NOT NULL, Ordering INT NOT NULL );"), + new DatabaseCommand(122, 26, "INSERT INTO CrossRef_AniDB_TMDB_Movie ( AnidbAnimeID, TmdbMovieID, Source ) SELECT AnimeID, CAST ( CrossRefID AS INT ), CrossRefSource FROM CrossRef_AniDB_Other WHERE CrossRefType = 1;"), + new DatabaseCommand(122, 27, "DROP TABLE CrossRef_AniDB_Other;"), + new DatabaseCommand(122, 28, "DROP TABLE MovieDB_Fanart;"), + new DatabaseCommand(122, 29, "DROP TABLE MovieDB_Movie;"), + new DatabaseCommand(122, 30, "DROP TABLE MovieDB_Poster;"), + new DatabaseCommand(122, 31, "DROP TABLE AniDB_Anime_DefaultImage;"), + new DatabaseCommand(122, 32, "CREATE TABLE AniDB_Episode_PreferredImage ( AniDB_Episode_PreferredImageID INT IDENTITY(1,1) NOT NULL, AnidbAnimeID INT NOT NULL, AnidbEpisodeID INT NOT NULL, ImageID INT NOT NULL, ImageType INT NOT NULL, ImageSource INT NOT NULL );"), + new DatabaseCommand(122, 33, DatabaseFixes.CleanupAfterAddingTMDB), + new DatabaseCommand(122, 34, "UPDATE FilterPreset SET Expression = REPLACE(Expression, 'HasTMDbLinkExpression', 'HasTmdbLinkExpression');"), + new DatabaseCommand(122, 35, "exec sp_rename 'TMDB_Movie.EnglishOvervie', 'EnglishOverview', 'COLUMN';"), + new DatabaseCommand(122, 36, "UPDATE TMDB_Image SET IsEnabled = 1;"), + new DatabaseCommand(123, 1, MigrateRenamers), + new DatabaseCommand(123, 2, "DELETE FROM RenamerInstance WHERE NAME = 'AAA_WORKINGFILE_TEMP_AAA';"), + new DatabaseCommand(123, 3, DatabaseFixes.CreateDefaultRenamerConfig), + new DatabaseCommand(124, 1, "ALTER TABLE TMDB_Show ADD TvdbShowID INT NULL DEFAULT NULL;"), + new DatabaseCommand(124, 2, "ALTER TABLE TMDB_Episode ADD TvdbEpisodeID INT NULL DEFAULT NULL;"), + new DatabaseCommand(124, 3, "ALTER TABLE TMDB_Movie ADD ImdbMovieID INT NULL DEFAULT NULL;"), + new DatabaseCommand(124, 4, AlterImdbMovieIDType), + new DatabaseCommand(124, 5, "CREATE INDEX IX_TMDB_Overview ON TMDB_Overview(ParentType, ParentID)"), + new DatabaseCommand(124, 6, "CREATE INDEX IX_TMDB_Title ON TMDB_Title(ParentType, ParentID)"), + new DatabaseCommand(124, 7, "CREATE UNIQUE INDEX UIX_TMDB_Episode_TmdbEpisodeID ON TMDB_Episode(TmdbEpisodeID)"), + new DatabaseCommand(124, 8, "CREATE UNIQUE INDEX UIX_TMDB_Show_TmdbShowID ON TMDB_Show(TmdbShowID)"), + new DatabaseCommand(125, 1, "UPDATE CrossRef_AniDB_TMDB_Movie SET AnidbEpisodeID = (SELECT TOP 1 EpisodeID FROM AniDB_Episode WHERE AniDB_Episode.AnimeID = CrossRef_AniDB_TMDB_Movie.AnidbAnimeID ORDER BY EpisodeType, EpisodeNumber) WHERE AnidbEpisodeID IS NULL AND EXISTS (SELECT 1 FROM AniDB_Episode WHERE AniDB_Episode.AnimeID = CrossRef_AniDB_TMDB_Movie.AnidbAnimeID);"), + new DatabaseCommand(125, 2, "DELETE FROM CrossRef_AniDB_TMDB_Movie WHERE AnidbEpisodeID IS NULL;"), + new DatabaseCommand(125, 3, "ALTER TABLE CrossRef_AniDB_TMDB_Movie ALTER COLUMN AnidbEpisodeID INT NOT NULL;"), + new DatabaseCommand(125, 4, "ALTER TABLE CrossRef_AniDB_TMDB_Movie ADD CONSTRAINT DF_CrossRef_AniDB_TMDB_Movie_AnidbEpisodeID DEFAULT 0 FOR AnidbEpisodeID;"), + new DatabaseCommand(126, 1, "ALTER TABLE TMDB_Movie ADD PosterPath NVARCHAR(64) NULL DEFAULT NULL;"), + new DatabaseCommand(126, 2, "ALTER TABLE TMDB_Movie ADD BackdropPath NVARCHAR(64) NULL DEFAULT NULL;"), + new DatabaseCommand(126, 3, "ALTER TABLE TMDB_Show ADD PosterPath NVARCHAR(64) NULL DEFAULT NULL;"), + new DatabaseCommand(126, 4, "ALTER TABLE TMDB_Show ADD BackdropPath NVARCHAR(64) NULL DEFAULT NULL;"), + new DatabaseCommand(127, 1, "UPDATE FilterPreset SET Expression = REPLACE(Expression, 'MissingTMDbLinkExpression', 'MissingTmdbLinkExpression');"), + new DatabaseCommand(128, 1, "CREATE TABLE AniDB_Creator ( AniDB_CreatorID INT IDENTITY(1,1) NOT NULL, CreatorID INT NOT NULL, Name NVARCHAR(512) NOT NULL, OriginalName NVARCHAR(512) NULL, Type INT NOT NULL DEFAULT 0, ImagePath NVARCHAR(512) NULL, EnglishHomepageUrl NVARCHAR(512) NULL, JapaneseHomepageUrl NVARCHAR(512) NULL, EnglishWikiUrl NVARCHAR(512) NULL, JapaneseWikiUrl NVARCHAR(512) NULL, LastUpdatedAt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (AniDB_CreatorID) );"), + new DatabaseCommand(128, 2, "CREATE TABLE AniDB_Character_Creator ( AniDB_Character_CreatorID INT IDENTITY(1,1) NOT NULL, CharacterID INT NOT NULL, CreatorID INT NOT NULL, PRIMARY KEY (AniDB_Character_CreatorID) );"), + new DatabaseCommand(128, 3, "CREATE UNIQUE INDEX UIX_AniDB_Creator_CreatorID ON AniDB_Creator(CreatorID);"), + new DatabaseCommand(128, 4, "CREATE INDEX UIX_AniDB_Character_Creator_CreatorID ON AniDB_Character_Creator(CreatorID);"), + new DatabaseCommand(128, 5, "CREATE INDEX UIX_AniDB_Character_Creator_CharacterID ON AniDB_Character_Creator(CharacterID);"), + new DatabaseCommand(128, 6, "CREATE UNIQUE INDEX UIX_AniDB_Character_Creator_CharacterID_CreatorID ON AniDB_Character_Creator(CharacterID, CreatorID);"), + new DatabaseCommand(128, 7, "INSERT INTO AniDB_Creator (CreatorID, Name, ImagePath) SELECT SeiyuuID, SeiyuuName, PicName FROM AniDB_Seiyuu;"), + new DatabaseCommand(128, 8, "INSERT INTO AniDB_Character_Creator (CharacterID, CreatorID) SELECT CharID, SeiyuuID FROM AniDB_Character_Seiyuu;"), + new DatabaseCommand(128, 9, "DROP TABLE AniDB_Seiyuu;"), + new DatabaseCommand(128, 10, "DROP TABLE AniDB_Character_Seiyuu;"), + new DatabaseCommand(129, 1, "ALTER TABLE TMDB_Show ADD PreferredAlternateOrderingID NVARCHAR(64) NULL DEFAULT NULL;"), + new DatabaseCommand(130, 1, "ALTER TABLE TMDB_Show ALTER COLUMN ContentRatings NVARCHAR(512) NOT NULL;"), + new DatabaseCommand(130, 2, "ALTER TABLE TMDB_Movie ALTER COLUMN ContentRatings NVARCHAR(512) NOT NULL;"), + new DatabaseCommand(131, 1, "DROP TABLE TvDB_Episode;"), + new DatabaseCommand(131, 2, "DROP TABLE TvDB_Series;"), + new DatabaseCommand(131, 3, "DROP TABLE TvDB_ImageFanart;"), + new DatabaseCommand(131, 4, "DROP TABLE TvDB_ImagePoster;"), + new DatabaseCommand(131, 5, "DROP TABLE TvDB_ImageWideBanner;"), + new DatabaseCommand(131, 6, "DROP TABLE CrossRef_AniDB_TvDB;"), + new DatabaseCommand(131, 7, "DROP TABLE CrossRef_AniDB_TvDB_Episode;"), + new DatabaseCommand(131, 8, "DROP TABLE CrossRef_AniDB_TvDB_Episode_Override;"), + new DatabaseCommand(131, 9, "ALTER TABLE Trakt_Show DROP COLUMN TvDB_ID;"), + new DatabaseCommand(131, 10, "ALTER TABLE Trakt_Show ADD TmdbShowID INT NULL;"), + new DatabaseCommand(131, 11, DatabaseFixes.CleanupAfterRemovingTvDB), + new DatabaseCommand(131, 12, DatabaseFixes.ClearQuartzQueue), }; - + private static void AlterImdbMovieIDType() + { + DropColumnWithDefaultConstraint("TMDB_Movie", "ImdbMovieID"); + + using var session = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().SessionFactory.OpenStatelessSession(); + using var transaction = session.BeginTransaction(); + + const string alterCommand = "ALTER TABLE TMDB_Movie ADD ImdbMovieID NVARCHAR(12) NULL DEFAULT NULL;"; + session.CreateSQLQuery(alterCommand).ExecuteUpdate(); + transaction.Commit(); + } + + private static Tuple<bool, string> MigrateRenamers(object connection) + { + var factory = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().Instance; + var renamerService = Utils.ServiceContainer.GetRequiredService<RenameFileService>(); + var settingsProvider = Utils.SettingsProvider; + + var sessionFactory = factory.CreateSessionFactory(); + using var session = sessionFactory.OpenSession(); + using var transaction = session.BeginTransaction(); + try + { + const string createCommand = """ + CREATE TABLE RenamerInstance (ID INT IDENTITY(1,1) PRIMARY KEY, Name nvarchar(250) NOT NULL, Type nvarchar(250) NOT NULL, Settings varbinary(MAX)); + CREATE INDEX IX_RenamerInstance_Name ON RenamerInstance(Name); + CREATE INDEX IX_RenamerInstance_Type ON RenamerInstance(Type); + """; + + session.CreateSQLQuery(createCommand).ExecuteUpdate(); + + const string selectCommand = "SELECT ScriptName, RenamerType, IsEnabledOnImport, Script FROM RenameScript;"; + var reader = session.CreateSQLQuery(selectCommand) + .AddScalar("ScriptName", NHibernateUtil.String) + .AddScalar("RenamerType", NHibernateUtil.String) + .AddScalar("IsEnabledOnImport", NHibernateUtil.Int32) + .AddScalar("Script", NHibernateUtil.String) + .List<object[]>(); + string defaultName = null; + var renamerInstances = reader.Select(a => + { + try + { + var type = ((string)a[1]).Equals("Legacy") + ? typeof(WebAOMRenamer) + : renamerService.RenamersByKey.ContainsKey((string)a[1]) + ? renamerService.RenamersByKey[(string)a[1]].GetType() + : Type.GetType((string)a[1]); + if (type == null) + { + if ((string)a[1] == "GroupAwareRenamer") + return (Renamer: new RenamerConfig + { + Name = (string)a[0], + Type = typeof(WebAOMRenamer), + Settings = new WebAOMSettings + { + Script = (string)a[3], GroupAwareSorting = true + } + }, IsDefault: (int)a[2] == 1); + + Logger.Warn("A RenameScipt could not be converted to RenamerConfig. Renamer name: " + (string)a[0] + " Renamer type: " + (string)a[1] + + " Script: " + (string)a[3]); + return default; + } + + var settingsType = type.GetInterfaces().FirstOrDefault(b => b.IsGenericType && b.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetGenericArguments().FirstOrDefault(); + object settings = null; + if (settingsType != null) + { + settings = ActivatorUtilities.CreateInstance(Utils.ServiceContainer, settingsType); + settingsType.GetProperties(BindingFlags.Instance | BindingFlags.Public).FirstOrDefault(b => b.Name == "Script") + ?.SetValue(settings, (string)a[3]); + } + + return (Renamer: new RenamerConfig + { + Name = (string)a[0], Type = type, Settings = settings + }, IsDefault: (int)a[2] == 1); + } + catch (Exception ex) + { + if (a is { Length: >= 4 }) + { + Logger.Warn(ex, "A RenameScipt could not be converted to RenamerConfig. Renamer name: " + a[0] + " Renamer type: " + a[1] + + " Script: " + a[3]); + } + else + { + Logger.Warn(ex, "A RenameScipt could not be converted to RenamerConfig, but there wasn't enough data to log"); + } + + return default; + } + }).WhereNotDefault().GroupBy(a => a.Renamer.Name).SelectMany(a => a.Select((b, i) => + { + // Names are distinct + var renamer = b.Renamer; + if (i > 0) renamer.Name = renamer.Name + "_" + (i + 1); + if (b.IsDefault) defaultName = renamer.Name; + return renamer; + })); + + if (defaultName != null) + { + var settings = settingsProvider.GetSettings(); + settings.Plugins.Renamer.DefaultRenamer = defaultName; + settingsProvider.SaveSettings(settings); + } + + const string insertCommand = "INSERT INTO RenamerInstance (Name, Type, Settings) VALUES (:Name, :Type, :Settings);"; + foreach (var renamer in renamerInstances) + { + var command = session.CreateSQLQuery(insertCommand); + command.SetParameter("Name", renamer.Name); + command.SetParameter("Type", renamer.Type.ToString()); + command.SetParameter("Settings", renamer.Settings == null ? null : MessagePackSerializer.Typeless.Serialize(renamer.Settings)); + command.ExecuteUpdate(); + } + + const string dropCommand = "DROP TABLE RenameScript;"; + session.CreateSQLQuery(dropCommand).ExecuteUpdate(); + transaction.Commit(); + } + catch (Exception e) + { + transaction.Rollback(); + return new Tuple<bool, string>(false, e.ToString()); + } + + return new Tuple<bool, string>(true, null); + } private static Tuple<bool, string> DropDefaultsOnAnimeEpisode_User(object connection) { @@ -822,12 +1042,12 @@ protected override long ExecuteScalar(SqlConnection connection, string command) return long.Parse(result.ToString()); } - protected override List<object> ExecuteReader(SqlConnection connection, string command) + protected override List<object[]> ExecuteReader(SqlConnection connection, string command) { using var cmd = new SqlCommand(command, connection); cmd.CommandTimeout = 0; using var reader = cmd.ExecuteReader(); - var rows = new List<object>(); + var rows = new List<object[]>(); while (reader.Read()) { var values = new object[reader.FieldCount]; diff --git a/Shoko.Server/Databases/SQLite.cs b/Shoko.Server/Databases/SQLite.cs index 8956ece60..fa3661948 100644 --- a/Shoko.Server/Databases/SQLite.cs +++ b/Shoko.Server/Databases/SQLite.cs @@ -3,13 +3,19 @@ using Microsoft.Data.Sqlite; using System.IO; using System.Linq; +using System.Reflection; using FluentNHibernate.Cfg; using FluentNHibernate.Cfg.Db; +using MessagePack; using Microsoft.Extensions.DependencyInjection; using NHibernate; +using Shoko.Commons.Extensions; using Shoko.Commons.Properties; -using Shoko.Server.Databases.NHIbernate; +using Shoko.Plugin.Abstractions; +using Shoko.Server.Databases.NHibernate; using Shoko.Server.Databases.SqliteFixes; +using Shoko.Server.Models; +using Shoko.Server.Renamer; using Shoko.Server.Repositories; using Shoko.Server.Server; using Shoko.Server.Utilities; @@ -22,8 +28,7 @@ public class SQLite : BaseDatabase<SqliteConnection> { public override string Name => "SQLite"; - public override int RequiredVersion => 113; - + public override int RequiredVersion => 123; public override void BackupDatabase(string fullfilename) { @@ -324,7 +329,7 @@ public override void CreateDatabase() new DatabaseCommand(1, 109, "CREATE TABLE VideoLocal_User( VideoLocal_UserID INTEGER PRIMARY KEY AUTOINCREMENT, JMMUserID int NOT NULL, VideoLocalID int NOT NULL, WatchedDate timestamp NOT NULL ); "), new DatabaseCommand(1, 110, - "CREATE UNIQUE INDEX UIX_VideoLocal_User_User_VideoLocalID ON VideoLocal_User(JMMUserID, VideoLocalID);") + "CREATE UNIQUE INDEX UIX_VideoLocal_User_User_VideoLocalID ON VideoLocal_User(JMMUserID, VideoLocalID);"), }; private List<DatabaseCommand> updateVersionTable = new() @@ -348,8 +353,8 @@ public override void CreateDatabase() new(3, 2, "CREATE UNIQUE INDEX UIX_Trakt_Friend_Username ON Trakt_Friend(Username);"), new(4, 1, "ALTER TABLE AnimeGroup ADD DefaultAnimeSeriesID int NULL"), new(5, 1, "ALTER TABLE JMMUser ADD CanEditServerSettings int NULL"), - new(6, 1, DatabaseFixes.FixDuplicateTvDBLinks), - new(6, 2, DatabaseFixes.FixDuplicateTraktLinks), + new(6, 1, DatabaseFixes.NoOperation), + new(6, 2, DatabaseFixes.NoOperation), new(6, 3, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TvDB_Season ON CrossRef_AniDB_TvDB(TvDBID, TvDBSeasonNumber);"), new(6, 4, @@ -422,19 +427,19 @@ public override void CreateDatabase() "CREATE TABLE CrossRef_AniDB_TvDBV2( CrossRef_AniDB_TvDBV2ID INTEGER PRIMARY KEY AUTOINCREMENT, AnimeID int NOT NULL, AniDBStartEpisodeType int NOT NULL, AniDBStartEpisodeNumber int NOT NULL, TvDBID int NOT NULL, TvDBSeasonNumber int NOT NULL, TvDBStartEpisodeNumber int NOT NULL, TvDBTitle text, CrossRefSource int NOT NULL ); "), new(29, 2, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TvDBV2 ON CrossRef_AniDB_TvDBV2(AnimeID, TvDBID, TvDBSeasonNumber, TvDBStartEpisodeNumber, AniDBStartEpisodeType, AniDBStartEpisodeNumber);"), - new(29, 3, DatabaseFixes.MigrateTvDBLinks_V1_to_V2), + new(29, 3, DatabaseFixes.NoOperation), new(30, 1, "ALTER TABLE GroupFilter ADD Locked int NULL"), new(31, 1, "ALTER TABLE VideoInfo ADD FullInfo text NULL"), new(32, 1, "CREATE TABLE CrossRef_AniDB_TraktV2( CrossRef_AniDB_TraktV2ID INTEGER PRIMARY KEY AUTOINCREMENT, AnimeID int NOT NULL, AniDBStartEpisodeType int NOT NULL, AniDBStartEpisodeNumber int NOT NULL, TraktID text, TraktSeasonNumber int NOT NULL, TraktStartEpisodeNumber int NOT NULL, TraktTitle text, CrossRefSource int NOT NULL ); "), new(32, 2, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TraktV2 ON CrossRef_AniDB_TraktV2(AnimeID, TraktSeasonNumber, TraktStartEpisodeNumber, AniDBStartEpisodeType, AniDBStartEpisodeNumber);"), - new(32, 3, DatabaseFixes.MigrateTraktLinks_V1_to_V2), + new(32, 3, DatabaseFixes.NoOperation), new(33, 1, "CREATE TABLE CrossRef_AniDB_Trakt_Episode( CrossRef_AniDB_Trakt_EpisodeID INTEGER PRIMARY KEY AUTOINCREMENT, AnimeID int NOT NULL, AniDBEpisodeID int NOT NULL, TraktID text, Season int NOT NULL, EpisodeNumber int NOT NULL ); "), new(33, 2, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_Trakt_Episode_AniDBEpisodeID ON CrossRef_AniDB_Trakt_Episode(AniDBEpisodeID);"), - new(34, 1, DatabaseFixes.RemoveOldMovieDBImageRecords), + new(34, 1, DatabaseFixes.NoOperation), new(35, 1, "CREATE TABLE CustomTag( CustomTagID INTEGER PRIMARY KEY AUTOINCREMENT, TagName text, TagDescription text ); "), new(35, 2, @@ -449,7 +454,7 @@ public override void CreateDatabase() new(43, 1, "ALTER TABLE GroupFilter ADD FilterType int NOT NULL DEFAULT 1"), new(43, 2, $"UPDATE GroupFilter SET FilterType = 2 WHERE GroupFilterName='{Constants.GroupFilterName.ContinueWatching}'"), - new(43, 3, DatabaseFixes.FixContinueWatchingGroupFilter_20160406), + new(43, 3, DatabaseFixes.NoOperation), new(44, 1, DropAniDB_AnimeAllCategories), new(44, 2, "ALTER TABLE AniDB_Anime ADD ContractVersion int NOT NULL DEFAULT 0"), new(44, 3, "ALTER TABLE AniDB_Anime ADD ContractBlob BLOB NULL"), @@ -485,7 +490,7 @@ public override void CreateDatabase() new(44, 33, "ALTER TABLE VideoLocal ADD MediaVersion int NOT NULL DEFAULT 0"), new(44, 34, "ALTER TABLE VideoLocal ADD MediaBlob BLOB NULL"), new(44, 35, "ALTER TABLE VideoLocal ADD MediaSize int NOT NULL DEFAULT 0"), - new(45, 1, DatabaseFixes.DeleteSerieUsersWithoutSeries), + new(45, 1, DatabaseFixes.DeleteSeriesUsersWithoutSeries), new(46, 1, "CREATE TABLE VideoLocal_Place ( VideoLocal_Place_ID INTEGER PRIMARY KEY AUTOINCREMENT,VideoLocalID int NOT NULL, FilePath text NOT NULL, ImportFolderID int NOT NULL, ImportFolderType int NOT NULL )"), new(46, 2, @@ -510,9 +515,9 @@ public override void CreateDatabase() new(49, 2, "CREATE TABLE ScanFile ( ScanFileID INTEGER PRIMARY KEY AUTOINCREMENT, ScanID int NOT NULL, ImportFolderID int NOT NULL, VideoLocal_Place_ID int NOT NULL, FullName text NOT NULL, FileSize bigint NOT NULL, Status int NOT NULL, CheckDate timestamp NULL, Hash text NOT NULL, HashResult text NULL )"), new(49, 3, "CREATE INDEX UIX_ScanFileStatus ON ScanFile(ScanID,Status,CheckDate);"), - new(50, 1, DatabaseFixes.FixTagsWithInclude), - new(51, 1, DatabaseFixes.MakeYearsApplyToSeries), - new(52, 1, DatabaseFixes.FixEmptyVideoInfos), + new(50, 1, DatabaseFixes.NoOperation), + new(51, 1, DatabaseFixes.NoOperation), + new(52, 1, DatabaseFixes.NoOperation), new(53, 1, "ALTER TABLE JMMUser ADD PlexToken text NULL"), new(54, 1, "ALTER TABLE AniDB_File ADD IsChaptered INT NOT NULL DEFAULT -1"), new(55, 1, "ALTER TABLE RenameScript ADD RenamerType TEXT NOT NULL DEFAULT 'Legacy'"), @@ -522,7 +527,7 @@ public override void CreateDatabase() // This adds the new columns `AirDate` and `Rating` as well new(57, 1, "DROP INDEX UIX_TvDB_Episode_Id;"), new(57, 2, DropTvDB_EpisodeFirstAiredColumn), - new(57, 3, DatabaseFixes.UpdateAllTvDBSeries), + new(57, 3, DatabaseFixes.NoOperation), new(58, 1, "ALTER TABLE AnimeSeries ADD AirsOn TEXT NULL"), new(59, 1, "DROP TABLE Trakt_ImageFanart"), new(59, 2, "DROP TABLE Trakt_ImagePoster"), @@ -538,7 +543,7 @@ public override void CreateDatabase() new(62, 1, "ALTER TABLE AniDB_Episode ADD Description TEXT NOT NULL DEFAULT ''"), new(62, 2, DatabaseFixes.FixCharactersWithGrave), new(63, 1, DatabaseFixes.RefreshAniDBInfoFromXML), - new(64, 1, DatabaseFixes.MakeTagsApplyToSeries), + new(64, 1, DatabaseFixes.NoOperation), new(64, 2, DatabaseFixes.UpdateAllStats), new(65, 1, DatabaseFixes.RemoveBasePathsFromStaffAndCharacters), new(66, 1, @@ -546,8 +551,8 @@ public override void CreateDatabase() new(66, 2, "CREATE UNIQUE INDEX UIX_AniDB_AnimeUpdate ON AniDB_AnimeUpdate(AnimeID)"), new(66, 3, DatabaseFixes.MigrateAniDB_AnimeUpdates), new(67, 1, DatabaseFixes.RemoveBasePathsFromStaffAndCharacters), - new(68, 1, DatabaseFixes.FixDuplicateTagFiltersAndUpdateSeasons), - new(69, 1, DatabaseFixes.RecalculateYears), + new(68, 1, DatabaseFixes.NoOperation), + new(69, 1, DatabaseFixes.NoOperation), new(70, 1, "DROP INDEX UIX_CrossRef_AniDB_MAL_Anime;"), new(70, 2, "ALTER TABLE AniDB_Anime ADD Site_JP TEXT NULL"), new(70, 3, "ALTER TABLE AniDB_Anime ADD Site_EN TEXT NULL"), @@ -556,13 +561,13 @@ public override void CreateDatabase() new(70, 6, "ALTER TABLE AniDB_Anime ADD SyoboiID INT NULL"), new(70, 7, "ALTER TABLE AniDB_Anime ADD AnisonID INT NULL"), new(70, 8, "ALTER TABLE AniDB_Anime ADD CrunchyrollID TEXT NULL"), - new(70, 9, DatabaseFixes.PopulateResourceLinks), + new(70, 9, DatabaseFixes.NoOperation), new(71, 1, "ALTER TABLE VideoLocal ADD MyListID INT NOT NULL DEFAULT 0"), - new(71, 2, DatabaseFixes.PopulateMyListIDs), + new(71, 2, DatabaseFixes.NoOperation), new(72, 1, DropAniDB_EpisodeTitles), new(72, 2, "CREATE TABLE AniDB_Episode_Title ( AniDB_Episode_TitleID INTEGER PRIMARY KEY AUTOINCREMENT, AniDB_EpisodeID int NOT NULL, Language text NOT NULL, Title text NOT NULL ); "), - new(72, 3, DatabaseFixes.DummyMigrationOfObsolescence), + new(72, 3, DatabaseFixes.NoOperation), new(73, 1, "DROP INDEX UIX_CrossRef_AniDB_TvDB_Episode_AniDBEpisodeID;"), // SQLite is stupid, so we need to create a new table and copy the contents to it new(73, 2, RenameCrossRef_AniDB_TvDB_Episode), @@ -576,13 +581,13 @@ public override void CreateDatabase() "CREATE TABLE CrossRef_AniDB_TvDB_Episode(CrossRef_AniDB_TvDB_EpisodeID INTEGER PRIMARY KEY AUTOINCREMENT, AniDBEpisodeID int NOT NULL, TvDBEpisodeID int NOT NULL, MatchRating INT NOT NULL);"), new(73, 7, "CREATE UNIQUE INDEX UIX_CrossRef_AniDB_TvDB_Episode_AniDBID_TvDBID ON CrossRef_AniDB_TvDB_Episode(AniDBEpisodeID,TvDBEpisodeID);"), - new(73, 9, DatabaseFixes.MigrateTvDBLinks_v2_to_V3), + new(73, 9, DatabaseFixes.NoOperation), // DatabaseFixes.MigrateTvDBLinks_v2_to_V3() drops the CrossRef_AniDB_TvDBV2 table. We do it after init to migrate - new(74, 1, DatabaseFixes.FixAniDB_EpisodesWithMissingTitles), - new(75, 1, DatabaseFixes.RegenTvDBMatches), + new(74, 1, DatabaseFixes.NoOperation), + new(75, 1, DatabaseFixes.NoOperation), new(76, 1, "ALTER TABLE AnimeSeries ADD UpdatedAt timestamp NOT NULL default '2000-01-01 00:00:00'"), - new(77, 1, DatabaseFixes.MigrateAniDBToNet), + new(77, 1, DatabaseFixes.NoOperation), new(78, 1, DropVideoLocal_Media), new(79, 1, "DROP INDEX IF EXISTS UIX_CrossRef_AniDB_MAL_MALID;"), new(79, 1, "DROP INDEX IF EXISTS UIX_CrossRef_AniDB_MAL_MALID;"), @@ -686,9 +691,213 @@ public override void CreateDatabase() new(112, 1, "ALTER TABLE AniDB_Anime DROP COLUMN ContractVersion;ALTER TABLE AniDB_Anime DROP COLUMN ContractBlob;ALTER TABLE AniDB_Anime DROP COLUMN ContractSize;"), new(112, 2, "ALTER TABLE AnimeSeries DROP COLUMN ContractVersion;ALTER TABLE AnimeSeries DROP COLUMN ContractBlob;ALTER TABLE AnimeSeries DROP COLUMN ContractSize;"), new(112, 3, "ALTER TABLE AnimeGroup DROP COLUMN ContractVersion;ALTER TABLE AnimeGroup DROP COLUMN ContractBlob;ALTER TABLE AnimeGroup DROP COLUMN ContractSize;"), - new DatabaseCommand(113, 1, "ALTER TABLE VideoLocal DROP COLUMN MediaSize;"), + new(113, 1, "ALTER TABLE VideoLocal DROP COLUMN MediaSize;"), + new(114, 1, "CREATE TABLE AniDB_NotifyQueue( AniDB_NotifyQueueID INTEGER PRIMARY KEY AUTOINCREMENT, Type int NOT NULL, ID int NOT NULL, AddedAt timestamp NOT NULL ); "), + new(114, 2, "CREATE TABLE AniDB_Message( AniDB_MessageID INTEGER PRIMARY KEY AUTOINCREMENT, MessageID int NOT NULL, FromUserID int NOT NULL, FromUserName text NOT NULL, SentAt timestamp NOT NULL, FetchedAt timestamp NOT NULL, Type int NOT NULL, Title text NOT NULL, Body text NOT NULL, Flags int NOT NULL DEFAULT 0 ); "), + new(115, 1, "CREATE TABLE CrossRef_AniDB_TMDB_Episode ( CrossRef_AniDB_TMDB_EpisodeID INTEGER PRIMARY KEY AUTOINCREMENT, AnidbAnimeID INTEGER NOT NULL, AnidbEpisodeID INTEGER NOT NULL, TmdbShowID INTEGER NOT NULL, TmdbEpisodeID INTEGER NOT NULL, 'Ordering' INTEGER NOT NULL, MatchRating INTEGER NOT NULL);"), + new(115, 2, "CREATE TABLE CrossRef_AniDB_TMDB_Movie ( CrossRef_AniDB_TMDB_MovieID INTEGER PRIMARY KEY AUTOINCREMENT, AnidbAnimeID INTEGER NOT NULL, AnidbEpisodeID INTEGER NULL, TmdbMovieID INTEGER NOT NULL, Source INTEGER NOT NULL);"), + new(115, 3, "CREATE TABLE CrossRef_AniDB_TMDB_Show ( CrossRef_AniDB_TMDB_ShowID INTEGER PRIMARY KEY AUTOINCREMENT, AnidbAnimeID INTEGER NOT NULL, TmdbShowID INTEGER NOT NULL, Source INTEGER NOT NULL);"), + new(115, 4, "CREATE TABLE TMDB_Image ( TMDB_ImageID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbMovieID INTEGER NULL, TmdbEpisodeID INTEGER NULL, TmdbSeasonID INTEGER NULL, TmdbShowID INTEGER NULL, TmdbCollectionID INTEGER NULL, TmdbNetworkID INTEGER NULL, TmdbCompanyID INTEGER NULL, TmdbPersonID INTEGER NULL, ForeignType INTEGER NOT NULL, ImageType INTEGER NOT NULL, IsEnabled INTEGER NOT NULL, Width INTEGER NOT NULL, Height INTEGER NOT NULL, Language TEXT NOT NULL, RemoteFileName TEXT NOT NULL, UserRating REAL NOT NULL, UserVotes INTEGER NOT NULL );"), + new(115, 5, "CREATE TABLE AniDB_Anime_PreferredImage ( AniDB_Anime_PreferredImageID INTEGER PRIMARY KEY AUTOINCREMENT, AnidbAnimeID INTEGER NOT NULL, ImageID INTEGER NOT NULL, ImageType INTEGER NOT NULL, ImageSource INTEGER NOT NULL );"), + new(115, 6, "CREATE TABLE TMDB_Title ( TMDB_TitleID INTEGER PRIMARY KEY AUTOINCREMENT, ParentID INTEGER NOT NULL, ParentType INTEGER NOT NULL, LanguageCode TEXT NOT NULL, CountryCode TEXT NOT NULL, Value TEXT NOT NULL );"), + new(115, 7, "CREATE TABLE TMDB_Overview ( TMDB_OverviewID INTEGER PRIMARY KEY AUTOINCREMENT, ParentID INTEGER NOT NULL, ParentType INTEGER NOT NULL, LanguageCode TEXT NOT NULL, CountryCode TEXT NOT NULL, Value TEXT NOT NULL );"), + new(115, 8, "CREATE TABLE TMDB_Company ( TMDB_CompanyID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbCompanyID INTEGER NOT NULL, Name TEXT NOT NULL, CountryOfOrigin TEXT NOT NULL );"), + new(115, 9, "CREATE TABLE TMDB_Network ( TMDB_NetworkID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbNetworkID INTEGER NOT NULL, Name TEXT NOT NULL, CountryOfOrigin TEXT NOT NULL );"), + new(115, 10, "CREATE TABLE TMDB_Person ( TMDB_PersonID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbPersonID INTEGER NOT NULL, EnglishName TEXT NOT NULL, EnglishBiography TEXT NOT NULL, Aliases TEXT NOT NULL, Gender INTEGER NOT NULL, IsRestricted INTEGER NOT NULL, BirthDay DATE NULL, DeathDay DATE NULL, PlaceOfBirth TEXT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 11, "CREATE TABLE TMDB_Movie ( TMDB_MovieID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbMovieID INTEGER NOT NULL, TmdbCollectionID INTEGER NULL, EnglishTitle TEXT NOT NULL, EnglishOverview TEXT NOT NULL, OriginalTitle TEXT NOT NULL, OriginalLanguageCode TEXT NOT NULL, IsRestricted INTEGER NOT NULL, IsVideo INTEGER NOT NULL, Genres TEXT NOT NULL, ContentRatings TEXT NOT NULL, Runtime TEXT NULL, UserRating REAL NOT NULL, UserVotes INTEGER NOT NULL, ReleasedAt DATE NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 12, "CREATE TABLE TMDB_Movie_Cast ( TMDB_Movie_CastID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbMovieID INT NOT NULL, TmdbPersonID INT NOT NULL, TmdbCreditID TEXT NOT NULL, CharacterName TEXT NOT NULL, Ordering INTEGER NOT NULL );"), + new(115, 13, "CREATE TABLE TMDB_Company_Entity ( TMDB_Company_EntityID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbCompanyID INTEGER NOT NULL, TmdbEntityType INTEGER NOT NULL, TmdbEntityID INTEGER NOT NULL, 'Ordering' INTEGER NOT NULL, ReleasedAt DATE NULL );"), + new(115, 14, "CREATE TABLE TMDB_Movie_Crew ( TMDB_Movie_CrewID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbMovieID INTEGER NOT NULL, TmdbPersonID INTEGER NOT NULL, TmdbCreditID TEXT NOT NULL, Job TEXT NOT NULL, Department TEXT NOT NULL );"), + new(115, 15, "CREATE TABLE TMDB_Show ( TMDB_ShowID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, EnglishTitle TEXT NOT NULL, EnglishOverview TEXT NOT NULL, OriginalTitle TEXT NOT NULL, OriginalLanguageCode TEXT NOT NULL, IsRestricted INTEGER NOT NULL, Genres TEXT NOT NULL, ContentRatings TEXT NOT NULL, EpisodeCount INTEGER NOT NULL, SeasonCount INTEGER NOT NULL, AlternateOrderingCount INTEGER NOT NULL, UserRating REAL NOT NULL, UserVotes INTEGER NOT NULL, FirstAiredAt DATE, LastAiredAt DATE NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 16, "CREATE TABLE Tmdb_Show_Network ( TMDB_Show_NetworkID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbNetworkID INTEGER NOT NULL, Ordering INTEGER NOT NULL );"), + new(115, 17, "CREATE TABLE TMDB_Season ( TMDB_SeasonID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbSeasonID INTEGER NOT NULL, EnglishTitle TEXT NOT NULL, EnglishOverview TEXT NOT NULL, EpisodeCount INTEGER NOT NULL, SeasonNumber INTEGER NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 18, "CREATE TABLE TMDB_Episode ( TMDB_EpisodeID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbSeasonID INTEGER NOT NULL, TmdbEpisodeID INTEGER NOT NULL, EnglishTitle TEXT NOT NULL, EnglishOverview TEXT NOT NULL, SeasonNumber INTEGER NOT NULL, EpisodeNumber INTEGER NOT NULL, Runtime TEXT NULL, UserRating REAL NOT NULL, UserVotes INTEGER NOT NULL, AiredAt DATE NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 19, "CREATE TABLE TMDB_Episode_Cast ( TMDB_Episode_CastID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbSeasonID INTEGER NOT NULL, TmdbEpisodeID INTEGER NOT NULL, TmdbPersonID INTEGER NOT NULL, TmdbCreditID TEXT NOT NULL, CharacterName TEXT NOT NULL, IsGuestRole INTEGER NOT NULL, Ordering INTEGER NOT NULL );"), + new(115, 20, "CREATE TABLE TMDB_Episode_Crew ( TMDB_Episode_CrewID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbSeasonID INTEGER NOT NULL, TmdbEpisodeID INTEGER NOT NULL, TmdbPersonID INTEGER NOT NULL, TmdbCreditID TEXT NOT NULL, Job TEXT NOT NULL, Department TEXT NOT NULL );"), + new(115, 21, "CREATE TABLE TMDB_AlternateOrdering ( TMDB_AlternateOrderingID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbNetworkID INTEGER NULL, TmdbEpisodeGroupCollectionID TEXT NOT NULL, EnglishTitle TEXT NOT NULL, EnglishOverview TEXT NOT NULL, EpisodeCount INTEGER NOT NULL, SeasonCount INTEGER NOT NULL, Type INTEGER NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 22, "CREATE TABLE TMDB_AlternateOrdering_Season ( TMDB_AlternateOrdering_SeasonID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbEpisodeGroupCollectionID TEXT NOT NULL, TmdbEpisodeGroupID TEXT NOT NULL, EnglishTitle TEXT NOT NULL, SeasonNumber INTEGER NOT NULL, EpisodeCount INTEGER NOT NULL, IsLocked INTEGER NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 23, "CREATE TABLE TMDB_AlternateOrdering_Episode ( TMDB_AlternateOrdering_EpisodeID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbShowID INTEGER NOT NULL, TmdbEpisodeGroupCollectionID TEXT NOT NULL, TmdbEpisodeGroupID TEXT NOT NULL, TmdbEpisodeID INTEGER NOT NULL, SeasonNumber INTEGER NOT NULL, EpisodeNumber INTEGER NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 24, "CREATE TABLE TMDB_Collection ( TMDB_CollectionID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbCollectionID INTEGER NOT NULL, EnglishTitle TEXT NOT NULL, EnglishOverview TEXT NOT NULL, MovieCount INTEGER NOT NULL, CreatedAt DATETIME NOT NULL, LastUpdatedAt DATETIME NOT NULL );"), + new(115, 25, "CREATE TABLE TMDB_Collection_Movie ( TMDB_Collection_MovieID INTEGER PRIMARY KEY AUTOINCREMENT, TmdbCollectionID INTEGER NOT NULL, TmdbMovieID INTEGER NOT NULL, Ordering INTEGER NOT NULL );"), + new(115, 26, "INSERT INTO CrossRef_AniDB_TMDB_Movie (AnidbAnimeID, TmdbMovieID, Source) SELECT AnimeID, CAST(CrossRefID AS INTEGER), CrossRefSource FROM CrossRef_AniDB_Other WHERE CrossRefType = 1;"), + new(115, 27, "DROP TABLE CrossRef_AniDB_Other;"), + new(115, 28, "DROP TABLE MovieDB_Fanart;"), + new(115, 29, "DROP TABLE MovieDB_Movie;"), + new(115, 30, "DROP TABLE MovieDB_Poster;"), + new(115, 31, "DROP TABLE AniDB_Anime_DefaultImage;"), + new(115, 32, "CREATE TABLE AniDB_Episode_PreferredImage ( AniDB_Episode_PreferredImageID INTEGER PRIMARY KEY AUTOINCREMENT, AnidbAnimeID INTEGER NOT NULL, AnidbEpisodeID INTEGER NOT NULL, ImageID INTEGER NOT NULL, ImageType INTEGER NOT NULL, ImageSource INTEGER NOT NULL );"), + new(115, 33, DatabaseFixes.CleanupAfterAddingTMDB), + new(115, 34, "UPDATE FilterPreset SET Expression = REPLACE(Expression, 'HasTMDbLinkExpression', 'HasTmdbLinkExpression');"), + new(115, 35, "UPDATE TMDB_Image SET IsEnabled = 1;"), + new(116, 1, MigrateRenamers), + new(116, 2, "DELETE FROM RenamerInstance WHERE NAME = 'AAA_WORKINGFILE_TEMP_AAA';"), + new(116, 3, DatabaseFixes.CreateDefaultRenamerConfig), + new(117, 1, "UPDATE CrossRef_AniDB_TMDB_Episode SET MatchRating = CASE MatchRating WHEN 'UserVerified' THEN 1 WHEN 'DateAndTitleMatches' THEN 2 WHEN 'DateMatches' THEN 3 WHEN 'TitleMatches' THEN 4 WHEN 'FirstAvailable' THEN 5 WHEN 'SarahJessicaParker' THEN 6 ELSE MatchRating END;"), + new(117, 2, "UPDATE CrossRef_AniDB_TMDB_Show SET Source = CASE Source WHEN 'Automatic' THEN 0 WHEN 'User' THEN 2 ELSE Source END;"), + new(117, 3, "ALTER TABLE TMDB_Show ADD COLUMN TvdbShowID INTEGER NULL DEFAULT NULL;"), + new(117, 4, "ALTER TABLE TMDB_Episode ADD COLUMN TvdbEpisodeID INTEGER NULL DEFAULT NULL;"), + new(117, 5, "ALTER TABLE TMDB_Movie ADD COLUMN ImdbMovieID INTEGER NULL DEFAULT NULL;"), + new(117, 6, "ALTER TABLE TMDB_Movie DROP COLUMN ImdbMovieID;"), + new(117, 7, "ALTER TABLE TMDB_Movie ADD COLUMN ImdbMovieID TEXT NULL DEFAULT NULL;"), + new(117, 8, "CREATE INDEX IX_TMDB_Overview ON TMDB_Overview(ParentType, ParentID)"), + new(117, 9, "CREATE INDEX IX_TMDB_Title ON TMDB_Title(ParentType, ParentID)"), + new(117, 10, "CREATE UNIQUE INDEX UIX_TMDB_Episode_TmdbEpisodeID ON TMDB_Episode(TmdbEpisodeID)"), + new(117, 11, "CREATE UNIQUE INDEX UIX_TMDB_Show_TmdbShowID ON TMDB_Show(TmdbShowID)"), + new(118, 1, "UPDATE CrossRef_AniDB_TMDB_Movie SET AnidbEpisodeID = (SELECT EpisodeID FROM AniDB_Episode WHERE AniDB_Episode.AnimeID = CrossRef_AniDB_TMDB_Movie.AnidbAnimeID ORDER BY EpisodeType, EpisodeNumber LIMIT 1) WHERE AnidbEpisodeID IS NULL AND EXISTS (SELECT 1 FROM AniDB_Episode WHERE AniDB_Episode.AnimeID = CrossRef_AniDB_TMDB_Movie.AnidbAnimeID);"), + new(118, 2, "DELETE FROM CrossRef_AniDB_TMDB_Movie WHERE AnidbEpisodeID IS NULL;"), + new(118, 3, "ALTER TABLE CrossRef_AniDB_TMDB_Movie RENAME AnidbEpisodeID TO AniDBEpisodeID_OLD; ALTER TABLE CrossRef_AniDB_TMDB_Movie ADD COLUMN AnidbEpisodeID INT NOT NULL DEFAULT 0; UPDATE CrossRef_AniDB_TMDB_Movie SET AnidbEpisodeID = AniDBEpisodeID_OLD WHERE AniDBEpisodeID_OLD > 0; ALTER TABLE CrossRef_AniDB_TMDB_Movie DROP COLUMN AniDBEpisodeID_OLD;"), + new(119, 1, "ALTER TABLE TMDB_Movie ADD COLUMN PosterPath TEXT NULL DEFAULT NULL;"), + new(119, 2, "ALTER TABLE TMDB_Movie ADD COLUMN BackdropPath TEXT NULL DEFAULT NULL;"), + new(119, 3, "ALTER TABLE TMDB_Show ADD COLUMN PosterPath TEXT NULL DEFAULT NULL;"), + new(119, 4, "ALTER TABLE TMDB_Show ADD COLUMN BackdropPath TEXT NULL DEFAULT NULL;"), + new(120, 1, "UPDATE FilterPreset SET Expression = REPLACE(Expression, 'MissingTMDbLinkExpression', 'MissingTmdbLinkExpression');"), + new(121, 1, "CREATE TABLE AniDB_Creator ( AniDB_CreatorID INTEGER PRIMARY KEY AUTOINCREMENT, CreatorID INTEGER NOT NULL, Name TEXT NOT NULL, OriginalName TEXT, Type INTEGER NOT NULL DEFAULT 0, ImagePath TEXT, EnglishHomepageUrl TEXT, JapaneseHomepageUrl TEXT, EnglishWikiUrl TEXT, JapaneseWikiUrl TEXT, LastUpdatedAt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00' );"), + new(121, 2, "CREATE TABLE AniDB_Character_Creator ( AniDB_Character_CreatorID INTEGER PRIMARY KEY AUTOINCREMENT, CharacterID INTEGER NOT NULL, CreatorID INTEGER NOT NULL );"), + new(121, 3, "CREATE UNIQUE INDEX UIX_AniDB_Creator_CreatorID ON AniDB_Creator(CreatorID);"), + new(121, 4, "CREATE INDEX UIX_AniDB_Character_Creator_CreatorID ON AniDB_Character_Creator(CreatorID);"), + new(121, 5, "CREATE INDEX UIX_AniDB_Character_Creator_CharacterID ON AniDB_Character_Creator(CharacterID);"), + new(121, 6, "CREATE UNIQUE INDEX UIX_AniDB_Character_Creator_CharacterID_CreatorID ON AniDB_Character_Creator(CharacterID, CreatorID);"), + new(121, 7, "INSERT INTO AniDB_Creator (CreatorID, Name, ImagePath) SELECT SeiyuuID, SeiyuuName, PicName FROM AniDB_Seiyuu;"), + new(121, 8, "INSERT INTO AniDB_Character_Creator (CharacterID, CreatorID) SELECT CharID, SeiyuuID FROM AniDB_Character_Seiyuu;"), + new(121, 9, "DROP TABLE AniDB_Seiyuu;"), + new(121, 10, "DROP TABLE AniDB_Character_Seiyuu;"), + new(122, 1, "ALTER TABLE TMDB_Show ADD COLUMN PreferredAlternateOrderingID TEXT NULL DEFAULT NULL;"), + new(123, 1, "DROP TABLE TvDB_Episode;"), + new(123, 2, "DROP TABLE TvDB_Series;"), + new(123, 3, "DROP TABLE TvDB_ImageFanart;"), + new(123, 4, "DROP TABLE TvDB_ImagePoster;"), + new(123, 5, "DROP TABLE TvDB_ImageWideBanner;"), + new(123, 6, "DROP TABLE CrossRef_AniDB_TvDB;"), + new(123, 7, "DROP TABLE CrossRef_AniDB_TvDB_Episode;"), + new(123, 8, "DROP TABLE CrossRef_AniDB_TvDB_Episode_Override;"), + new(123, 9, "ALTER TABLE Trakt_Show DROP COLUMN TvDB_ID;"), + new(123, 10, "ALTER TABLE Trakt_Show ADD COLUMN TmdbShowID INTEGER NULL;"), + new(123, 11, DatabaseFixes.CleanupAfterRemovingTvDB), + new(123, 12, DatabaseFixes.ClearQuartzQueue), }; + private static Tuple<bool, string> MigrateRenamers(object connection) + { + var factory = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().Instance; + var renamerService = Utils.ServiceContainer.GetRequiredService<RenameFileService>(); + var settingsProvider = Utils.SettingsProvider; + + var sessionFactory = factory.CreateSessionFactory(); + using var session = sessionFactory.OpenSession(); + using var transaction = session.BeginTransaction(); + try + { + const string createCommand = """ + CREATE TABLE IF NOT EXISTS RenamerInstance (ID INTEGER PRIMARY KEY AUTOINCREMENT, Name text NOT NULL, Type text NOT NULL, Settings BLOB); + CREATE INDEX IX_RenamerInstance_Name ON RenamerInstance(Name); + CREATE INDEX IX_RenamerInstance_Type ON RenamerInstance(Type); + """; + + session.CreateSQLQuery(createCommand).ExecuteUpdate(); + + const string selectCommand = "SELECT ScriptName, RenamerType, IsEnabledOnImport, Script FROM RenameScript;"; + var reader = session.CreateSQLQuery(selectCommand) + .AddScalar("ScriptName", NHibernateUtil.String) + .AddScalar("RenamerType", NHibernateUtil.String) + .AddScalar("IsEnabledOnImport", NHibernateUtil.Int32) + .AddScalar("Script", NHibernateUtil.String) + .List<object[]>(); + string defaultName = null; + var renamerInstances = reader.Select(a => + { + try + { + var type = ((string)a[1]).Equals("Legacy") + ? typeof(WebAOMRenamer) + : renamerService.RenamersByKey.ContainsKey((string)a[1]) + ? renamerService.RenamersByKey[(string)a[1]].GetType() + : Type.GetType((string)a[1]); + if (type == null) + { + if ((string)a[1] == "GroupAwareRenamer") + return (Renamer: new RenamerConfig + { + Name = (string)a[0], + Type = typeof(WebAOMRenamer), + Settings = new WebAOMSettings + { + Script = (string)a[3], GroupAwareSorting = true + } + }, IsDefault: (int)a[2] == 1); + + Logger.Warn("A RenameScipt could not be converted to RenamerConfig. Renamer name: " + (string)a[0] + " Renamer type: " + (string)a[1] + + " Script: " + (string)a[3]); + return default; + } + + var settingsType = type.GetInterfaces().FirstOrDefault(b => b.IsGenericType && b.GetGenericTypeDefinition() == typeof(IRenamer<>)) + ?.GetGenericArguments().FirstOrDefault(); + object settings = null; + if (settingsType != null) + { + settings = ActivatorUtilities.CreateInstance(Utils.ServiceContainer, settingsType); + settingsType.GetProperties(BindingFlags.Instance | BindingFlags.Public).FirstOrDefault(b => b.Name == "Script") + ?.SetValue(settings, (string)a[3]); + } + + return (Renamer: new RenamerConfig + { + Name = (string)a[0], Type = type, Settings = settings + }, IsDefault: (int)a[2] == 1); + } + catch (Exception ex) + { + if (a is { Length: >= 4 }) + { + Logger.Warn(ex, "A RenameScipt could not be converted to RenamerConfig. Renamer name: " + a[0] + " Renamer type: " + a[1] + + " Script: " + a[3]); + } + else + { + Logger.Warn(ex, "A RenameScipt could not be converted to RenamerConfig, but there wasn't enough data to log"); + } + + return default; + } + }).WhereNotDefault().GroupBy(a => a.Renamer.Name).SelectMany(a => a.Select((b, i) => + { + // Names are distinct + var renamer = b.Renamer; + if (i > 0) renamer.Name = renamer.Name + "_" + (i + 1); + if (b.IsDefault) defaultName = renamer.Name; + return renamer; + })); + + if (defaultName != null) + { + var settings = settingsProvider.GetSettings(); + settings.Plugins.Renamer.DefaultRenamer = defaultName; + settingsProvider.SaveSettings(settings); + } + + const string insertCommand = "INSERT INTO RenamerInstance (Name, Type, Settings) VALUES (:Name, :Type, :Settings);"; + foreach (var renamer in renamerInstances) + { + var command = session.CreateSQLQuery(insertCommand); + command.SetParameter("Name", renamer.Name); + command.SetParameter("Type", renamer.Type.ToString()); + command.SetParameter("Settings", renamer.Settings == null ? null : MessagePackSerializer.Typeless.Serialize(renamer.Settings)); + command.ExecuteUpdate(); + } + + const string dropCommand = "DROP TABLE RenameScript;"; + session.CreateSQLQuery(dropCommand).ExecuteUpdate(); + transaction.Commit(); + } + catch (Exception e) + { + transaction.Rollback(); + return new Tuple<bool, string>(false, e.ToString()); + } + + return new Tuple<bool, string>(true, null); + } + private static Tuple<bool, string> DropLanguage(object connection) { try @@ -1187,10 +1396,10 @@ protected override long ExecuteScalar(SqliteConnection connection, string comman return long.Parse(sqCommand.ExecuteScalar().ToString()); } - protected override List<object> ExecuteReader(SqliteConnection connection, string command) + protected override List<object[]> ExecuteReader(SqliteConnection connection, string command) { using var sqCommand = new SqliteCommand(command, connection); - var rows = new List<object>(); + var rows = new List<object[]>(); sqCommand.CommandTimeout = 0; using var reader = sqCommand.ExecuteReader(); while (reader.Read()) diff --git a/Shoko.Server/Extensions/ImageResolvers.TOBEMOVETOCOMMONS.cs b/Shoko.Server/Extensions/ImageResolvers.TOBEMOVETOCOMMONS.cs deleted file mode 100644 index 281c82a96..000000000 --- a/Shoko.Server/Extensions/ImageResolvers.TOBEMOVETOCOMMONS.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.IO; -using Shoko.Commons.Extensions; -using Shoko.Commons.Properties; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.ImageDownload; - -namespace Shoko.Server.Extensions; - -public static class ImageResolvers -{ - public static string GetFullImagePath(this MovieDB_Fanart fanart) - { - if (string.IsNullOrEmpty(fanart.URL)) - { - return string.Empty; - } - - //strip out the base URL - var pos = fanart.URL.IndexOf('/', 0); - var fname = fanart.URL.Substring(pos + 1, fanart.URL.Length - pos - 1); - fname = fname.Replace("/", $"{Path.DirectorySeparatorChar}"); - return Path.Combine(ImageUtils.GetMovieDBImagePath(), fname); - } - - public static string GetFullImagePath(this MovieDB_Poster movie) - { - if (string.IsNullOrEmpty(movie.URL)) - { - return string.Empty; - } - - //strip out the base URL - var pos = movie.URL.IndexOf('/', 0); - var fname = movie.URL.Substring(pos + 1, movie.URL.Length - pos - 1); - fname = fname.Replace("/", $"{Path.DirectorySeparatorChar}"); - return Path.Combine(ImageUtils.GetMovieDBImagePath(), fname); - } - - public static string GetFullImagePath(this TvDB_Episode episode) - { - if (string.IsNullOrEmpty(episode.Filename)) - { - return string.Empty; - } - - var fname = episode.Filename; - fname = episode.Filename.Replace("/", $"{Path.DirectorySeparatorChar}"); - return Path.Combine(ImageUtils.GetTvDBImagePath(), fname); - } - - public static string GetFullImagePath(this TvDB_ImageFanart fanart) - { - if (string.IsNullOrEmpty(fanart.BannerPath)) - { - return string.Empty; - } - - var fname = fanart.BannerPath; - fname = fanart.BannerPath.Replace("/", $"{Path.DirectorySeparatorChar}"); - return Path.Combine(ImageUtils.GetTvDBImagePath(), fname); - } - - public static string GetFullImagePath(this TvDB_ImagePoster poster) - { - if (string.IsNullOrEmpty(poster.BannerPath)) - { - return string.Empty; - } - - var fname = poster.BannerPath; - fname = poster.BannerPath.Replace("/", $"{Path.DirectorySeparatorChar}"); - return Path.Combine(ImageUtils.GetTvDBImagePath(), fname); - } - - public static string GetFullImagePath(this TvDB_ImageWideBanner banner) - { - if (string.IsNullOrEmpty(banner.BannerPath)) - { - return string.Empty; - } - - var fname = banner.BannerPath; - fname = banner.BannerPath.Replace("/", $"{Path.DirectorySeparatorChar}"); - return Path.Combine(ImageUtils.GetTvDBImagePath(), fname); - } - - public static void Valid(this TvDB_ImageFanart fanart) - { - if (!File.Exists(fanart.GetFullImagePath())) - { - //clean leftovers - if (File.Exists(fanart.GetFullImagePath())) - { - File.Delete(fanart.GetFullImagePath()); - } - } - } - - public static string GetPosterPath(this AniDB_Character character) - { - if (string.IsNullOrEmpty(character.PicName)) - { - return string.Empty; - } - - return Path.Combine(ImageUtils.GetAniDBCharacterImagePath(character.CharID), character.PicName); - } - - public static string GetPosterPath(this AniDB_Seiyuu seiyuu) - { - if (string.IsNullOrEmpty(seiyuu.PicName)) - { - return string.Empty; - } - - return Path.Combine(ImageUtils.GetAniDBCreatorImagePath(seiyuu.SeiyuuID), seiyuu.PicName); - } - - //The resources need to be moved - public static string GetAnimeTypeDescription(this AniDB_Anime anidbanime) - { - switch (anidbanime.GetAnimeTypeEnum()) - { - case AnimeType.Movie: - return Resources.AnimeType_Movie; - case AnimeType.Other: - return Resources.AnimeType_Other; - case AnimeType.OVA: - return Resources.AnimeType_OVA; - case AnimeType.TVSeries: - return Resources.AnimeType_TVSeries; - case AnimeType.TVSpecial: - return Resources.AnimeType_TVSpecial; - case AnimeType.Web: - return Resources.AnimeType_Web; - default: - return Resources.AnimeType_Other; - } - } -} diff --git a/Shoko.Server/Extensions/ImageResolvers.cs b/Shoko.Server/Extensions/ImageResolvers.cs new file mode 100644 index 000000000..2c7c6bfd6 --- /dev/null +++ b/Shoko.Server/Extensions/ImageResolvers.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Repositories; +using Shoko.Server.Server; +using Shoko.Server.Utilities; + +#nullable enable +namespace Shoko.Server.Extensions; + +public static class ImageResolvers +{ + private static string ResolveAnidbImageUrl(string relativePath) + => string.Format(string.Format(Constants.URLS.AniDB_Images, Constants.URLS.AniDB_Images_Domain), relativePath.Split(Path.DirectorySeparatorChar).LastOrDefault()); + + public static IImageMetadata? GetImageMetadata(this AniDB_Character character, bool preferred = false) + => !string.IsNullOrEmpty(character.PicName) + ? new Image_Base(DataSourceEnum.AniDB, ImageEntityType.Character, character.CharID, character.GetFullImagePath(), ResolveAnidbImageUrl(character.PicName)) + { + IsEnabled = true, + IsPreferred = preferred, + } + : null; + + public static IImageMetadata GetImageMetadata(this AniDB_Anime anime, bool? preferred = null) => + !string.IsNullOrEmpty(anime.Picname) + ? new Image_Base(DataSourceEnum.AniDB, ImageEntityType.Poster, anime.AnimeID, GetFullImagePath(anime), ResolveAnidbImageUrl(anime.Picname)) + { + IsEnabled = anime.ImageEnabled == 1, + IsPreferred = preferred ?? + (RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(anime.AnimeID, ImageEntityType.Poster) is { } preferredImage && + preferredImage!.ImageSource == Shoko.Models.Enums.DataSourceType.AniDB), + } + : new Image_Base(DataSourceEnum.AniDB, ImageEntityType.Poster, anime.AnimeID); + + public static string GetFullImagePath(this AniDB_Anime anime) + { + if (string.IsNullOrEmpty(anime.Picname)) + return string.Empty; + + return Path.Combine(ImageUtils.GetAniDBImagePath(anime.AnimeID), anime.Picname); + } + + public static string GetFullImagePath(this AniDB_Character character) + { + if (string.IsNullOrEmpty(character.PicName)) + return string.Empty; + + return Path.Combine(ImageUtils.GetAniDBCharacterImagePath(character.CharID), character.PicName); + } + + public static IImageMetadata? GetImageMetadata(this AniDB_Creator seiyuu, bool preferred = false) + => !string.IsNullOrEmpty(seiyuu.ImagePath) + ? new Image_Base(DataSourceEnum.AniDB, ImageEntityType.Person, seiyuu.CreatorID, seiyuu.GetFullImagePath(), ResolveAnidbImageUrl(seiyuu.ImagePath)) + { + IsEnabled = true, + IsPreferred = preferred, + } + : null; + + public static string GetFullImagePath(this AniDB_Creator seiyuu) + { + if (string.IsNullOrEmpty(seiyuu.ImagePath)) + return string.Empty; + + return Path.Combine(ImageUtils.GetAniDBCreatorImagePath(seiyuu.CreatorID), seiyuu.ImagePath); + } + + public static IImageMetadata? GetImageMetadata(this AnimeCharacter character, bool preferred = false) + => !string.IsNullOrEmpty(character.ImagePath) + ? new Image_Base(DataSourceEnum.Shoko, ImageEntityType.Character, character.CharacterID, character.GetFullImagePath(), ResolveAnidbImageUrl(character.ImagePath)) + { + IsEnabled = true, + IsPreferred = preferred, + } + : null; + + public static string GetFullImagePath(this AnimeCharacter character) + { + if (string.IsNullOrEmpty(character.ImagePath)) + return string.Empty; + + return Path.Combine(ImageUtils.GetBaseAniDBCharacterImagesPath(), character.ImagePath); + } + + public static IImageMetadata? GetImageMetadata(this AnimeStaff staff, bool preferred = false) + => !string.IsNullOrEmpty(staff.ImagePath) + ? new Image_Base(DataSourceEnum.Shoko, ImageEntityType.Person, staff.StaffID, staff.GetFullImagePath(), ResolveAnidbImageUrl(staff.ImagePath)) + { + IsEnabled = true, + IsPreferred = preferred, + } + : null; + + public static string GetFullImagePath(this AnimeStaff staff) + { + if (string.IsNullOrEmpty(staff.ImagePath)) + return string.Empty; + + return Path.Combine(ImageUtils.GetBaseAniDBCreatorImagesPath(), staff.ImagePath); + } +} diff --git a/Shoko.Server/Extensions/ModelClients.cs b/Shoko.Server/Extensions/ModelClients.cs index c5ee2ac3a..cf19f6f8a 100644 --- a/Shoko.Server/Extensions/ModelClients.cs +++ b/Shoko.Server/Extensions/ModelClients.cs @@ -1,33 +1,38 @@ using System; using System.Collections.Generic; using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Commons.Properties; using Shoko.Models.Client; using Shoko.Models.Enums; using Shoko.Models.Interfaces; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Models; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Models.Trakt; using Shoko.Server.Repositories; using Shoko.Server.Settings; +#nullable enable namespace Shoko.Server.Extensions; public static class ModelClients { public static CL_ServerSettings ToContract(this IServerSettings settings) - { - var httpServerUri = new Uri(settings.AniDb.HTTPServerUrl); - - return new CL_ServerSettings + => new() { AniDB_Username = settings.AniDb.Username, AniDB_Password = settings.AniDb.Password, - AniDB_ServerAddress = httpServerUri.Host, - AniDB_ServerPort = httpServerUri.Port.ToString(), + AniDB_ServerAddress = new Uri(settings.AniDb.HTTPServerUrl).Host, + AniDB_ServerPort = new Uri(settings.AniDb.HTTPServerUrl).Port.ToString(), AniDB_ClientPort = settings.AniDb.ClientPort.ToString(), AniDB_AVDumpClientPort = settings.AniDb.AVDumpClientPort.ToString(), AniDB_AVDumpKey = settings.AniDb.AVDumpKey, AniDB_DownloadRelatedAnime = settings.AniDb.DownloadRelatedAnime, - AniDB_DownloadSimilarAnime = settings.AniDb.DownloadSimilarAnime, + AniDB_DownloadSimilarAnime = false, AniDB_DownloadReviews = settings.AniDb.DownloadReviews, AniDB_DownloadReleaseGroups = settings.AniDb.DownloadReleaseGroups, AniDB_MyList_AddFiles = settings.AniDb.MyList_AddFiles, @@ -40,37 +45,28 @@ public static CL_ServerSettings ToContract(this IServerSettings settings) AniDB_MyList_UpdateFrequency = (int)settings.AniDb.MyList_UpdateFrequency, AniDB_Calendar_UpdateFrequency = (int)settings.AniDb.Calendar_UpdateFrequency, AniDB_Anime_UpdateFrequency = (int)settings.AniDb.Anime_UpdateFrequency, - AniDB_MyListStats_UpdateFrequency = (int)settings.AniDb.MyListStats_UpdateFrequency, + AniDB_MyListStats_UpdateFrequency = (int)ScheduledUpdateFrequency.Never, AniDB_File_UpdateFrequency = (int)settings.AniDb.File_UpdateFrequency, AniDB_DownloadCharacters = settings.AniDb.DownloadCharacters, AniDB_DownloadCreators = settings.AniDb.DownloadCreators, AniDB_MaxRelationDepth = settings.AniDb.MaxRelationDepth, - // Web Cache - WebCache_Address = settings.WebCache.Address, - WebCache_XRefFileEpisode_Get = settings.WebCache.XRefFileEpisode_Get, - WebCache_XRefFileEpisode_Send = settings.WebCache.XRefFileEpisode_Send, - WebCache_TvDB_Get = settings.WebCache.TvDB_Get, - WebCache_TvDB_Send = settings.WebCache.TvDB_Send, - WebCache_Trakt_Get = settings.WebCache.Trakt_Get, - WebCache_Trakt_Send = settings.WebCache.Trakt_Send, - // TvDB - TvDB_AutoLink = settings.TvDB.AutoLink, - TvDB_AutoFanart = settings.TvDB.AutoFanart, - TvDB_AutoFanartAmount = settings.TvDB.AutoFanartAmount, - TvDB_AutoPosters = settings.TvDB.AutoPosters, - TvDB_AutoPostersAmount = settings.TvDB.AutoPostersAmount, - TvDB_AutoWideBanners = settings.TvDB.AutoWideBanners, - TvDB_AutoWideBannersAmount = settings.TvDB.AutoWideBannersAmount, - TvDB_UpdateFrequency = (int)settings.TvDB.UpdateFrequency, - TvDB_Language = settings.TvDB.Language, - - // MovieDB - MovieDB_AutoFanart = settings.MovieDb.AutoFanart, - MovieDB_AutoFanartAmount = settings.MovieDb.AutoFanartAmount, - MovieDB_AutoPosters = settings.MovieDb.AutoPosters, - MovieDB_AutoPostersAmount = settings.MovieDb.AutoPostersAmount, + TvDB_AutoLink = false, + TvDB_AutoFanart = false, + TvDB_AutoFanartAmount = 0, + TvDB_AutoPosters = false, + TvDB_AutoPostersAmount = 0, + TvDB_AutoWideBanners = false, + TvDB_AutoWideBannersAmount = 0, + TvDB_UpdateFrequency = (int)ScheduledUpdateFrequency.Never, + TvDB_Language = "en", + + // TMDB + MovieDB_AutoFanart = settings.TMDB.AutoDownloadBackdrops, + MovieDB_AutoFanartAmount = settings.TMDB.MaxAutoBackdrops, + MovieDB_AutoPosters = settings.TMDB.AutoDownloadPosters, + MovieDB_AutoPostersAmount = settings.TMDB.MaxAutoPosters, // Import settings VideoExtensions = string.Join(",", settings.Import.VideoExtensions), @@ -79,22 +75,22 @@ public static CL_ServerSettings ToContract(this IServerSettings settings) AutoGroupSeriesRelationExclusions = string.Join("|", settings.AutoGroupSeriesRelationExclusions).Replace("alternative", "alternate", StringComparison.InvariantCultureIgnoreCase), FileQualityFilterEnabled = settings.FileQualityFilterEnabled, FileQualityFilterPreferences = SettingsProvider.Serialize(settings.FileQualityPreferences), - Import_MoveOnImport = settings.Import.MoveOnImport, - Import_RenameOnImport = settings.Import.RenameOnImport, + Import_MoveOnImport = settings.Plugins.Renamer.MoveOnImport, + Import_RenameOnImport = settings.Plugins.Renamer.RenameOnImport, Import_UseExistingFileWatchedStatus = settings.Import.UseExistingFileWatchedStatus, RunImportOnStart = settings.Import.RunOnStart, ScanDropFoldersOnStart = settings.Import.ScanDropFoldersOnStart, - Hash_CRC32 = settings.Import.Hash_CRC32, - Hash_MD5 = settings.Import.Hash_MD5, - Hash_SHA1 = settings.Import.Hash_SHA1, + Hash_CRC32 = settings.Import.Hasher.CRC, + Hash_MD5 = settings.Import.Hasher.MD5, + Hash_SHA1 = settings.Import.Hasher.SHA1, SkipDiskSpaceChecks = settings.Import.SkipDiskSpaceChecks, // Language - LanguagePreference = string.Join(",", settings.LanguagePreference), - LanguageUseSynonyms = settings.LanguageUseSynonyms, - EpisodeTitleSource = (int)settings.EpisodeTitleSource, - SeriesDescriptionSource = (int)settings.SeriesDescriptionSource, - SeriesNameSource = (int)settings.SeriesNameSource, + LanguagePreference = string.Join(",", settings.Language.SeriesTitleLanguageOrder), + LanguageUseSynonyms = settings.Language.UseSynonyms, + EpisodeTitleSource = (int)settings.Language.EpisodeTitleSourceOrder.FirstOrDefault(), + SeriesDescriptionSource = (int)settings.Language.DescriptionSourceOrder.FirstOrDefault(), + SeriesNameSource = (int)settings.Language.SeriesTitleSourceOrder.FirstOrDefault(), // trakt Trakt_IsEnabled = settings.TraktTv.Enabled, @@ -117,11 +113,9 @@ public static CL_ServerSettings ToContract(this IServerSettings settings) Plex_Sections = string.Join(",", settings.Plex.Libraries), Plex_ServerHost = settings.Plex.Server }; - } public static CL_AniDB_Anime ToClient(this SVR_AniDB_Anime anime) - { - return new CL_AniDB_Anime + => new() { AniDB_AnimeID = anime.AniDB_AnimeID, AnimeID = anime.AnimeID, @@ -145,7 +139,9 @@ public static CL_AniDB_Anime ToClient(this SVR_AniDB_Anime anime) TempVoteCount = anime.TempVoteCount, AvgReviewRating = anime.AvgReviewRating, ReviewCount = anime.ReviewCount, +#pragma warning disable CS0618 DateTimeUpdated = anime.DateTimeUpdated, +#pragma warning restore CS0618 DateTimeDescUpdated = anime.DateTimeDescUpdated, ImageEnabled = anime.ImageEnabled, Restricted = anime.Restricted, @@ -154,11 +150,9 @@ public static CL_AniDB_Anime ToClient(this SVR_AniDB_Anime anime) LatestEpisodeNumber = anime.LatestEpisodeNumber, DisableExternalLinksFlag = 0 }; - } public static CL_AniDB_GroupStatus ToClient(this AniDB_GroupStatus g) - { - return new CL_AniDB_GroupStatus + => new CL_AniDB_GroupStatus { AniDB_GroupStatusID = g.AniDB_GroupStatusID, AnimeID = g.AnimeID, @@ -170,102 +164,93 @@ public static CL_AniDB_GroupStatus ToClient(this AniDB_GroupStatus g) Votes = g.Votes, EpisodeRange = g.EpisodeRange }; - } - public static CL_IgnoreAnime ToClient(this IgnoreAnime i) - { - var c = new CL_IgnoreAnime + => new() { - IgnoreAnimeID = i.IgnoreAnimeID, JMMUserID = i.JMMUserID, AnimeID = i.AnimeID, IgnoreType = i.IgnoreType + IgnoreAnimeID = i.IgnoreAnimeID, + JMMUserID = i.JMMUserID, + AnimeID = i.AnimeID, + IgnoreType = i.IgnoreType, + Anime = RepoFactory.AniDB_Anime.GetByAnimeID(i.AnimeID).ToClient(), }; - c.Anime = RepoFactory.AniDB_Anime.GetByAnimeID(i.AnimeID).ToClient(); - return c; - } - public static CL_Trakt_Season ToClient(this Trakt_Season season) - { - return new CL_Trakt_Season + public static CrossRef_AniDB_Other? ToClient(this CrossRef_AniDB_TMDB_Movie? xref) + => xref is null ? null : new() { - Trakt_SeasonID = season.Trakt_SeasonID, - Trakt_ShowID = season.Trakt_ShowID, - Season = season.Season, - URL = season.URL, - Episodes = season.GetTraktEpisodes() + CrossRef_AniDB_OtherID = xref.CrossRef_AniDB_TMDB_MovieID, + AnimeID = xref.AnidbAnimeID, + CrossRefType = (int)CrossRefType.MovieDB, + CrossRefID = xref.TmdbMovieID.ToString(), + CrossRefSource = (int)CrossRefSource.User, }; - } - public static CL_Trakt_Show ToClient(this Trakt_Show show) - { - return new CL_Trakt_Show + public static MovieDB_Movie ToClient(this TMDB_Movie movie) + => new() { - Trakt_ShowID = show.Trakt_ShowID, - TraktID = show.TraktID, - Title = show.Title, - Year = show.Year, - URL = show.URL, - Overview = show.Overview, - TvDB_ID = show.TvDB_ID, - Seasons = show.GetTraktSeasons().Select(a => a.ToClient()).ToList() + MovieDB_MovieID = movie.TMDB_MovieID, + MovieId = movie.Id, + MovieName = movie.EnglishTitle, + OriginalName = movie.OriginalTitle, + Overview = movie.EnglishOverview, + Rating = (int)Math.Round(movie.UserRating * 10), }; - } - - public static CL_AniDB_Anime_DefaultImage ToClient(this AniDB_Anime_DefaultImage defaultImage) - { - var imgType = (ImageEntityType)defaultImage.ImageParentType; - IImageEntity parentImage = null; - - switch (imgType) + public static MovieDB_Fanart ToClientFanart(this TMDB_Image image) + => new() { - case ImageEntityType.TvDB_Banner: - parentImage = RepoFactory.TvDB_ImageWideBanner.GetByID(defaultImage.ImageParentID); - break; - case ImageEntityType.TvDB_Cover: - parentImage = RepoFactory.TvDB_ImagePoster.GetByID(defaultImage.ImageParentID); - break; - case ImageEntityType.TvDB_FanArt: - parentImage = RepoFactory.TvDB_ImageFanart.GetByID(defaultImage.ImageParentID); - break; - case ImageEntityType.MovieDB_Poster: - parentImage = RepoFactory.MovieDB_Poster.GetByID(defaultImage.ImageParentID); - break; - case ImageEntityType.MovieDB_FanArt: - parentImage = RepoFactory.MovieDB_Fanart.GetByID(defaultImage.ImageParentID); - break; - } + MovieDB_FanartID = image.TMDB_ImageID, + Enabled = image.IsEnabled ? 1 : 0, + ImageHeight = image.Height, + ImageID = string.Empty, + ImageSize = "original", + ImageType = "backdrop", + ImageWidth = image.Width, + MovieId = image.TmdbMovieID ?? 0, + URL = image.RemoteFileName, + }; - return defaultImage.ToClient(parentImage); - } + public static MovieDB_Poster ToClientPoster(this TMDB_Image image) + => new() + { + MovieDB_PosterID = image.TMDB_ImageID, + Enabled = image.IsEnabled ? 1 : 0, + ImageHeight = image.Height, + ImageID = string.Empty, + ImageSize = "original", + ImageType = "poster", + ImageWidth = image.Width, + MovieId = image.TmdbMovieID ?? 0, + URL = image.RemoteFileName, + }; - public static CL_AniDB_Anime_DefaultImage ToClient(this AniDB_Anime_DefaultImage defaultimage, - IImageEntity parentImage) + public static CL_AniDB_Anime_DefaultImage? ToClient(this AniDB_Anime_PreferredImage image, IImageEntity? parentImage = null) { - var contract = new CL_AniDB_Anime_DefaultImage + parentImage ??= image.GetImageEntity(); + if (parentImage is null) + return null; + + var contract = new CL_AniDB_Anime_DefaultImage() { - AniDB_Anime_DefaultImageID = defaultimage.AniDB_Anime_DefaultImageID, - AnimeID = defaultimage.AnimeID, - ImageParentID = defaultimage.ImageParentID, - ImageParentType = defaultimage.ImageParentType, - ImageType = defaultimage.ImageType + AniDB_Anime_DefaultImageID = image.AniDB_Anime_PreferredImageID, + AnimeID = image.AnidbAnimeID, + ImageParentID = image.ImageID, + ImageParentType = (int)image.ImageType.ToClient(image.ImageSource), + ImageType = image.ImageType switch + { + ImageEntityType.Backdrop => (int)CL_ImageSizeType.Fanart, + ImageEntityType.Poster => (int)CL_ImageSizeType.Poster, + ImageEntityType.Banner => (int)CL_ImageSizeType.WideBanner, + _ => (int)CL_ImageSizeType.Fanart, + }, }; - var imgType = (ImageEntityType)defaultimage.ImageParentType; - switch (imgType) + switch ((CL_ImageEntityType)contract.ImageParentType) { - case ImageEntityType.TvDB_Banner: - contract.TVWideBanner = parentImage as TvDB_ImageWideBanner; - break; - case ImageEntityType.TvDB_Cover: - contract.TVPoster = parentImage as TvDB_ImagePoster; - break; - case ImageEntityType.TvDB_FanArt: - contract.TVFanart = parentImage as TvDB_ImageFanart; - break; - case ImageEntityType.MovieDB_Poster: + case CL_ImageEntityType.MovieDB_Poster: contract.MoviePoster = parentImage as MovieDB_Poster; break; - case ImageEntityType.MovieDB_FanArt: + case CL_ImageEntityType.MovieDB_FanArt: contract.MovieFanart = parentImage as MovieDB_Fanart; break; } @@ -273,10 +258,159 @@ public static CL_AniDB_Anime_DefaultImage ToClient(this AniDB_Anime_DefaultImage return contract; } + public static AniDB_Anime_PreferredImage? ToServer(this CL_AniDB_Anime_DefaultImage image) + => new() + { + AniDB_Anime_PreferredImageID = image.AniDB_Anime_DefaultImageID, + AnidbAnimeID = image.AnimeID, + ImageID = image.ImageParentID, + ImageType = (CL_ImageSizeType)image.ImageType switch + { + CL_ImageSizeType.Poster => ImageEntityType.Poster, + CL_ImageSizeType.Fanart => ImageEntityType.Backdrop, + CL_ImageSizeType.WideBanner => ImageEntityType.Banner, + _ => ImageEntityType.None, + }, + ImageSource = (CL_ImageEntityType)image.ImageParentType switch + { + CL_ImageEntityType.AniDB_Cover => DataSourceType.AniDB, + CL_ImageEntityType.MovieDB_FanArt => DataSourceType.TMDB, + CL_ImageEntityType.MovieDB_Poster => DataSourceType.TMDB, + _ => DataSourceType.None, + }, + }; + + public static ImageEntityType ToServerType(this CL_ImageEntityType type) + => type switch + { + CL_ImageEntityType.AniDB_Character => ImageEntityType.Character, + CL_ImageEntityType.AniDB_Cover => ImageEntityType.Poster, + CL_ImageEntityType.AniDB_Creator => ImageEntityType.Person, + CL_ImageEntityType.Character => ImageEntityType.Character, + CL_ImageEntityType.MovieDB_FanArt => ImageEntityType.Backdrop, + CL_ImageEntityType.MovieDB_Poster => ImageEntityType.Poster, + CL_ImageEntityType.Staff => ImageEntityType.Person, + CL_ImageEntityType.Trakt_Episode => ImageEntityType.Thumbnail, + CL_ImageEntityType.Trakt_Fanart => ImageEntityType.Backdrop, + CL_ImageEntityType.Trakt_Friend => ImageEntityType.Person, + CL_ImageEntityType.Trakt_Poster => ImageEntityType.Poster, + CL_ImageEntityType.UserAvatar => ImageEntityType.Thumbnail, + _ => ImageEntityType.None, + }; + + public static DataSourceType ToServerSource(this CL_ImageEntityType type) + => type switch + { + CL_ImageEntityType.AniDB_Character => DataSourceType.AniDB, + CL_ImageEntityType.AniDB_Cover => DataSourceType.AniDB, + CL_ImageEntityType.AniDB_Creator => DataSourceType.AniDB, + CL_ImageEntityType.Character => DataSourceType.Shoko, + CL_ImageEntityType.MovieDB_FanArt => DataSourceType.TMDB, + CL_ImageEntityType.MovieDB_Poster => DataSourceType.TMDB, + CL_ImageEntityType.Staff => DataSourceType.Shoko, + CL_ImageEntityType.Trakt_Episode => DataSourceType.Trakt, + CL_ImageEntityType.Trakt_Fanart => DataSourceType.Trakt, + CL_ImageEntityType.Trakt_Friend => DataSourceType.Trakt, + CL_ImageEntityType.Trakt_Poster => DataSourceType.Trakt, + CL_ImageEntityType.UserAvatar => DataSourceType.User, + _ => DataSourceType.None, + }; + + public static DataSourceType ToDataSourceType(this DataSourceEnum value) + => value switch + { + DataSourceEnum.AniDB => DataSourceType.AniDB, + DataSourceEnum.AniList => DataSourceType.AniList, + DataSourceEnum.Animeshon => DataSourceType.Animeshon, + DataSourceEnum.Shoko => DataSourceType.Shoko, + DataSourceEnum.TMDB => DataSourceType.TMDB, + DataSourceEnum.Trakt => DataSourceType.Trakt, + DataSourceEnum.User => DataSourceType.User, + _ => DataSourceType.None, + }; + + public static DataSourceEnum ToDataSourceEnum(this DataSourceType value) + => value switch + { + DataSourceType.AniDB => DataSourceEnum.AniDB, + DataSourceType.AniList => DataSourceEnum.AniList, + DataSourceType.Animeshon => DataSourceEnum.Animeshon, + DataSourceType.Shoko => DataSourceEnum.Shoko, + DataSourceType.TMDB => DataSourceEnum.TMDB, + DataSourceType.Trakt => DataSourceEnum.Trakt, + DataSourceType.User => DataSourceEnum.User, + _ => DataSourceEnum.AniDB, + }; + + public static CL_ImageEntityType ToClient(this ImageEntityType type, DataSourceEnum source) + => ToClient(source.ToDataSourceType(), type); + + public static CL_ImageEntityType ToClient(this ImageEntityType type, DataSourceType source) + => ToClient(source, type); + + public static CL_ImageEntityType ToClient(this DataSourceType source, ImageEntityType imageType) + => source switch + { + DataSourceType.AniDB => imageType switch + { + ImageEntityType.Character => CL_ImageEntityType.AniDB_Character, + ImageEntityType.Poster => CL_ImageEntityType.AniDB_Cover, + ImageEntityType.Person => CL_ImageEntityType.AniDB_Creator, + _ => CL_ImageEntityType.None, + }, + DataSourceType.Shoko => imageType switch + { + ImageEntityType.Character => CL_ImageEntityType.Character, + ImageEntityType.Person => CL_ImageEntityType.Staff, + _ => CL_ImageEntityType.None, + }, + DataSourceType.TMDB => imageType switch + { + ImageEntityType.Backdrop => CL_ImageEntityType.MovieDB_FanArt, + ImageEntityType.Poster => CL_ImageEntityType.MovieDB_Poster, + _ => CL_ImageEntityType.None, + }, + DataSourceType.Trakt => imageType switch + { + ImageEntityType.Backdrop => CL_ImageEntityType.MovieDB_FanArt, + ImageEntityType.Poster => CL_ImageEntityType.MovieDB_Poster, + _ => CL_ImageEntityType.None, + }, + DataSourceType.User => imageType switch + { + ImageEntityType.Thumbnail => CL_ImageEntityType.UserAvatar, + _ => CL_ImageEntityType.None, + }, + _ => CL_ImageEntityType.None, + }; + + public static CL_Trakt_Season ToClient(this Trakt_Season season) + => new() + { + Trakt_SeasonID = season.Trakt_SeasonID, + Trakt_ShowID = season.Trakt_ShowID, + Season = season.Season, + URL = season.URL, + Episodes = season.GetTraktEpisodes(), + }; + + public static CL_Trakt_Show ToClient(this Trakt_Show show) + => new() + { + Trakt_ShowID = show.Trakt_ShowID, + TraktID = show.TraktID, + Title = show.Title, + Year = show.Year, + URL = show.URL, + Overview = show.Overview, + TvDB_ID = null, + Seasons = show.GetTraktSeasons() + .Select(a => a.ToClient()) + .ToList(), + }; + public static CL_AniDB_Character ToClient(this AniDB_Character character) - { - var seiyuu = character.GetSeiyuu(); - var contract = new CL_AniDB_Character + => new() { AniDB_CharacterID = character.AniDB_CharacterID, CharID = character.CharID, @@ -284,21 +418,21 @@ public static CL_AniDB_Character ToClient(this AniDB_Character character) CreatorListRaw = character.CreatorListRaw ?? "", CharName = character.CharName, CharKanjiName = character.CharKanjiName, - CharDescription = character.CharDescription + CharDescription = character.CharDescription, + Seiyuu = character.GetCreator()?.ToClient(), }; - if (seiyuu != null) + public static CL_AniDB_Seiyuu ToClient(this AniDB_Creator creator) + => new() { - contract.Seiyuu = seiyuu; - } - - return contract; - } + AniDB_SeiyuuID = creator.AniDB_CreatorID, + SeiyuuID = creator.CreatorID, + SeiyuuName = creator.Name, + PicName = creator.ImagePath, + }; public static CL_AniDB_Episode ToClient(this SVR_AniDB_Episode ep) - { - var titles = RepoFactory.AniDB_Episode_Title.GetByEpisodeID(ep.EpisodeID); - return new CL_AniDB_Episode + => new() { AniDB_EpisodeID = ep.AniDB_EpisodeID, EpisodeID = ep.EpisodeID, @@ -311,27 +445,23 @@ public static CL_AniDB_Episode ToClient(this SVR_AniDB_Episode ep) Description = ep.Description, AirDate = ep.AirDate, DateTimeUpdated = ep.DateTimeUpdated, - Titles = titles.ToDictionary(a => a.LanguageCode, a => a.Title) + Titles = RepoFactory.AniDB_Episode_Title.GetByEpisodeID(ep.EpisodeID) + .ToDictionary(a => a.LanguageCode, a => a.Title), }; - } - public static CL_VideoLocal_Place ToClient(this SVR_VideoLocal_Place vlocalplace) - { - var v = new CL_VideoLocal_Place + public static CL_VideoLocal_Place ToClient(this SVR_VideoLocal_Place vlp) + => new() { - FilePath = vlocalplace.FilePath, - ImportFolderID = vlocalplace.ImportFolderID, - ImportFolderType = vlocalplace.ImportFolderType, - VideoLocalID = vlocalplace.VideoLocalID, - ImportFolder = vlocalplace.ImportFolder, - VideoLocal_Place_ID = vlocalplace.VideoLocal_Place_ID + FilePath = vlp.FilePath, + ImportFolderID = vlp.ImportFolderID, + ImportFolderType = vlp.ImportFolderType, + VideoLocalID = vlp.VideoLocalID, + ImportFolder = vlp.ImportFolder, + VideoLocal_Place_ID = vlp.VideoLocal_Place_ID }; - return v; - } public static CL_AnimeGroup_User DeepCopy(this CL_AnimeGroup_User c) - { - var contract = new CL_AnimeGroup_User + => new() { AnimeGroupID = c.AnimeGroupID, AnimeGroupParentID = c.AnimeGroupParentID, @@ -389,6 +519,34 @@ public static CL_AnimeGroup_User DeepCopy(this CL_AnimeGroup_User c) Stat_SubtitleLanguages = new HashSet<string>(c.Stat_SubtitleLanguages, StringComparer.InvariantCultureIgnoreCase) }; - return contract; - } + + public static CL_AniDB_ReleaseGroup? ToClient(this AniDB_ReleaseGroup? group) + => group is null ? null : new CL_AniDB_ReleaseGroup + { + AniDB_ReleaseGroupID = group.AniDB_ReleaseGroupID, + AnimeCount = group.AnimeCount, + FileCount = group.FileCount, + GroupID = group.GroupID, + GroupName = group.GroupName, + GroupNameShort = group.GroupNameShort, + IRCChannel = group.IRCChannel, + IRCServer = group.IRCServer, + Picname = group.Picname, + Rating = group.Rating, + URL = group.URL, + Votes = group.Votes, + }; + + //The resources need to be moved + public static string GetAnimeTypeDescription(this AniDB_Anime anime) + => anime.GetAnimeTypeEnum() switch + { + AnimeType.Movie => Resources.AnimeType_Movie, + AnimeType.Other => Resources.AnimeType_Other, + AnimeType.OVA => Resources.AnimeType_OVA, + AnimeType.TVSeries => Resources.AnimeType_TVSeries, + AnimeType.TVSpecial => Resources.AnimeType_TVSpecial, + AnimeType.Web => Resources.AnimeType_Web, + _ => Resources.AnimeType_Other, + }; } diff --git a/Shoko.Server/Extensions/ModelDatabase.cs b/Shoko.Server/Extensions/ModelDatabase.cs index 3e7c64ee5..4ebcded67 100644 --- a/Shoko.Server/Extensions/ModelDatabase.cs +++ b/Shoko.Server/Extensions/ModelDatabase.cs @@ -1,53 +1,29 @@ using System.Collections.Generic; -using Microsoft.Extensions.DependencyInjection; using NHibernate; -using Shoko.Models.Enums; using Shoko.Models.Server; -using Shoko.Server.Databases; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Models.Trakt; using Shoko.Server.Repositories; -using Shoko.Server.Repositories.NHibernate; -using Shoko.Server.Utilities; +#nullable enable namespace Shoko.Server.Extensions; public static class ModelDatabase { - public static AniDB_Character GetCharacter(this AniDB_Anime_Character character) - { - return RepoFactory.AniDB_Character.GetByCharID(character.CharID); - } + public static AniDB_Character? GetCharacter(this AniDB_Anime_Character character) + => RepoFactory.AniDB_Character.GetByCharID(character.CharID); - public static AniDB_Seiyuu GetSeiyuu(this AniDB_Character character) - { - var charSeiyuus = RepoFactory.AniDB_Character_Seiyuu.GetByCharID(character.CharID); - return charSeiyuus.Count > 0 ? RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(charSeiyuus[0].SeiyuuID) : null; - } + public static AniDB_Creator? GetCreator(this AniDB_Character character) + => RepoFactory.AniDB_Character_Creator.GetByCharacterID(character.CharID) is { Count: > 0 } characterVAs + ? RepoFactory.AniDB_Creator.GetByCreatorID(characterVAs[0].CreatorID) + : null; - public static MovieDB_Movie GetMovieDB_Movie(this CrossRef_AniDB_Other cross) - { - var databaseFactory = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>(); - using var session = databaseFactory.SessionFactory.OpenSession(); - return cross.CrossRefType != (int)CrossRefType.MovieDB ? null : RepoFactory.MovieDb_Movie.GetByOnlineID(session.Wrap(), int.Parse(cross.CrossRefID)); - } - - public static Trakt_Show GetByTraktShow(this CrossRef_AniDB_TraktV2 cross, ISession session) - { - return RepoFactory.Trakt_Show.GetByTraktSlug(session, cross.TraktID); - } + public static Trakt_Show? GetByTraktShow(this CrossRef_AniDB_TraktV2 cross, ISession session) + => RepoFactory.Trakt_Show.GetByTraktSlug(session, cross.TraktID); public static List<Trakt_Episode> GetTraktEpisodes(this Trakt_Season season) - { - return RepoFactory.Trakt_Episode - .GetByShowIDAndSeason(season.Trakt_ShowID, season.Season); - } + => RepoFactory.Trakt_Episode.GetByShowIDAndSeason(season.Trakt_ShowID, season.Season); public static List<Trakt_Season> GetTraktSeasons(this Trakt_Show show) - { - return RepoFactory.Trakt_Season.GetByShowID(show.Trakt_ShowID); - } - - public static TvDB_Series GetTvDBSeries(this CrossRef_AniDB_TvDB cross) - { - return RepoFactory.TvDB_Series.GetByTvDBID(cross.TvDBID); - } + => RepoFactory.Trakt_Season.GetByShowID(show.Trakt_ShowID); } diff --git a/Shoko.Server/Extensions/ModelProviders.cs b/Shoko.Server/Extensions/ModelProviders.cs index baac3f551..aa1b251f4 100644 --- a/Shoko.Server/Extensions/ModelProviders.cs +++ b/Shoko.Server/Extensions/ModelProviders.cs @@ -1,194 +1,23 @@ using System; -using System.Globalization; -using NLog; using Shoko.Models.Enums; using Shoko.Models.Metro; using Shoko.Models.Server; -using Shoko.Models.TvDB; using Shoko.Server.Models; -using Shoko.Server.Providers.MovieDB; +using Shoko.Server.Models.Trakt; using Shoko.Server.Providers.TraktTV.Contracts; -using Shoko.Server.Repositories; -using TvDbSharper.Dto; namespace Shoko.Server.Extensions; public static class ModelProviders { - private static Logger logger = LogManager.GetCurrentClassLogger(); - - public static void Populate(this MovieDB_Fanart m, MovieDB_Image_Result result, int movieID) - { - m.MovieId = movieID; - m.ImageID = result.ImageID; - m.ImageType = result.ImageType; - m.ImageSize = result.ImageSize; - m.ImageWidth = result.ImageWidth; - m.ImageHeight = result.ImageHeight; - m.Enabled = 1; - } - - public static void Populate(this MovieDB_Movie m, MovieDB_Movie_Result result) - { - m.MovieId = result.MovieID; - m.MovieName = result.MovieName; - m.OriginalName = result.OriginalName; - m.Overview = result.Overview; - m.Rating = (int)Math.Round(result.Rating * 10D); - } - - public static void Populate(this MovieDB_Poster m, MovieDB_Image_Result result, int movieID) - { - m.MovieId = movieID; - m.ImageID = result.ImageID; - m.ImageType = result.ImageType; - m.ImageSize = result.ImageSize; - m.URL = result.URL; - m.ImageWidth = result.ImageWidth; - m.ImageHeight = result.ImageHeight; - m.Enabled = 1; - } - - public static void Populate(this Trakt_Show show, TraktV2ShowExtended tvshow) - { - show.Overview = tvshow.overview; - show.Title = tvshow.title; - show.TraktID = tvshow.ids.slug; - show.TvDB_ID = tvshow.ids.tvdb; - show.URL = tvshow.ShowURL; - show.Year = tvshow.year.ToString(); - } - - public static void Populate(this TvDB_Episode episode, EpisodeRecord apiEpisode) - { - episode.Id = apiEpisode.Id; - episode.SeriesID = apiEpisode.SeriesId; - episode.SeasonID = 0; - episode.SeasonNumber = apiEpisode.AiredSeason ?? 0; - episode.EpisodeNumber = apiEpisode.AiredEpisodeNumber ?? 0; - - var flag = 0; - if (apiEpisode.Filename != string.Empty) - { - flag = 1; - } - - episode.EpImgFlag = flag; - episode.AbsoluteNumber = apiEpisode.AbsoluteNumber ?? 0; - episode.EpisodeName = apiEpisode.EpisodeName ?? string.Empty; - episode.Overview = apiEpisode.Overview; - episode.Filename = apiEpisode.Filename ?? string.Empty; - episode.AirsAfterSeason = apiEpisode.AirsAfterSeason; - episode.AirsBeforeEpisode = apiEpisode.AirsBeforeEpisode; - episode.AirsBeforeSeason = apiEpisode.AirsBeforeSeason; - if (apiEpisode.SiteRating != null) - { - episode.Rating = (int)Math.Round(apiEpisode.SiteRating.Value); - } - - if (!string.IsNullOrEmpty(apiEpisode.FirstAired)) - { - episode.AirDate = - DateTime.ParseExact(apiEpisode.FirstAired, "yyyy-MM-dd", DateTimeFormatInfo.InvariantInfo); - } - } - - public static bool Populate(this TvDB_ImageFanart fanart, int seriesID, Image image) - { - try - { - fanart.SeriesID = seriesID; - fanart.Id = image.Id; - fanart.BannerPath = image.FileName; - fanart.BannerType2 = image.Resolution; - fanart.Colors = string.Empty; - fanart.VignettePath = string.Empty; - return true; - } - catch (Exception ex) - { - logger.Error(ex, "Error in TvDB_ImageFanart.Init: " + ex); - return false; - } - } - - public static bool Populate(this TvDB_ImagePoster poster, int seriesID, Image image) - { - try - { - poster.SeriesID = seriesID; - poster.SeasonNumber = null; - poster.Id = image.Id; - poster.BannerPath = image.FileName; - poster.BannerType = image.KeyType; - poster.BannerType2 = image.Resolution; - return true; - } - catch (Exception ex) - { - logger.Error(ex, "Error in TvDB_ImagePoster.Populate: " + ex); - return false; - } - } - - public static bool Populate(this TvDB_ImageWideBanner poster, int seriesID, Image image) - { - try - { - poster.SeriesID = seriesID; - try - { - poster.SeasonNumber = int.Parse(image.SubKey); - } - catch (FormatException) - { - poster.SeasonNumber = null; - } - - poster.Id = image.Id; - poster.BannerPath = image.FileName; - poster.BannerType = image.KeyType; - poster.BannerType2 = image.Resolution; - return true; - } - catch (Exception ex) - { - logger.Error(ex, "Error in TvDB_ImageWideBanner.Populate: " + ex); - return false; - } - } - - public static void PopulateFromSeriesInfo(this TvDB_Series series, Series apiSeries) + public static void Populate(this Trakt_Show show, TraktV2ShowExtended tvShow) { - series.SeriesID = 0; - series.Overview = string.Empty; - series.SeriesName = string.Empty; - series.Status = string.Empty; - series.Banner = string.Empty; - series.Fanart = string.Empty; - series.Lastupdated = string.Empty; - series.Poster = string.Empty; - - series.SeriesID = apiSeries.Id; - series.SeriesName = apiSeries.SeriesName; - series.Overview = apiSeries.Overview; - series.Banner = apiSeries.Banner; - series.Status = apiSeries.Status; - series.Lastupdated = apiSeries.LastUpdated.ToString(); - if (apiSeries.SiteRating != null) - { - series.Rating = (int)Math.Round(apiSeries.SiteRating.Value * 10); - } - } - - public static void Populate(this TVDB_Series_Search_Response response, SeriesSearchResult series) - { - response.Id = string.Empty; - response.SeriesID = series.Id; - response.SeriesName = series.SeriesName; - response.Overview = series.Overview; - response.Banner = series.Banner; - response.Language = string.Intern("en"); + show.Overview = tvShow.Overview; + show.Title = tvShow.Title; + show.TraktID = tvShow.IDs.TraktSlug; + show.TmdbShowID = tvShow.IDs.TmdbID; + show.URL = tvShow.URL; + show.Year = tvShow.Year.ToString(); } public static Metro_AniDB_Character ToContractMetro(this AniDB_Character character, @@ -202,94 +31,52 @@ public static Metro_AniDB_Character ToContractMetro(this AniDB_Character charact CharKanjiName = character.CharKanjiName, CharDescription = character.CharDescription, CharType = charRel.CharType, - ImageType = (int)ImageEntityType.AniDB_Character, + ImageType = (int)CL_ImageEntityType.AniDB_Character, ImageID = character.AniDB_CharacterID }; - var seiyuu = character.GetSeiyuu(); + var seiyuu = character.GetCreator(); if (seiyuu != null) { - contract.SeiyuuID = seiyuu.AniDB_SeiyuuID; - contract.SeiyuuName = seiyuu.SeiyuuName; - contract.SeiyuuImageType = (int)ImageEntityType.AniDB_Creator; - contract.SeiyuuImageID = seiyuu.AniDB_SeiyuuID; + contract.SeiyuuID = seiyuu.AniDB_CreatorID; + contract.SeiyuuName = seiyuu.Name; + contract.SeiyuuImageType = (int)CL_ImageEntityType.AniDB_Creator; + contract.SeiyuuImageID = seiyuu.CreatorID; } return contract; } - public static void Populate(this SVR_AnimeGroup agroup, SVR_AnimeSeries series) + public static void Populate(this SVR_AnimeGroup group, SVR_AnimeSeries series) { - agroup.Populate(series, DateTime.Now); + group.Populate(series, DateTime.Now); } - public static void Populate(this SVR_AnimeGroup agroup, SVR_AnimeSeries series, DateTime now) + public static void Populate(this SVR_AnimeGroup group, SVR_AnimeSeries series, DateTime now) { var anime = series.AniDB_Anime; - agroup.Description = anime.Description; - var name = series.SeriesName; - agroup.GroupName = name; - agroup.MainAniDBAnimeID = series.AniDB_ID; - agroup.DateTimeUpdated = now; - agroup.DateTimeCreated = now; + group.Description = anime.Description; + var name = series.PreferredTitle; + group.GroupName = name; + group.MainAniDBAnimeID = series.AniDB_ID; + group.DateTimeUpdated = now; + group.DateTimeCreated = now; } - public static void Populate(this SVR_AnimeGroup agroup, SVR_AniDB_Anime anime, DateTime now) + public static void Populate(this SVR_AnimeGroup group, SVR_AniDB_Anime anime, DateTime now) { - agroup.Description = anime.Description; + group.Description = anime.Description; var name = anime.PreferredTitle; - agroup.GroupName = name; - agroup.MainAniDBAnimeID = anime.AnimeID; - agroup.DateTimeUpdated = now; - agroup.DateTimeCreated = now; - } - - public static void Populate(this SVR_AnimeEpisode animeep, SVR_AniDB_Episode anidbEp) - { - animeep.AniDB_EpisodeID = anidbEp.EpisodeID; - animeep.DateTimeUpdated = DateTime.Now; - animeep.DateTimeCreated = DateTime.Now; - } - - public static (int season, int episodeNumber) GetNextEpisode(this TvDB_Episode ep) - { - if (ep == null) - { - return (0, 0); - } - - var epsInSeason = RepoFactory.TvDB_Episode.GetNumberOfEpisodesForSeason(ep.SeriesID, ep.SeasonNumber); - if (ep.EpisodeNumber == epsInSeason) - { - var numberOfSeasons = RepoFactory.TvDB_Episode.GetLastSeasonForSeries(ep.SeriesID); - if (ep.SeasonNumber == numberOfSeasons) - { - return (0, 0); - } - - return (ep.SeasonNumber + 1, 1); - } - - return (ep.SeasonNumber, ep.EpisodeNumber + 1); + group.GroupName = name; + group.MainAniDBAnimeID = anime.AnimeID; + group.DateTimeUpdated = now; + group.DateTimeCreated = now; } - public static (int season, int episodeNumber) GetPreviousEpisode(this TvDB_Episode ep) + public static void Populate(this SVR_AnimeEpisode episode, SVR_AniDB_Episode anidbEpisode) { - // check bounds and exit - if (ep.SeasonNumber == 1 && ep.EpisodeNumber == 1) - { - return (0, 0); - } - - // self explanatory - if (ep.EpisodeNumber > 1) - { - return (ep.SeasonNumber, ep.EpisodeNumber - 1); - } - - // episode number is 1 - // get the last episode of last season - var epsInSeason = RepoFactory.TvDB_Episode.GetNumberOfEpisodesForSeason(ep.SeriesID, ep.SeasonNumber - 1); - return (ep.SeasonNumber - 1, epsInSeason); + episode.AniDB_EpisodeID = anidbEpisode.EpisodeID; + episode.DateTimeUpdated = DateTime.Now; + episode.DateTimeCreated = DateTime.Now; } } diff --git a/Shoko.Server/Extensions/StringExtensions.cs b/Shoko.Server/Extensions/StringExtensions.cs index 9c8c72308..1efd09e93 100644 --- a/Shoko.Server/Extensions/StringExtensions.cs +++ b/Shoko.Server/Extensions/StringExtensions.cs @@ -3,23 +3,47 @@ using System.Globalization; using System.Linq; using System.Text; -using Shoko.Commons.Extensions; namespace Shoko.Server.Extensions; public static class StringExtensions { - public static void Deconstruct(this IList<string> list, out string first, out string second, out string third, out string forth, out IList<string> rest) { - first = list.Count > 0 ? list[0] : ""; - second = list.Count > 1 ? list[1] : ""; - third = list.Count > 2 ? list[2] : ""; - forth = list.Count > 3 ? list[3] : ""; - rest = list.Skip(4).ToList(); + public static void Deconstruct<T>(this IReadOnlyList<T> list, out T first, out T second) + { + first = list.Count > 0 ? list[0] : default; + second = list.Count > 1 ? list[1] : default; + } + + public static void Deconstruct<T>(this IReadOnlyList<T> list, out T first, out T second, out T third) + { + first = list.Count > 0 ? list[0] : default; + second = list.Count > 1 ? list[1] : default; + third = list.Count > 2 ? list[2] : default; + } + + public static void Deconstruct<T>(this IReadOnlyList<T> list, out T first, out T second, out T third, out T forth) + { + first = list.Count > 0 ? list[0] : default; + second = list.Count > 1 ? list[1] : default; + third = list.Count > 2 ? list[2] : default; + forth = list.Count > 3 ? list[3] : default; + } + + public static void Deconstruct<T>(this IReadOnlyList<T> list, out T first, out T second, out T third, out T forth, out T fifth) + { + first = list.Count > 0 ? list[0] : default; + second = list.Count > 1 ? list[1] : default; + third = list.Count > 2 ? list[2] : default; + forth = list.Count > 3 ? list[3] : default; + fifth = list.Count > 4 ? list[4] : default; } public static string Join(this IEnumerable<string> list, char separator) => string.Join(separator, list); + public static string Join(this IEnumerable<string> list, string separator) + => string.Join(separator, list); + public static string ToISO8601Date(this DateTime dt) { return dt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); @@ -78,32 +102,24 @@ public static bool EqualsInvariantIgnoreCase(this string value1, string value2) { return value1.Equals(value2, StringComparison.InvariantCultureIgnoreCase); } - - public static string CamelCaseToNatural(this string text, bool preserveAcronyms=true) + + public static string CamelCaseToNatural(this string text, bool preserveAcronyms = true) { if (string.IsNullOrWhiteSpace(text)) return string.Empty; - StringBuilder newText = new StringBuilder(text.Length * 2); + var newText = new StringBuilder(text.Length * 2); newText.Append(text[0]); - for (int i = 1; i < text.Length; i++) + for (var i = 1; i < text.Length; i++) { if (char.IsUpper(text[i])) if ((text[i - 1] != ' ' && !char.IsUpper(text[i - 1])) || - (preserveAcronyms && char.IsUpper(text[i - 1]) && + (preserveAcronyms && char.IsUpper(text[i - 1]) && i < text.Length - 1 && !char.IsUpper(text[i + 1]))) newText.Append(' '); newText.Append(text[i]); } return newText.ToString(); } - - public static string TrimStart(this string inputText, string value, StringComparison comparisonType = StringComparison.CurrentCultureIgnoreCase) - { - if (string.IsNullOrEmpty(value)) return inputText; - while (!string.IsNullOrEmpty(inputText) && inputText.StartsWith(value, comparisonType)) inputText = inputText[(value.Length - 1)..]; - - return inputText; - } public static string TrimEnd(this string inputText, string value, StringComparison comparisonType = StringComparison.CurrentCultureIgnoreCase) { @@ -112,9 +128,4 @@ public static string TrimEnd(this string inputText, string value, StringComparis return inputText; } - - public static string Trim(this string inputText, string value, StringComparison comparisonType = StringComparison.CurrentCultureIgnoreCase) - { - return TrimStart(TrimEnd(inputText, value, comparisonType), value, comparisonType); - } } diff --git a/Shoko.Server/FileHelper/FileHashHelper.cs b/Shoko.Server/FileHelper/FileHashHelper.cs index ed07290f2..cca1f9e30 100644 --- a/Shoko.Server/FileHelper/FileHashHelper.cs +++ b/Shoko.Server/FileHelper/FileHashHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using NLog; @@ -15,8 +15,11 @@ public class FileHashHelper /// Get all the hash info and video/audio info for a video file /// </summary> /// <param name="fileName"></param> - /// <param name="hashInfo"></param> - /// <param name="vidInfo"></param> + /// <param name="forceRefresh"></param> + /// <param name="hashProgress"></param> + /// <param name="getCRC32"></param> + /// <param name="getMD5"></param> + /// <param name="getSHA1"></param> public static Hashes GetHashInfo(string fileName, bool forceRefresh, Hasher.OnHashProgress hashProgress, bool getCRC32, bool getMD5, bool getSHA1) { diff --git a/Shoko.Server/Filters/FilterEvaluator.cs b/Shoko.Server/Filters/FilterEvaluator.cs index 7dc747e08..cb3d43606 100644 --- a/Shoko.Server/Filters/FilterEvaluator.cs +++ b/Shoko.Server/Filters/FilterEvaluator.cs @@ -5,35 +5,29 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; using Shoko.Server.Filters.Interfaces; using Shoko.Server.Filters.SortingSelectors; using Shoko.Server.Models; using Shoko.Server.Repositories.Cached; -using Shoko.Server.Repositories.Direct; -using Shoko.Server.Repositories.NHibernate; namespace Shoko.Server.Filters; public class FilterEvaluator { - private readonly DatabaseFactory _databaseFactory; private readonly AnimeGroupRepository _groups; + private readonly AnimeSeriesRepository _series; - private readonly CrossRef_AniDB_OtherRepository _crossRefAniDBOther; + private readonly JMMUserRepository _user; + private readonly ILogger<FilterEvaluator> _logger; - public FilterEvaluator(ILogger<FilterEvaluator> logger, AnimeGroupRepository groups, AnimeSeriesRepository series, JMMUserRepository user, - DatabaseFactory databaseFactory, CrossRef_AniDB_OtherRepository crossRefAniDBOther) + public FilterEvaluator(ILogger<FilterEvaluator> logger, AnimeGroupRepository groups, AnimeSeriesRepository series, JMMUserRepository user) { _logger = logger; _groups = groups; _series = series; _user = user; - _databaseFactory = databaseFactory; - _crossRefAniDBOther = crossRefAniDBOther; } /// <summary> @@ -51,7 +45,7 @@ public IEnumerable<IGrouping<int, int>> EvaluateFilter(FilterPreset filter, int? var user = userID != null ? _user.GetByID(userID.Value) : null; - var filterables = filter.ApplyAtSeriesLevel switch + var filterable = filter.ApplyAtSeriesLevel switch { true when needsUser => _series?.GetAll().AsParallel().Where(a => user?.AllowedSeries(a) ?? true).Select(a => new FilterableWithID(a.AnimeSeriesID, a.AnimeGroupID, a.ToFilterable(), a.ToFilterableUserInfo(userID.Value))) ?? @@ -67,7 +61,7 @@ public IEnumerable<IGrouping<int, int>> EvaluateFilter(FilterPreset filter, int? // Filtering var errors = new List<Exception>(); - var filtered = filterables.Where(a => + var filtered = filterable.Where(a => { try { @@ -80,12 +74,12 @@ public IEnumerable<IGrouping<int, int>> EvaluateFilter(FilterPreset filter, int? } }); - if (errors.Any()) + if (errors.Count != 0) _logger.LogError(new AggregateException(errors.DistinctBy(a => a.StackTrace)), "There were one or more errors while evaluating filter: {Filter}", filter); // ordering - var ordered = OrderFilterables(filter, filtered); + var ordered = OrderFilterable(filter, filtered); var result = ordered.GroupBy(a => a.GroupID, a => a.SeriesID); if (!filter.ApplyAtSeriesLevel) @@ -104,10 +98,10 @@ public IEnumerable<IGrouping<int, int>> EvaluateFilter(FilterPreset filter, int? /// <param name="skipSorting"></param> /// <returns>SeriesIDs, grouped by the direct parent GroupID</returns> /// <exception cref="ArgumentNullException"></exception> - public Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>> BatchEvaluateFilters(List<FilterPreset> filters, int? userID, bool skipSorting=false) + public Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>> BatchEvaluateFilters(List<FilterPreset> filters, int? userID, bool skipSorting = false) { ArgumentNullException.ThrowIfNull(filters); - if (!filters.Any()) return new Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>>(); + if (filters.Count == 0) return []; // count it as a user filter if it needs to sort using a user-dependent expression var hasSeries = filters.Any(a => a.ApplyAtSeriesLevel); var seriesNeedsUser = hasSeries && filters.Any(a => @@ -129,9 +123,6 @@ public Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>> BatchEvaluateF if (needsUser && userID == null) throw new ArgumentNullException(nameof(userID)); var user = userID != null ? _user.GetByID(userID.Value) : null; - ILookup<int, CrossRef_AniDB_Other> movieDBMappings; - using (var session = _databaseFactory.SessionFactory.OpenStatelessSession()) - movieDBMappings = _crossRefAniDBOther.GetByAnimeIDsAndType(session.Wrap(), null, CrossRefType.MovieDB); FilterableWithID[] series = null; if (hasSeries) @@ -139,8 +130,8 @@ public Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>> BatchEvaluateF var allowedSeries = _series.GetAll().Where(a => user?.AllowedSeries(a) ?? true); series = seriesNeedsUser ? allowedSeries.Select(a => - new FilterableWithID(a.AnimeSeriesID, a.AnimeGroupID, a.ToFilterable(movieDBMappings), a.ToFilterableUserInfo(userID.Value))).ToArray() - : allowedSeries.Select(a => new FilterableWithID(a.AnimeSeriesID, a.AnimeGroupID, a.ToFilterable(movieDBMappings))).ToArray(); + new FilterableWithID(a.AnimeSeriesID, a.AnimeGroupID, a.ToFilterable(), a.ToFilterableUserInfo(userID.Value))).ToArray() + : allowedSeries.Select(a => new FilterableWithID(a.AnimeSeriesID, a.AnimeGroupID, a.ToFilterable())).ToArray(); } FilterableWithID[] groups = null; @@ -148,30 +139,30 @@ public Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>> BatchEvaluateF { var allowedGroups = _groups.GetAll().Where(a => user?.AllowedGroup(a) ?? true); groups = groupsNeedUser - ? allowedGroups.Select(a => new FilterableWithID(0, a.AnimeGroupID, a.ToFilterable(movieDBMappings), a.ToFilterableUserInfo(userID.Value))) + ? allowedGroups.Select(a => new FilterableWithID(0, a.AnimeGroupID, a.ToFilterable(), a.ToFilterableUserInfo(userID.Value))) .ToArray() - : allowedGroups.Select(a => new FilterableWithID(0, a.AnimeGroupID, a.ToFilterable(movieDBMappings))).ToArray(); + : allowedGroups.Select(a => new FilterableWithID(0, a.AnimeGroupID, a.ToFilterable())).ToArray(); } var filterableMap = filters.Where(a => (a.FilterType & GroupFilterType.Directory) == 0) .ToDictionary(filter => filter, filter => filter.ApplyAtSeriesLevel switch { true => series, false => groups }); var results = new Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>>(); - + filterableMap.AsParallel().AsUnordered().ForAll(kv => { - var (filter, filterables) = kv; + var (filter, filterable) = kv; var expression = filter.Expression; // Filtering - var filtered = filterables.AsParallel().AsUnordered().Where(a => expression?.Evaluate(a.Filterable, a.UserInfo) ?? true).ToArray(); + var filtered = filterable.AsParallel().AsUnordered().Where(a => expression?.Evaluate(a.Filterable, a.UserInfo) ?? true).ToArray(); // Sorting - var ordered = skipSorting ? (IEnumerable<FilterableWithID>)filtered : OrderFilterables(filter, filtered); + var ordered = skipSorting ? (IEnumerable<FilterableWithID>)filtered : OrderFilterable(filter, filtered); // Building Group -> Series map var result = ordered.GroupBy(a => a.GroupID, a => a.SeriesID); // Fill Series IDs for filters calculated at the group level if (!filter.ApplyAtSeriesLevel) result = result.Select(a => new Grouping(a.Key, _series.GetByGroupID(a.Key).Select(ser => ser.AnimeSeriesID))); - lock(results) results[filter] = result; + lock (results) results[filter] = result; }); foreach (var filter in filters.Where(filter => !results.ContainsKey(filter))) @@ -180,7 +171,7 @@ public Dictionary<FilterPreset, IEnumerable<IGrouping<int, int>>> BatchEvaluateF return results; } - private static IOrderedEnumerable<FilterableWithID> OrderFilterables(FilterPreset filter, IEnumerable<FilterableWithID> filtered) + private static IOrderedEnumerable<FilterableWithID> OrderFilterable(FilterPreset filter, IEnumerable<FilterableWithID> filtered) { var nameSorter = new NameSortingSelector(); var ordered = filter.SortingExpression == null ? filtered.OrderBy(a => nameSorter.Evaluate(a.Filterable, a.UserInfo)) : @@ -198,7 +189,7 @@ private static IOrderedEnumerable<FilterableWithID> OrderFilterables(FilterPrese return ordered; } - private record FilterableWithID(int SeriesID, int GroupID, IFilterable Filterable, IFilterableUserInfo UserInfo=null); + private record FilterableWithID(int SeriesID, int GroupID, IFilterable Filterable, IFilterableUserInfo UserInfo = null); private record Grouping(int GroupID, IEnumerable<int> SeriesIDs) : IGrouping<int, int> { diff --git a/Shoko.Server/Filters/FilterExpression.cs b/Shoko.Server/Filters/FilterExpression.cs index f911b5e91..135b9ab60 100644 --- a/Shoko.Server/Filters/FilterExpression.cs +++ b/Shoko.Server/Filters/FilterExpression.cs @@ -9,6 +9,7 @@ public class FilterExpression : IFilterExpression { [IgnoreDataMember] [JsonIgnore] public virtual bool TimeDependent => false; [IgnoreDataMember] [JsonIgnore] public virtual bool UserDependent => false; + [IgnoreDataMember] [JsonIgnore] public virtual bool Deprecated => false; [IgnoreDataMember] [JsonIgnore] public virtual string Name => GetType().Name.TrimEnd("Expression").TrimEnd("Function").TrimEnd("SortingSelector").TrimEnd("Selector").CamelCaseToNatural(); [IgnoreDataMember] [JsonIgnore] public virtual FilterExpressionGroup Group => FilterExpressionGroup.Info; diff --git a/Shoko.Server/Filters/FilterExtensions.cs b/Shoko.Server/Filters/FilterExtensions.cs index 762d0e2ab..8030ec962 100644 --- a/Shoko.Server/Filters/FilterExtensions.cs +++ b/Shoko.Server/Filters/FilterExtensions.cs @@ -4,110 +4,129 @@ using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Models.MediaInfo; -using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels; using Shoko.Server.Models; using Shoko.Server.Providers.AniDB; using Shoko.Server.Repositories; using AnimeType = Shoko.Models.Enums.AnimeType; +using EpisodeType = Shoko.Models.Enums.EpisodeType; +#nullable enable namespace Shoko.Server.Filters; public static class FilterExtensions { - public static bool IsDirectory(this FilterPreset filter) => (filter.FilterType & GroupFilterType.Directory) != 0; - - public static Filterable ToFilterable(this SVR_AnimeSeries series, ILookup<int, CrossRef_AniDB_Other> movieDBLookup = null) + #region Filter + + public static bool IsDirectory(this FilterPreset filter) => filter.FilterType.HasFlag(GroupFilterType.Directory); + + #endregion + + #region Series + + public static Filterable ToFilterable(this SVR_AnimeSeries series) { var filterable = new Filterable { - NameDelegate = () => series.SeriesName, + NameDelegate = () => + series.PreferredTitle, NamesDelegate = () => { - var titles = new HashSet<string>(); - if (!string.IsNullOrEmpty(series.SeriesNameOverride)) titles.Add(series.SeriesNameOverride); - var ani = series.AniDB_Anime; - if (ani != null) titles.UnionWith(ani.GetAllTitles()); - var tvdb = series.TvDBSeries?.Select(t => t.SeriesName).WhereNotNull(); - if (tvdb != null) titles.UnionWith(tvdb); - var group = series.AnimeGroup; - if (group != null) titles.Add(group.GroupName); + var titles = series.Titles.Select(t => t.Title).ToHashSet(); + foreach (var group in series.AllGroupsAbove) + titles.Add(group.GroupName); + return titles; }, - AniDBIDsDelegate = () => new HashSet<string>(){series.AniDB_ID.ToString()}, - SortingNameDelegate = () => series.SeriesName.ToSortName(), + AniDBIDsDelegate = () => + new HashSet<string>() { series.AniDB_ID.ToString() }, + SortingNameDelegate = () => + series.PreferredTitle.ToSortName(), SeriesCountDelegate = () => 1, - AirDateDelegate = () => series.AniDB_Anime?.AirDate, - MissingEpisodesDelegate = () => series.MissingEpisodeCount, - MissingEpisodesCollectingDelegate = () => series.MissingEpisodeCountGroups, - TagsDelegate = () => series.AniDB_Anime?.Tags.Select(a => a.TagName).ToHashSet() ?? [], - CustomTagsDelegate = - () => series.AniDB_Anime?.CustomTags.Select(a => a.TagName).ToHashSet(StringComparer.InvariantCultureIgnoreCase) ?? [], - YearsDelegate = () => series.Years, - SeasonsDelegate = () => series.AniDB_Anime?.Seasons.ToHashSet() ?? [], - HasTvDBLinkDelegate = () => RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(series.AniDB_ID).Any(), - HasMissingTvDbLinkDelegate = () => HasMissingTvDBLink(series), - // expensive, as these are direct - HasTMDbLinkDelegate = () => movieDBLookup?.Contains(series.AniDB_ID) ?? series.MovieDB_Movie != null, - HasMissingTMDbLinkDelegate = () => HasMissingTMDbLink(series, movieDBLookup), - HasTraktLinkDelegate = () => RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(series.AniDB_ID).Any(), - HasMissingTraktLinkDelegate = () => !RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(series.AniDB_ID).Any(), - IsFinishedDelegate = - () => - { - var anime = series.AniDB_Anime; - return anime?.EndDate != null && anime.EndDate.Value < DateTime.Now; - }, + AirDateDelegate = () => + series.AniDB_Anime?.AirDate, + MissingEpisodesDelegate = () => + series.MissingEpisodeCount, + MissingEpisodesCollectingDelegate = () => + series.MissingEpisodeCountGroups, + TagsDelegate = () => + series.AniDB_Anime?.Tags.Select(a => a.TagName).ToHashSet() ?? [], + CustomTagsDelegate = () => + series.AniDB_Anime?.CustomTags.Select(a => a.TagName).ToHashSet() ?? [], + YearsDelegate = () => + series.Years, + SeasonsDelegate = () => + series.AniDB_Anime?.Seasons.ToHashSet() ?? [], + AvailableImageTypesDelegate = () => + series.GetAvailableImageTypes(), + PreferredImageTypesDelegate = () => + series.GetPreferredImageTypes(), + HasTmdbLinkDelegate = () => + series.TmdbShowCrossReferences.Count is > 0 || series.TmdbMovieCrossReferences.Count is > 0, + HasMissingTmdbLinkDelegate = () => + HasMissingTmdbLink(series), + AutomaticTmdbEpisodeLinksDelegate = () => + series.TmdbEpisodeCrossReferences.Count(xref => xref.MatchRating is not MatchRating.UserVerified) + + series.TmdbMovieCrossReferences.Count(xref => xref.Source is not CrossRefSource.User), + UserVerifiedTmdbEpisodeLinksDelegate = () => + series.TmdbEpisodeCrossReferences.Count(xref => xref.MatchRating is MatchRating.UserVerified) + + series.TmdbMovieCrossReferences.Count(xref => xref.Source is CrossRefSource.User), + HasTraktLinkDelegate = () => + series.TraktShowCrossReferences.Count is > 0, + HasMissingTraktLinkDelegate = () => + HasMissingTraktLink(series), + IsFinishedDelegate = () => + series.AniDB_Anime?.EndDate is { } endDate && endDate < DateTime.Now, LastAirDateDelegate = () => - series.EndDate ?? series.AllAnimeEpisodes.Select(a => a.AniDB_Episode?.GetAirDateAsDate()).Where(a => a != null).DefaultIfEmpty().Max(), - AddedDateDelegate = () => series.DateTimeCreated, - LastAddedDateDelegate = () => series.VideoLocals.Select(a => a.DateTimeCreated).DefaultIfEmpty().Max(), - EpisodeCountDelegate = () => series.AniDB_Anime?.EpisodeCountNormal ?? 0, - TotalEpisodeCountDelegate = () => series.AniDB_Anime?.EpisodeCount ?? 0, - LowestAniDBRatingDelegate = () => decimal.Round(Convert.ToDecimal(series.AniDB_Anime?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero), - HighestAniDBRatingDelegate = () => decimal.Round(Convert.ToDecimal(series.AniDB_Anime?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero), - AverageAniDBRatingDelegate = () => decimal.Round(Convert.ToDecimal(series.AniDB_Anime?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero), + series.EndDate ?? series.AllAnimeEpisodes.Select(a => a.AniDB_Episode?.GetAirDateAsDate()).WhereNotNull().DefaultIfEmpty().Max(), + AddedDateDelegate = () => + series.DateTimeCreated, + LastAddedDateDelegate = () => + series.VideoLocals.Select(a => a.DateTimeCreated).DefaultIfEmpty().Max(), + EpisodeCountDelegate = () => + series.AniDB_Anime?.EpisodeCountNormal ?? 0, + TotalEpisodeCountDelegate = () => + series.AniDB_Anime?.EpisodeCount ?? 0, + LowestAniDBRatingDelegate = () => + decimal.Round(Convert.ToDecimal(series.AniDB_Anime?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero), + HighestAniDBRatingDelegate = () => + decimal.Round(Convert.ToDecimal(series.AniDB_Anime?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero), + AverageAniDBRatingDelegate = () => + decimal.Round(Convert.ToDecimal(series.AniDB_Anime?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero), AnimeTypesDelegate = () => - { - var anime = series.AniDB_Anime; - return anime == null - ? [] - : new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) - { - ((AnimeType)anime.AnimeType).ToString() - }; - }, - VideoSourcesDelegate = () => series.VideoLocals.Select(a => a.AniDBFile).Where(a => a != null).Select(a => a.File_Source).ToHashSet(), + series.AniDB_Anime is { } anime + ? new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { ((AnimeType)anime.AnimeType).ToString() } + : [], + VideoSourcesDelegate = () => + series.VideoLocals.Select(a => a.AniDBFile).WhereNotNull().Select(a => a.File_Source).ToHashSet(), SharedVideoSourcesDelegate = () => - { - var sources = series.VideoLocals.Select(b => b.AniDBFile).Where(a => a != null).Select(a => a.File_Source).ToHashSet(); - return sources.Count > 0 ? sources : []; - }, - AudioLanguagesDelegate = - () => series.VideoLocals.Select(a => a.AniDBFile).Where(a => a != null).SelectMany(a => a.Languages.Select(b => b.LanguageName)) - .ToHashSet(StringComparer.InvariantCultureIgnoreCase), + series.VideoLocals.Select(b => b.AniDBFile).WhereNotNull().Select(a => a.File_Source).ToHashSet() is { Count: > 0 } sources ? sources : [], + AudioLanguagesDelegate = () => series.VideoLocals + .Select(a => a.AniDBFile) + .WhereNotNull() + .SelectMany(a => a.Languages.Select(b => b.LanguageName)) + .ToHashSet(StringComparer.InvariantCultureIgnoreCase), SharedAudioLanguagesDelegate = () => - { - var audio = new HashSet<string>(); - var audioNames = series.VideoLocals.Select(b => b.AniDBFile).Where(a => a != null) - .Select(a => a.Languages.Select(b => b.LanguageName)); - if (audioNames.Any()) audio = audioNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)).ToHashSet(); - return audio; - }, - SubtitleLanguagesDelegate = - () => series.VideoLocals.Select(a => a.AniDBFile).Where(a => a != null).SelectMany(a => a.Subtitles.Select(b => b.LanguageName)) - .ToHashSet(StringComparer.InvariantCultureIgnoreCase), + series.VideoLocals.Select(b => b.AniDBFile).WhereNotNull().Select(a => a.Languages.Select(b => b.LanguageName)).ToList() is { Count: > 0 } audioNames + ? audioNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)).ToHashSet() + : [], + SubtitleLanguagesDelegate = () => + series.VideoLocals.Select(a => a.AniDBFile).WhereNotNull().SelectMany(a => a.Subtitles.Select(b => b.LanguageName)).ToHashSet(StringComparer.InvariantCultureIgnoreCase), SharedSubtitleLanguagesDelegate = () => - { - var subtitles = new HashSet<string>(); - var subtitleNames = series.VideoLocals.Select(b => b.AniDBFile).Where(a => a != null) - .Select(a => a.Subtitles.Select(b => b.LanguageName)); - if (subtitleNames.Any()) subtitles = subtitleNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)).ToHashSet(); - return subtitles; - }, + series.VideoLocals.Select(b => b.AniDBFile).WhereNotNull().Select(a => a.Subtitles.Select(b => b.LanguageName)).ToList() is { Count: > 0 } subtitleNames + ? subtitleNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)).ToHashSet() + : [], ResolutionsDelegate = () => - series.VideoLocals.Where(a => a.MediaInfo?.VideoStream != null).Select(a => - MediaInfoUtils.GetStandardResolution(Tuple.Create(a.MediaInfo.VideoStream.Width, a.MediaInfo.VideoStream.Height))).ToHashSet(), - FilePathsDelegate = () => series.VideoLocals.Select(a => a.FirstValidPlace.FilePath).ToHashSet() + series.VideoLocals + .Where(a => a.MediaInfo?.VideoStream is not null) + .Select(a => MediaInfoUtils.GetStandardResolution(Tuple.Create(a.MediaInfo!.VideoStream!.Width, a.MediaInfo!.VideoStream!.Height))) + .ToHashSet(), + ImportFolderIDsDelegate = () => + series.VideoLocals.Select(a => a.FirstValidPlace?.ImportFolderID.ToString()).WhereNotNull().ToHashSet(), + ImportFolderNamesDelegate = () => + series.VideoLocals.Select(a => a.FirstValidPlace?.ImportFolder?.ImportFolderName).WhereNotNull().ToHashSet(), + FilePathsDelegate = () => + series.VideoLocals.Select(a => a.FirstValidPlace?.FilePath).WhereNotNull().ToHashSet(), }; return filterable; @@ -118,9 +137,11 @@ public static FilterableUserInfo ToFilterableUserInfo(this SVR_AnimeSeries serie var anime = series.AniDB_Anime; var user = RepoFactory.AnimeSeries_User.GetByUserAndSeriesID(userID, series.AnimeSeriesID); var vote = anime?.UserVote; - var watchedDates = series.VideoLocals.Select(a => RepoFactory.VideoLocalUser.GetByUserIDAndVideoLocalID(userID, a.VideoLocalID)?.WatchedDate) - .Where(a => a != null).OrderBy(a => a).ToList(); - + var watchedDates = series.VideoLocals + .Select(a => RepoFactory.VideoLocalUser.GetByUserIDAndVideoLocalID(userID, a.VideoLocalID)?.WatchedDate) + .WhereNotNull() + .OrderBy(a => a) + .ToList(); var filterable = new FilterableUserInfo { IsFavoriteDelegate = () => false, @@ -128,150 +149,133 @@ public static FilterableUserInfo ToFilterableUserInfo(this SVR_AnimeSeries serie UnwatchedEpisodesDelegate = () => user?.UnwatchedEpisodeCount ?? 0, LowestUserRatingDelegate = () => vote?.VoteValue ?? 0, HighestUserRatingDelegate = () => vote?.VoteValue ?? 0, - HasVotesDelegate = () => vote != null, + HasVotesDelegate = () => vote is not null, HasPermanentVotesDelegate = () => vote is { VoteType: (int)AniDBVoteType.Anime }, - MissingPermanentVotesDelegate = () => vote is not { VoteType: (int)AniDBVoteType.Anime } && anime?.EndDate != null && anime.EndDate > DateTime.Now, + MissingPermanentVotesDelegate = () => vote is not { VoteType: (int)AniDBVoteType.Anime } && anime?.EndDate is not null && anime.EndDate > DateTime.Now, WatchedDateDelegate = () => watchedDates.FirstOrDefault(), LastWatchedDateDelegate = () => watchedDates.LastOrDefault() }; - return filterable; } - private static bool HasMissingTMDbLink(SVR_AnimeSeries series, ILookup<int, CrossRef_AniDB_Other> movieDBLookup) - { - var anime = series.AniDB_Anime; - if (anime == null) - { - return false; - } - - // TODO update this with the TMDB refactor - if (anime.AnimeType != (int)AnimeType.Movie) - { - return false; - } - - if (anime.Restricted > 0) - { - return false; - } - - return !movieDBLookup?.Contains(series.AniDB_ID) ?? series.MovieDB_Movie == null; - } - - private static bool HasMissingTvDBLink(SVR_AnimeSeries series) - { - var anime = series.AniDB_Anime; - if (anime == null) - { - return false; - } + private static bool HasMissingTmdbLink(SVR_AnimeSeries series) + => !series.IsTMDBAutoMatchingDisabled && series.TmdbShowCrossReferences.Count is 0 && series.TmdbMovieCrossReferences.Count is 0; - if (anime.AnimeType == (int)AnimeType.Movie) - { - return false; - } + private static bool HasMissingTraktLink(SVR_AnimeSeries series) + => !series.IsTraktAutoMatchingDisabled && series.TraktShowCrossReferences.Count is 0; - if (anime.Restricted > 0) - { - return false; - } + #endregion - return !RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(series.AniDB_ID).Any(); - } + #region Group - public static Filterable ToFilterable(this SVR_AnimeGroup group, ILookup<int, CrossRef_AniDB_Other> movieDBLookup = null) + public static Filterable ToFilterable(this SVR_AnimeGroup group) { var series = group.AllSeries; var anime = group.Anime; - var filterable = new Filterable { - NameDelegate = () => group.GroupName, + NameDelegate = () => + group.GroupName, NamesDelegate = () => { - var result = new HashSet<string>() - { - group.GroupName - }; - result.UnionWith(group.AllSeries.SelectMany(a => - { - var titles = new HashSet<string>(); - if (!string.IsNullOrEmpty(a.SeriesNameOverride)) titles.Add(a.SeriesNameOverride); - var ani = a.AniDB_Anime; - if (ani != null) titles.UnionWith(ani.GetAllTitles()); - var tvdb = a.TvDBSeries?.Select(t => t.SeriesName).WhereNotNull(); - if (tvdb != null) titles.UnionWith(tvdb); - return titles; - })); + var result = new HashSet<string>() { group.GroupName }; + foreach (var grp in group.AllGroupsAbove) + result.Add(grp.GroupName); + result.UnionWith(series.SelectMany(a => a.Titles.Select(t => t.Title))); return result; }, - AniDBIDsDelegate = () => group.AllSeries.Select(a => a.AniDB_ID.ToString()).ToHashSet(), - SortingNameDelegate = () => group.GroupName.ToSortName(), - SeriesCountDelegate = () => series.Count, - AirDateDelegate = () => group.AllSeries.Select(a => a.AirDate).DefaultIfEmpty(DateTime.MaxValue).Min(), - LastAirDateDelegate = () => group.AllSeries.SelectMany(a => a.AllAnimeEpisodes).Select(a => - a.AniDB_Episode?.GetAirDateAsDate()).Where(a => a != null).DefaultIfEmpty().Max(), - MissingEpisodesDelegate = () => group.MissingEpisodeCount, - MissingEpisodesCollectingDelegate = () => group.MissingEpisodeCount, - TagsDelegate = () => group.Tags.Select(a => a.TagName).ToHashSet(), - CustomTagsDelegate = () => group.CustomTags.Select(a => a.TagName).ToHashSet(), - YearsDelegate = () => group.Years.ToHashSet(), - SeasonsDelegate = () => group.Seasons.ToHashSet(), - HasTvDBLinkDelegate = () => series.Any(a => RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(a.AniDB_ID).Any()), - HasMissingTvDbLinkDelegate = () => HasMissingTvDBLink(group), - HasTMDbLinkDelegate = () => series.Any(a => a.CrossRefMovieDB != null), - HasMissingTMDbLinkDelegate = () => HasMissingTMDbLink(series), - HasTraktLinkDelegate = () => series.Any(a => RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(a.AniDB_ID).Any()), - HasMissingTraktLinkDelegate = () => series.Any(a => !RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(a.AniDB_ID).Any()), - IsFinishedDelegate = () => group.AllSeries.All(a => a.EndDate != null && a.EndDate <= DateTime.Today), - AddedDateDelegate = () => group.DateTimeCreated, - LastAddedDateDelegate = () => series.SelectMany(a => a.VideoLocals).Select(a => a.DateTimeCreated).DefaultIfEmpty().Max(), - EpisodeCountDelegate = () => series.Sum(a => a.AniDB_Anime?.EpisodeCountNormal ?? 0), - TotalEpisodeCountDelegate = () => series.Sum(a => a.AniDB_Anime?.EpisodeCount ?? 0), - LowestAniDBRatingDelegate = - () => anime.Select(a => decimal.Round(Convert.ToDecimal(a?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero)).DefaultIfEmpty().Min(), - HighestAniDBRatingDelegate = - () => anime.Select(a => decimal.Round(Convert.ToDecimal(a?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero)).DefaultIfEmpty().Max(), - AverageAniDBRatingDelegate = - () => anime.Select(a => decimal.Round(Convert.ToDecimal(a?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero)).DefaultIfEmpty().Average(), - AnimeTypesDelegate = () => new HashSet<string>(anime.Select(a => ((AnimeType)a.AnimeType).ToString()), StringComparer.InvariantCultureIgnoreCase), - VideoSourcesDelegate = () => series.SelectMany(a => a.VideoLocals).Select(a => a.AniDBFile).Where(a => a != null).Select(a => a.File_Source).ToHashSet(), + AniDBIDsDelegate = () => + series.Select(a => a.AniDB_ID.ToString()).ToHashSet(), + SortingNameDelegate = () => + group.GroupName.ToSortName(), + SeriesCountDelegate = () => + series.Count, + AirDateDelegate = () => + series.Select(a => a.AirDate).DefaultIfEmpty(DateTime.MaxValue).Min(), + LastAirDateDelegate = () => + series.SelectMany(a => a.AllAnimeEpisodes).Select(a => + a.AniDB_Episode?.GetAirDateAsDate()).WhereNotNull().DefaultIfEmpty().Max(), + MissingEpisodesDelegate = () => + group.MissingEpisodeCount, + MissingEpisodesCollectingDelegate = () => + group.MissingEpisodeCountGroups, + TagsDelegate = () => + group.Tags.Select(a => a.TagName).ToHashSet(), + CustomTagsDelegate = () => + group.CustomTags.Select(a => a.TagName).ToHashSet(), + YearsDelegate = () => + group.Years, + SeasonsDelegate = () => + group.Seasons, + AvailableImageTypesDelegate = () => + group.AvailableImageTypes, + PreferredImageTypesDelegate = () => + group.PreferredImageTypes, + HasTmdbLinkDelegate = () => + series.Any(a => a.TmdbShowCrossReferences.Count is > 0 || a.TmdbMovieCrossReferences.Count is > 0), + HasMissingTmdbLinkDelegate = () => + series.Any(HasMissingTmdbLink), + AutomaticTmdbEpisodeLinksDelegate = () => + series.Sum(a => + a.TmdbEpisodeCrossReferences.Count(xref => xref.MatchRating is not MatchRating.UserVerified) + + a.TmdbMovieCrossReferences.Count(xref => xref.Source is not CrossRefSource.User) + ), + UserVerifiedTmdbEpisodeLinksDelegate = () => + series.Sum(a => + a.TmdbEpisodeCrossReferences.Count(xref => xref.MatchRating is MatchRating.UserVerified) + + a.TmdbMovieCrossReferences.Count(xref => xref.Source is CrossRefSource.User) + ), + HasTraktLinkDelegate = () => + series.Any(a => a.TraktShowCrossReferences.Count is > 0), + HasMissingTraktLinkDelegate = () => + series.Any(HasMissingTraktLink), + IsFinishedDelegate = () => + series.All(a => a.EndDate is not null && a.EndDate <= DateTime.Today), + AddedDateDelegate = () => + group.DateTimeCreated, + LastAddedDateDelegate = () => + series.SelectMany(a => a.VideoLocals).Select(a => a.DateTimeCreated).DefaultIfEmpty().Max(), + EpisodeCountDelegate = () => + series.Sum(a => a.AniDB_Anime?.EpisodeCountNormal ?? 0), + TotalEpisodeCountDelegate = () => + series.Sum(a => a.AniDB_Anime?.EpisodeCount ?? 0), + LowestAniDBRatingDelegate = () => + anime.Select(a => decimal.Round(Convert.ToDecimal(a?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero)).DefaultIfEmpty().Min(), + HighestAniDBRatingDelegate = () => + anime.Select(a => decimal.Round(Convert.ToDecimal(a?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero)).DefaultIfEmpty().Max(), + AverageAniDBRatingDelegate = () => + anime.Select(a => decimal.Round(Convert.ToDecimal(a?.Rating ?? 0) / 100, 1, MidpointRounding.AwayFromZero)).DefaultIfEmpty().Average(), + AnimeTypesDelegate = () => + new HashSet<string>(anime.Select(a => ((AnimeType)a.AnimeType).ToString()), StringComparer.InvariantCultureIgnoreCase), + VideoSourcesDelegate = () => + series.SelectMany(a => a.VideoLocals).Select(a => a.AniDBFile).WhereNotNull().Select(a => a.File_Source).ToHashSet(), SharedVideoSourcesDelegate = () => - { - var sources = series.SelectMany(a => a.VideoLocals).Select(b => b.AniDBFile).Where(a => a != null).Select(a => a.File_Source).ToHashSet(); - return sources.Count > 0 ? sources : []; - }, - AudioLanguagesDelegate = () => series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).Where(a => a != null) - .SelectMany(a => a.Languages.Select(b => b.LanguageName)).ToHashSet(), + series.SelectMany(a => a.VideoLocals).Select(b => b.AniDBFile).WhereNotNull().Select(a => a.File_Source).ToHashSet() is { Count: > 0 } sources ? sources : [], + AudioLanguagesDelegate = () => + series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).WhereNotNull().SelectMany(a => a.Languages.Select(b => b.LanguageName)).ToHashSet(), SharedAudioLanguagesDelegate = () => - { - var audio = new HashSet<string>(); - var audioLanguageNames = series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).Where(a => a != null) - .Select(a => a.Languages.Select(b => b.LanguageName)); - if (audioLanguageNames.Any()) - audio = audioLanguageNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)) - .ToHashSet(); - return audio; - }, - SubtitleLanguagesDelegate = () => series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).Where(a => a != null) - .SelectMany(a => a.Subtitles.Select(b => b.LanguageName)).ToHashSet(), + series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).WhereNotNull().Select(a => a.Languages.Select(b => b.LanguageName)).ToList() is { Count: > 0 } audioLanguageNames + ? audioLanguageNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)).ToHashSet() + : [], + SubtitleLanguagesDelegate = () => + series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).WhereNotNull().SelectMany(a => a.Subtitles.Select(b => b.LanguageName)).ToHashSet(), SharedSubtitleLanguagesDelegate = () => - { - var subtitles = new HashSet<string>(); - var subtitleLanguageNames = series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).Where(a => a != null) - .Select(a => a.Subtitles.Select(b => b.LanguageName)); - if (subtitleLanguageNames.Any()) - subtitles = subtitleLanguageNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)).ToHashSet(); - return subtitles; - }, - ResolutionsDelegate = - () => series.SelectMany(a => a.VideoLocals).Where(a => a.MediaInfo?.VideoStream != null).Select(a => - MediaInfoUtils.GetStandardResolution(Tuple.Create(a.MediaInfo.VideoStream.Width, a.MediaInfo.VideoStream.Height))).ToHashSet(), - FilePathsDelegate = () => series.SelectMany(s => s.VideoLocals.Select(a => a.FirstValidPlace.FilePath)).ToHashSet() + series.SelectMany(a => a.VideoLocals.Select(b => b.AniDBFile)).WhereNotNull().Select(a => a.Subtitles.Select(b => b.LanguageName)).ToList() is { Count: > 0 } subtitleLanguageNames + ? subtitleLanguageNames.Aggregate((a, b) => a.Intersect(b, StringComparer.InvariantCultureIgnoreCase)).ToHashSet() + : [], + ResolutionsDelegate = () => + series + .SelectMany(a => a.VideoLocals) + .Where(a => a.MediaInfo?.VideoStream is not null) + .Select(a => MediaInfoUtils.GetStandardResolution(Tuple.Create(a.MediaInfo!.VideoStream!.Width, a.MediaInfo!.VideoStream!.Height))) + .ToHashSet(), + ImportFolderIDsDelegate = () => + series.SelectMany(s => s.VideoLocals.Select(a => a.FirstValidPlace?.ImportFolderID.ToString())).WhereNotNull().ToHashSet(), + ImportFolderNamesDelegate = () => + series.SelectMany(s => s.VideoLocals.Select(a => a.FirstValidPlace?.ImportFolder?.ImportFolderName)).WhereNotNull().ToHashSet(), + FilePathsDelegate = () => + series.SelectMany(s => s.VideoLocals.Select(a => a.FirstValidPlace?.FilePath)).WhereNotNull().ToHashSet(), }; - return filterable; } @@ -280,75 +284,50 @@ public static FilterableUserInfo ToFilterableUserInfo(this SVR_AnimeGroup group, var series = group.AllSeries; var anime = group.Anime; var user = RepoFactory.AnimeGroup_User.GetByUserAndGroupID(userID, group.AnimeGroupID); - var vote = anime.Select(a => a.UserVote).Where(a => a is { VoteType: (int)VoteType.AnimePermanent or (int)VoteType.AnimeTemporary }) - .Select(a => a.VoteValue).OrderBy(a => a).ToList(); + var vote = anime.Select(a => a.UserVote) + .Where(a => a is { VoteType: (int)VoteType.AnimePermanent or (int)VoteType.AnimeTemporary }) + .WhereNotNull() + .Select(a => a.VoteValue) + .OrderBy(a => a) + .ToList(); var watchedDates = series.SelectMany(a => a.VideoLocals) - .Select(a => RepoFactory.VideoLocalUser.GetByUserIDAndVideoLocalID(userID, a.VideoLocalID)?.WatchedDate).Where(a => a != null).OrderBy(a => a) + .Select(a => RepoFactory.VideoLocalUser.GetByUserIDAndVideoLocalID(userID, a.VideoLocalID)?.WatchedDate) + .WhereNotNull() + .OrderBy(a => a) .ToList(); + // we only want to filter by watched states from files that we actually have and exclude trailers/credits, etc + int GetEpCount(bool getWatched) + { + var count = 0; + foreach (var ep in series.SelectMany(s => s.AnimeEpisodes)) + { + if (ep.EpisodeTypeEnum is not (EpisodeType.Episode or EpisodeType.Special)) continue; + var vls = ep.VideoLocals; + if (vls.Count == 0 || vls.All(vl => vl.IsIgnored)) continue; + + var isWatched = ep.GetUserRecord(userID)?.IsWatched() ?? false; + if (isWatched == getWatched) + count++; + } + return count; + } + var filterable = new FilterableUserInfo { IsFavoriteDelegate = () => user?.IsFave == 1, - WatchedEpisodesDelegate = () => user?.WatchedEpisodeCount ?? 0, - UnwatchedEpisodesDelegate = () => user?.UnwatchedEpisodeCount ?? 0, + WatchedEpisodesDelegate = () => GetEpCount(true), + UnwatchedEpisodesDelegate = () => GetEpCount(false), LowestUserRatingDelegate = () => vote.FirstOrDefault(), HighestUserRatingDelegate = () => vote.LastOrDefault(), HasVotesDelegate = () => vote.Any(), HasPermanentVotesDelegate = () => anime.Select(a => a.UserVote).Any(a => a is { VoteType: (int)VoteType.AnimePermanent }), - MissingPermanentVotesDelegate = () => anime.Any(a => a.UserVote is not { VoteType: (int)VoteType.AnimePermanent } && a.EndDate != null && a.EndDate > DateTime.Now), + MissingPermanentVotesDelegate = () => anime.Any(a => a.UserVote is not { VoteType: (int)VoteType.AnimePermanent } && a.EndDate is not null && a.EndDate > DateTime.Now), WatchedDateDelegate = () => watchedDates.FirstOrDefault(), LastWatchedDateDelegate = () => watchedDates.LastOrDefault() }; - return filterable; } - private static bool HasMissingTMDbLink(IEnumerable<SVR_AnimeSeries> series) - { - return series.Any(s => - { - var anime = s.AniDB_Anime; - if (anime == null) - { - return false; - } - - // TODO update this with the TMDB refactor - if (anime.AnimeType != (int)AnimeType.Movie) - { - return false; - } - - if (anime.Restricted > 0) - { - return false; - } - - return s.CrossRefMovieDB != null; - }); - } - - private static bool HasMissingTvDBLink(SVR_AnimeGroup group) - { - return group.AllSeries.Any(series => - { - var anime = series.AniDB_Anime; - if (anime == null) - { - return false; - } - - if (anime.AnimeType == (int)AnimeType.Movie) - { - return false; - } - - if (anime.Restricted > 0) - { - return false; - } - - return !RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(series.AniDB_ID).Any(); - }); - } + #endregion } diff --git a/Shoko.Server/Filters/Filterable.cs b/Shoko.Server/Filters/Filterable.cs index 90b2b72c3..03fe902da 100644 --- a/Shoko.Server/Filters/Filterable.cs +++ b/Shoko.Server/Filters/Filterable.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Filters.Interfaces; namespace Shoko.Server.Filters; @@ -14,12 +15,12 @@ public class Filterable : IFilterable private readonly Lazy<decimal> _averageAniDBRating; private readonly Lazy<IReadOnlySet<string>> _customTags; private readonly Lazy<int> _episodeCount; - private readonly Lazy<bool> _hasMissingTMDbLink; + private readonly Lazy<bool> _hasMissingTmdbLink; private readonly Lazy<bool> _hasMissingTraktLink; - private readonly Lazy<bool> _hasMissingTvDBLink; - private readonly Lazy<bool> _hasTMDbLink; + private readonly Lazy<bool> _hasTmdbLink; + private readonly Lazy<int> _automaticTmdbEpisodeLinks; + private readonly Lazy<int> _userVerifiedTmdbEpisodeLinks; private readonly Lazy<bool> _hasTraktLink; - private readonly Lazy<bool> _hasTvDBLink; private readonly Lazy<decimal> _highestAniDBRating; private readonly Lazy<bool> _isFinished; private readonly Lazy<DateTime> _lastAddedDate; @@ -31,6 +32,8 @@ public class Filterable : IFilterable private readonly Lazy<IReadOnlySet<string>> _names; private readonly Lazy<IReadOnlySet<string>> _aniDbIds; private readonly Lazy<IReadOnlySet<string>> _resolutions; + private readonly Lazy<IReadOnlySet<string>> _importFolderIDs; + private readonly Lazy<IReadOnlySet<string>> _importFolderNames; private readonly Lazy<IReadOnlySet<string>> _filePaths; private readonly Lazy<IReadOnlySet<(int year, AnimeSeason season)>> _seasons; private readonly Lazy<int> _seriesCount; @@ -43,6 +46,8 @@ public class Filterable : IFilterable private readonly Lazy<int> _totalEpisodeCount; private readonly Lazy<IReadOnlySet<string>> _videoSources; private readonly Lazy<IReadOnlySet<int>> _years; + private readonly Lazy<IReadOnlySet<ImageEntityType>> _availableImageTypes; + private readonly Lazy<IReadOnlySet<ImageEntityType>> _preferredImageTypes; public string Name => _name.Value; @@ -121,32 +126,46 @@ public Func<IReadOnlySet<int>> YearsDelegate init => _seasons = new Lazy<IReadOnlySet<(int year, AnimeSeason season)>>(value); } - public bool HasTvDBLink => _hasTvDBLink.Value; + public IReadOnlySet<ImageEntityType> AvailableImageTypes => _availableImageTypes.Value; - public Func<bool> HasTvDBLinkDelegate + public Func<IReadOnlySet<ImageEntityType>> AvailableImageTypesDelegate { - init => _hasTvDBLink = new Lazy<bool>(value); + init => _availableImageTypes = new Lazy<IReadOnlySet<ImageEntityType>>(value); } - public bool HasMissingTvDbLink => _hasMissingTvDBLink.Value; + public IReadOnlySet<ImageEntityType> PreferredImageTypes => _preferredImageTypes.Value; - public Func<bool> HasMissingTvDbLinkDelegate + public Func<IReadOnlySet<ImageEntityType>> PreferredImageTypesDelegate { - init => _hasMissingTvDBLink = new Lazy<bool>(value); + init => _preferredImageTypes = new Lazy<IReadOnlySet<ImageEntityType>>(value); } - public bool HasTMDbLink => _hasTMDbLink.Value; + public bool HasTmdbLink => _hasTmdbLink.Value; - public Func<bool> HasTMDbLinkDelegate + public Func<bool> HasTmdbLinkDelegate { - init => _hasTMDbLink = new Lazy<bool>(value); + init => _hasTmdbLink = new Lazy<bool>(value); } - public bool HasMissingTMDbLink => _hasMissingTMDbLink.Value; + public bool HasMissingTmdbLink => _hasMissingTmdbLink.Value; - public Func<bool> HasMissingTMDbLinkDelegate + public Func<bool> HasMissingTmdbLinkDelegate { - init => _hasMissingTMDbLink = new Lazy<bool>(value); + init => _hasMissingTmdbLink = new Lazy<bool>(value); + } + + public int AutomaticTmdbEpisodeLinks => _automaticTmdbEpisodeLinks.Value; + + public Func<int> AutomaticTmdbEpisodeLinksDelegate + { + init => _automaticTmdbEpisodeLinks = new Lazy<int>(value); + } + + public int UserVerifiedTmdbEpisodeLinks => _userVerifiedTmdbEpisodeLinks.Value; + + public Func<int> UserVerifiedTmdbEpisodeLinksDelegate + { + init => _userVerifiedTmdbEpisodeLinks = new Lazy<int>(value); } public bool HasTraktLink => _hasTraktLink.Value; @@ -290,7 +309,21 @@ public Func<IReadOnlySet<string>> ResolutionsDelegate _resolutions = new Lazy<IReadOnlySet<string>>(value); } } - + + public IReadOnlySet<string> ImportFolderIDs => _importFolderIDs.Value; + + public Func<IReadOnlySet<string>> ImportFolderIDsDelegate + { + init => _importFolderIDs = new Lazy<IReadOnlySet<string>>(value); + } + + public IReadOnlySet<string> ImportFolderNames => _importFolderNames.Value; + + public Func<IReadOnlySet<string>> ImportFolderNamesDelegate + { + init => _importFolderNames = new Lazy<IReadOnlySet<string>>(value); + } + public IReadOnlySet<string> FilePaths => _filePaths.Value; public Func<IReadOnlySet<string>> FilePathsDelegate diff --git a/Shoko.Server/Filters/Info/HasAvailableImageExpression.cs b/Shoko.Server/Filters/Info/HasAvailableImageExpression.cs new file mode 100644 index 000000000..42b9b178b --- /dev/null +++ b/Shoko.Server/Filters/Info/HasAvailableImageExpression.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Filters.Interfaces; +using Shoko.Server.Repositories; + +namespace Shoko.Server.Filters.Info; + +public class HasAvailableImageExpression : FilterExpression<bool>, IWithStringParameter +{ + public HasAvailableImageExpression(string parameter) + { + if (Enum.TryParse<ImageEntityType>(parameter, out var imageEntityType)) + imageEntityType = ImageEntityType.None; + Parameter = imageEntityType; + } + + public HasAvailableImageExpression() { } + + public ImageEntityType Parameter { get; set; } + public override bool TimeDependent => true; + public override bool UserDependent => false; + public override string HelpDescription => "This condition passes if any of the anime has the available image type."; + public override string[] HelpPossibleParameters => RepoFactory.AnimeSeries.GetAllImageTypes().Select(a => a.ToString()).ToArray(); + + string IWithStringParameter.Parameter + { + get => Parameter.ToString(); + set + { + if (Enum.TryParse<ImageEntityType>(value, out var imageEntityType)) + imageEntityType = ImageEntityType.None; + Parameter = imageEntityType; + } + } + + public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.AvailableImageTypes.Contains(Parameter); + } + + protected bool Equals(HasAvailableImageExpression other) + { + return base.Equals(other) && Parameter == other.Parameter; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((HasAvailableImageExpression)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), Parameter); + } + + public static bool operator ==(HasAvailableImageExpression left, HasAvailableImageExpression right) + { + return Equals(left, right); + } + + public static bool operator !=(HasAvailableImageExpression left, HasAvailableImageExpression right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/Filters/Info/HasPreferredImageExpression.cs b/Shoko.Server/Filters/Info/HasPreferredImageExpression.cs new file mode 100644 index 000000000..51e057ddc --- /dev/null +++ b/Shoko.Server/Filters/Info/HasPreferredImageExpression.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Filters.Interfaces; +using Shoko.Server.Repositories; + +namespace Shoko.Server.Filters.Info; + +public class HasPreferredImageExpression : FilterExpression<bool>, IWithStringParameter +{ + public HasPreferredImageExpression(string parameter) + { + if (Enum.TryParse<ImageEntityType>(parameter, out var imageEntityType)) + imageEntityType = ImageEntityType.None; + Parameter = imageEntityType; + } + + public HasPreferredImageExpression() { } + + public ImageEntityType Parameter { get; set; } + public override bool TimeDependent => true; + public override bool UserDependent => false; + public override string HelpDescription => "This condition passes if any of the anime has the preferred image type."; + public override string[] HelpPossibleParameters => RepoFactory.AnimeSeries.GetAllImageTypes().Select(a => a.ToString()).ToArray(); + + string IWithStringParameter.Parameter + { + get => Parameter.ToString(); + set + { + if (Enum.TryParse<ImageEntityType>(value, out var imageEntityType)) + imageEntityType = ImageEntityType.None; + Parameter = imageEntityType; + } + } + + public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.PreferredImageTypes.Contains(Parameter); + } + + protected bool Equals(HasPreferredImageExpression other) + { + return base.Equals(other) && Parameter == other.Parameter; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((HasPreferredImageExpression)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), Parameter); + } + + public static bool operator ==(HasPreferredImageExpression left, HasPreferredImageExpression right) + { + return Equals(left, right); + } + + public static bool operator !=(HasPreferredImageExpression left, HasPreferredImageExpression right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/Filters/Info/HasTMDbLinkExpression.cs b/Shoko.Server/Filters/Info/HasTmdbLinkExpression.cs similarity index 65% rename from Shoko.Server/Filters/Info/HasTMDbLinkExpression.cs rename to Shoko.Server/Filters/Info/HasTmdbLinkExpression.cs index 448e44cf9..27652605f 100644 --- a/Shoko.Server/Filters/Info/HasTMDbLinkExpression.cs +++ b/Shoko.Server/Filters/Info/HasTmdbLinkExpression.cs @@ -2,19 +2,19 @@ namespace Shoko.Server.Filters.Info; -public class HasTMDbLinkExpression : FilterExpression<bool> +public class HasTmdbLinkExpression : FilterExpression<bool> { public override bool TimeDependent => false; public override bool UserDependent => false; - public override string Name => "Has TMDb Link"; - public override string HelpDescription => "This condition passes if any of the anime have a TMDb link"; + public override string Name => "Has TMDB Link"; + public override string HelpDescription => "This condition passes if any of the anime have a TMDB link"; public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) { - return filterable.HasTMDbLink; + return filterable.HasTmdbLink; } - protected bool Equals(HasTMDbLinkExpression other) + protected bool Equals(HasTmdbLinkExpression other) { return base.Equals(other); } @@ -36,7 +36,7 @@ public override bool Equals(object obj) return false; } - return Equals((HasTMDbLinkExpression)obj); + return Equals((HasTmdbLinkExpression)obj); } public override int GetHashCode() @@ -44,12 +44,12 @@ public override int GetHashCode() return GetType().FullName!.GetHashCode(); } - public static bool operator ==(HasTMDbLinkExpression left, HasTMDbLinkExpression right) + public static bool operator ==(HasTmdbLinkExpression left, HasTmdbLinkExpression right) { return Equals(left, right); } - public static bool operator !=(HasTMDbLinkExpression left, HasTMDbLinkExpression right) + public static bool operator !=(HasTmdbLinkExpression left, HasTmdbLinkExpression right) { return !Equals(left, right); } diff --git a/Shoko.Server/Filters/Info/HasTvDBLinkExpression.cs b/Shoko.Server/Filters/Info/HasTvDBLinkExpression.cs index 1a1b55180..0ab9ecea9 100644 --- a/Shoko.Server/Filters/Info/HasTvDBLinkExpression.cs +++ b/Shoko.Server/Filters/Info/HasTvDBLinkExpression.cs @@ -2,16 +2,18 @@ namespace Shoko.Server.Filters.Info; +// TODO: REMOVE THIS FILTER EXPRESSION SOMETIME IN THE FUTURE AFTER THE LEGACY FILTERS ARE REMOVED!!1! public class HasTvDBLinkExpression : FilterExpression<bool> { public override bool TimeDependent => false; public override bool UserDependent => false; public override string Name => "Has TvDB Link"; public override string HelpDescription => "This condition passes if any of the anime have a TvDB link"; + public override bool Deprecated => true; public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) { - return filterable.HasTvDBLink; + return false; } protected bool Equals(HasTvDBLinkExpression other) diff --git a/Shoko.Server/Filters/Info/MissingTMDbLinkExpression.cs b/Shoko.Server/Filters/Info/MissingTmdbLinkExpression.cs similarity index 65% rename from Shoko.Server/Filters/Info/MissingTMDbLinkExpression.cs rename to Shoko.Server/Filters/Info/MissingTmdbLinkExpression.cs index 04717661a..62b7435ea 100644 --- a/Shoko.Server/Filters/Info/MissingTMDbLinkExpression.cs +++ b/Shoko.Server/Filters/Info/MissingTmdbLinkExpression.cs @@ -5,19 +5,19 @@ namespace Shoko.Server.Filters.Info; /// <summary> /// Missing Links include logic for whether a link should exist /// </summary> -public class MissingTMDbLinkExpression : FilterExpression<bool> +public class MissingTmdbLinkExpression : FilterExpression<bool> { public override bool TimeDependent => false; public override bool UserDependent => false; - public override string Name => "Missing TMDb Link"; - public override string HelpDescription => "This condition passes if any of the anime should have a TMDb link but does not have one"; + public override string Name => "Missing TMDB Link"; + public override string HelpDescription => "This condition passes if any of the anime should have a TMDB link but does not have one"; public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) { - return filterable.HasMissingTMDbLink; + return filterable.HasMissingTmdbLink; } - protected bool Equals(MissingTMDbLinkExpression other) + protected bool Equals(MissingTmdbLinkExpression other) { return base.Equals(other); } @@ -39,7 +39,7 @@ public override bool Equals(object obj) return false; } - return Equals((MissingTMDbLinkExpression)obj); + return Equals((MissingTmdbLinkExpression)obj); } public override int GetHashCode() @@ -47,12 +47,12 @@ public override int GetHashCode() return GetType().FullName!.GetHashCode(); } - public static bool operator ==(MissingTMDbLinkExpression left, MissingTMDbLinkExpression right) + public static bool operator ==(MissingTmdbLinkExpression left, MissingTmdbLinkExpression right) { return Equals(left, right); } - public static bool operator !=(MissingTMDbLinkExpression left, MissingTMDbLinkExpression right) + public static bool operator !=(MissingTmdbLinkExpression left, MissingTmdbLinkExpression right) { return !Equals(left, right); } diff --git a/Shoko.Server/Filters/Info/MissingTvDBLinkExpression.cs b/Shoko.Server/Filters/Info/MissingTvDBLinkExpression.cs index 06830a13d..e59977bc0 100644 --- a/Shoko.Server/Filters/Info/MissingTvDBLinkExpression.cs +++ b/Shoko.Server/Filters/Info/MissingTvDBLinkExpression.cs @@ -2,6 +2,7 @@ namespace Shoko.Server.Filters.Info; +// TODO: REMOVE THIS FILTER EXPRESSION SOMETIME IN THE FUTURE AFTER THE LEGACY FILTERS ARE REMOVED!!1! /// <summary> /// Missing Links include logic for whether a link should exist /// </summary> @@ -11,10 +12,11 @@ public class MissingTvDBLinkExpression : FilterExpression<bool> public override bool UserDependent => false; public override string Name => "Missing TvDB Link"; public override string HelpDescription => "This condition passes if any of the anime should have a TvDB link but does not have one"; + public override bool Deprecated => true; public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) { - return filterable.HasMissingTvDbLink; + return false; } protected bool Equals(MissingTvDBLinkExpression other) diff --git a/Shoko.Server/Filters/Interfaces/IFilterExpression.cs b/Shoko.Server/Filters/Interfaces/IFilterExpression.cs index bbc9964db..f6fc9fe3b 100644 --- a/Shoko.Server/Filters/Interfaces/IFilterExpression.cs +++ b/Shoko.Server/Filters/Interfaces/IFilterExpression.cs @@ -5,6 +5,7 @@ public interface IFilterExpression bool TimeDependent { get; } bool UserDependent { get; } string HelpDescription { get; } + bool Deprecated { get; } } public interface IFilterExpression<out T> diff --git a/Shoko.Server/Filters/Interfaces/IFilterable.cs b/Shoko.Server/Filters/Interfaces/IFilterable.cs index 5c0042f1f..7201b1802 100644 --- a/Shoko.Server/Filters/Interfaces/IFilterable.cs +++ b/Shoko.Server/Filters/Interfaces/IFilterable.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; namespace Shoko.Server.Filters.Interfaces; @@ -30,12 +31,12 @@ public interface IFilterable /// The number of series in a group /// </summary> int SeriesCount { get; } - + /// <summary> /// Number of Missing Episodes /// </summary> int MissingEpisodes { get; } - + /// <summary> /// Number of Missing Episodes from Groups that you have /// </summary> @@ -50,67 +51,77 @@ public interface IFilterable /// All of the custom tags /// </summary> IReadOnlySet<string> CustomTags { get; } - + /// <summary> /// The years this aired in /// </summary> IReadOnlySet<int> Years { get; } - + /// <summary> /// The seasons this aired in /// </summary> IReadOnlySet<(int year, AnimeSeason season)> Seasons { get; } /// <summary> - /// Has at least one TvDB Link + /// Available image types. + /// </summary> + IReadOnlySet<ImageEntityType> AvailableImageTypes { get; } + + /// <summary> + /// Preferred image types. + /// </summary> + IReadOnlySet<ImageEntityType> PreferredImageTypes { get; } + + /// <summary> + /// Has at least one TMDB Link /// </summary> - bool HasTvDBLink { get; } + bool HasTmdbLink { get; } /// <summary> - /// Missing at least one TvDB Link + /// Missing at least one TMDB Link /// </summary> - bool HasMissingTvDbLink { get; } - + bool HasMissingTmdbLink { get; } + /// <summary> - /// Has at least one TMDb Link + /// Number of automatic TMDB episode links. /// </summary> - bool HasTMDbLink { get; } - + int AutomaticTmdbEpisodeLinks { get; } + /// <summary> - /// Missing at least one TMDb Link + /// Number of user verified TMDB episode links. /// </summary> - bool HasMissingTMDbLink { get; } - + int UserVerifiedTmdbEpisodeLinks { get; } + /// <summary> /// Has at least one Trakt Link /// </summary> bool HasTraktLink { get; } - + /// <summary> /// Missing at least one Trakt Link /// </summary> bool HasMissingTraktLink { get; } - + /// <summary> /// Has Finished airing /// </summary> bool IsFinished { get; } - + /// <summary> /// First Air Date /// </summary> DateTime? AirDate { get; } - + /// <summary> /// Latest Air Date /// </summary> DateTime? LastAirDate { get; } - + /// <summary> /// When it was first added to the collection /// </summary> DateTime AddedDate { get; } - + /// <summary> /// When it was most recently added to the collection /// </summary> @@ -125,12 +136,12 @@ public interface IFilterable /// Total Episode Count /// </summary> int TotalEpisodeCount { get; } - + /// <summary> /// Lowest AniDB Rating (0-10) /// </summary> decimal LowestAniDBRating { get; } - + /// <summary> /// Highest AniDB Rating (0-10) /// </summary> @@ -145,7 +156,7 @@ public interface IFilterable /// The sources that the video came from, such as TV, Web, DVD, Blu-ray, etc. /// </summary> IReadOnlySet<string> VideoSources { get; } - + /// <summary> /// The sources that the video came from, such as TV, Web, DVD, Blu-ray, etc. (only sources that are in every file) /// </summary> @@ -160,17 +171,17 @@ public interface IFilterable /// Audio Languages /// </summary> IReadOnlySet<string> AudioLanguages { get; } - + /// <summary> /// Audio Languages (only languages that are in every file) /// </summary> IReadOnlySet<string> SharedAudioLanguages { get; } - + /// <summary> /// Subtitle Languages /// </summary> IReadOnlySet<string> SubtitleLanguages { get; } - + /// <summary> /// Subtitle Languages (only languages that are in every file) /// </summary> @@ -181,6 +192,16 @@ public interface IFilterable /// </summary> IReadOnlySet<string> Resolutions { get; } + /// <summary> + /// Import Folder IDs + /// </summary> + IReadOnlySet<string> ImportFolderIDs { get; } + + /// <summary> + /// Import Folder Names + /// </summary> + IReadOnlySet<string> ImportFolderNames { get; } + /// <summary> /// Relative File Paths /// </summary> diff --git a/Shoko.Server/Filters/Interfaces/IWithBoolParameter.cs b/Shoko.Server/Filters/Interfaces/IWithBoolParameter.cs new file mode 100644 index 000000000..0497c3909 --- /dev/null +++ b/Shoko.Server/Filters/Interfaces/IWithBoolParameter.cs @@ -0,0 +1,8 @@ +using System; + +namespace Shoko.Server.Filters.Interfaces; + +public interface IWithBoolParameter +{ + bool Parameter { get; set; } +} diff --git a/Shoko.Server/Filters/Legacy/LegacyConditionConverter.cs b/Shoko.Server/Filters/Legacy/LegacyConditionConverter.cs index 8a1f6bf04..a1e2f76aa 100644 --- a/Shoko.Server/Filters/Legacy/LegacyConditionConverter.cs +++ b/Shoko.Server/Filters/Legacy/LegacyConditionConverter.cs @@ -31,7 +31,7 @@ public static bool TryConvertToConditions(FilterPreset filter, out List<GroupFil // treat null expression similar to All if (expression == null) { - conditions = new List<GroupFilterCondition>(); + conditions = []; baseCondition = GroupFilterBaseCondition.Include; return true; } @@ -39,7 +39,7 @@ public static bool TryConvertToConditions(FilterPreset filter, out List<GroupFil if (TryGetSingleCondition(expression, out var condition)) { baseCondition = GroupFilterBaseCondition.Include; - conditions = new List<GroupFilterCondition> { condition }; + conditions = [condition]; return true; } @@ -103,7 +103,9 @@ private static bool TryGetGroupCondition(FilterExpression expression, out GroupF condition = new GroupFilterCondition { - ConditionType = (int)GroupFilterConditionType.AnimeGroup, ConditionOperator = (int)conditionOperator, ConditionParameter = nameExpression.Parameter + ConditionType = (int)GroupFilterConditionType.AnimeGroup, + ConditionOperator = (int)conditionOperator, + ConditionParameter = nameExpression.Parameter, }; return true; } @@ -123,7 +125,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.MissingEpisodes + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.MissingEpisodes, }; return true; } @@ -132,7 +135,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.MissingEpisodesCollecting + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.MissingEpisodesCollecting, }; return true; } @@ -141,7 +145,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.HasUnwatchedEpisodes + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.HasUnwatchedEpisodes, }; return true; } @@ -150,7 +155,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.HasWatchedEpisodes + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.HasWatchedEpisodes, }; return true; } @@ -159,7 +165,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.UserVoted + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.UserVoted, }; return true; } @@ -168,7 +175,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.UserVotedAny + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.UserVotedAny, }; return true; } @@ -177,16 +185,18 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.AssignedTvDBInfo + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.AssignedTvDBInfo, }; return true; } - if (type == typeof(HasTMDbLinkExpression)) + if (type == typeof(HasTmdbLinkExpression)) { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.AssignedMovieDBInfo + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.AssignedMovieDBInfo, }; return true; } @@ -195,7 +205,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.AssignedTraktInfo + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.AssignedTraktInfo, }; return true; } @@ -204,7 +215,8 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.Favourite + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.Favourite, }; return true; } @@ -213,16 +225,18 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.FinishedAiring + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.FinishedAiring, }; return true; } - if (type == typeof(HasTMDbLinkExpression)) + if (type == typeof(HasTmdbLinkExpression)) { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.AssignedMovieDBInfo + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.AssignedMovieDBInfo, }; return true; } @@ -231,16 +245,18 @@ private static bool TryGetIncludeCondition(FilterExpression expression, out Grou { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.CompletedSeries + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.CompletedSeries, }; return true; } - if (expression == new OrExpression(new HasTvDBLinkExpression(), new HasTMDbLinkExpression())) + if (expression == new OrExpression(new HasTvDBLinkExpression(), new HasTmdbLinkExpression())) { condition = new GroupFilterCondition { - ConditionOperator = (int)conditionOperator, ConditionType = (int)GroupFilterConditionType.AssignedTvDBOrMovieDBInfo + ConditionOperator = (int)conditionOperator, + ConditionType = (int)GroupFilterConditionType.AssignedTvDBOrMovieDBInfo, }; return true; } @@ -494,73 +510,73 @@ private static bool TryGetComparatorCondition(FilterExpression expression, out G private static bool IsInTag(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasTagExpression), parameters); } private static bool IsInCustomTag(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasCustomTagExpression), parameters); } private static bool IsInAnimeType(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasAnimeTypeExpression), parameters); } private static bool IsInVideoQuality(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasVideoSourceExpression), parameters); } private static bool IsInSharedVideoQuality(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasSharedVideoSourceExpression), parameters); } private static bool IsInGroup(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasNameExpression), parameters); } private static bool IsInYear(FilterExpression expression, out List<int> parameters) { - parameters = new List<int>(); + parameters = []; return TryParseIn(expression, typeof(InYearExpression), parameters); } private static bool IsInSeason(FilterExpression expression, out List<(int Year, string Season)> parameters) { - parameters = new List<(int, string)>(); + parameters = []; return TryParseIn(expression, typeof(InSeasonExpression), parameters); } private static bool IsInAudioLanguage(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasAudioLanguageExpression), parameters); } private static bool IsInSubtitleLanguage(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasSubtitleLanguageExpression), parameters); } private static bool IsInSharedAudioLanguage(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasSharedAudioLanguageExpression), parameters); } private static bool IsInSharedSubtitleLanguage(FilterExpression expression, out List<string> parameters) { - parameters = new List<string>(); + parameters = []; return TryParseIn(expression, typeof(HasSharedSubtitleLanguageExpression), parameters); } @@ -620,7 +636,7 @@ private static bool TryParseIn<T>(FilterExpression expression, Type type, List<T } - private static bool TryParseIn<T,T1>(FilterExpression expression, Type type, List<(T, T1)> parameters) + private static bool TryParseIn<T, T1>(FilterExpression expression, Type type, List<(T, T1)> parameters) { if (expression is OrExpression or) return TryParseIn(or.Left, type, parameters) && TryParseIn(or.Right, type, parameters); if (expression.GetType() != type) return false; @@ -763,7 +779,7 @@ public static FilterExpression<bool> GetExpression(List<GroupFilterCondition> co { // forward compatibility is easier. Just map the old conditions to an expression if (conditions == null || conditions.Count < 1) return null; - var first = conditions.Select((a, index) => new {Expression= GetExpression(a, suppressErrors), Index=index}).FirstOrDefault(a => a.Expression != null); + var first = conditions.Select((a, index) => new { Expression = GetExpression(a, suppressErrors), Index = index }).FirstOrDefault(a => a.Expression != null); if (first == null) return null; if (baseCondition == GroupFilterBaseCondition.Exclude) { @@ -829,12 +845,12 @@ private static FilterExpression<bool> GetExpression(GroupFilterCondition conditi return new NotExpression(new HasTvDBLinkExpression()); case GroupFilterConditionType.AssignedMovieDBInfo: if (op == GroupFilterOperator.Include) - return new HasTMDbLinkExpression(); - return new NotExpression(new HasTMDbLinkExpression()); + return new HasTmdbLinkExpression(); + return new NotExpression(new HasTmdbLinkExpression()); case GroupFilterConditionType.AssignedTvDBOrMovieDBInfo: if (op == GroupFilterOperator.Include) - return new OrExpression(new HasTvDBLinkExpression(), new HasTMDbLinkExpression()); - return new NotExpression(new OrExpression(new HasTvDBLinkExpression(), new HasTMDbLinkExpression())); + return new OrExpression(new HasTvDBLinkExpression(), new HasTmdbLinkExpression()); + return new NotExpression(new OrExpression(new HasTvDBLinkExpression(), new HasTmdbLinkExpression())); case GroupFilterConditionType.AssignedTraktInfo: if (op == GroupFilterOperator.Include) return new HasTraktLinkExpression(); @@ -957,27 +973,20 @@ public static SortingExpression GetSortingExpression(string sorting) { if (string.IsNullOrEmpty(sorting)) return new NameSortingSelector(); var sortCriteriaList = new List<GroupFilterSortingCriteria>(); - var scrit = sorting.Split('|'); - foreach (var sortpair in scrit) + foreach (var pair in sorting.Split('|').Select(a => a.Split(';'))) { - var spair = sortpair.Split(';'); - if (spair.Length != 2) - { + if (pair.Length != 2) continue; - } - - int.TryParse(spair[0], out var stype); - int.TryParse(spair[1], out var sdir); - if (stype > 0 && sdir > 0) + if (int.TryParse(pair[0], out var filterSorting) && filterSorting > 0 && int.TryParse(pair[1], out var sortDirection) && sortDirection > 0) { - var gfsc = new GroupFilterSortingCriteria + var criteria = new GroupFilterSortingCriteria { GroupFilterID = 0, - SortType = (GroupFilterSorting)stype, - SortDirection = (GroupFilterSortDirection)sdir + SortType = (GroupFilterSorting)filterSorting, + SortDirection = (GroupFilterSortDirection)sortDirection }; - sortCriteriaList.Add(gfsc); + sortCriteriaList.Add(criteria); } } diff --git a/Shoko.Server/Filters/Legacy/LegacyFilterConverter.cs b/Shoko.Server/Filters/Legacy/LegacyFilterConverter.cs index 73973a5d0..800ffe638 100644 --- a/Shoko.Server/Filters/Legacy/LegacyFilterConverter.cs +++ b/Shoko.Server/Filters/Legacy/LegacyFilterConverter.cs @@ -160,7 +160,6 @@ private List<CL_GroupFilter> SetOtherFilters(List<FilterPreset> otherFilters) /// </summary> /// <param name="userID">if this is specified, it only calculates one user</param> /// <param name="userFilters"></param> - /// <param name="result"></param> private List<CL_GroupFilter> SetUserFilters(int? userID, List<FilterPreset> userFilters) { diff --git a/Shoko.Server/Filters/Logic/Expressions/ConstantExpression.cs b/Shoko.Server/Filters/Logic/Expressions/ConstantExpression.cs new file mode 100644 index 000000000..9aeb6fbf4 --- /dev/null +++ b/Shoko.Server/Filters/Logic/Expressions/ConstantExpression.cs @@ -0,0 +1,71 @@ +using System; +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Logic.Expressions; + +public class ConstantExpression : FilterExpression<bool>, IWithBoolParameter +{ + public ConstantExpression(bool parameter) + { + Parameter = parameter; + } + + public ConstantExpression() { } + + public override bool TimeDependent => false; + public override bool UserDependent => false; + public override string HelpDescription => "This condition passes if the left expression is equal to the right expression or the parameter."; + public override FilterExpressionGroup Group => FilterExpressionGroup.Logic; + + public bool Parameter { get; set; } + + public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return Parameter; + } + + protected bool Equals(ConstantExpression other) + { + return base.Equals(other) && Equals(Parameter, other.Parameter); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((ConstantExpression)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), Parameter); + } + + public static bool operator ==(ConstantExpression left, ConstantExpression right) + { + return Equals(left, right); + } + + public static bool operator !=(ConstantExpression left, ConstantExpression right) + { + return !Equals(left, right); + } + + public override bool IsType(FilterExpression expression) + { + return expression is ConstantExpression; + } +} diff --git a/Shoko.Server/Filters/Logic/Expressions/EqualsExpression.cs b/Shoko.Server/Filters/Logic/Expressions/EqualsExpression.cs new file mode 100644 index 000000000..72f0aebd8 --- /dev/null +++ b/Shoko.Server/Filters/Logic/Expressions/EqualsExpression.cs @@ -0,0 +1,83 @@ +using System; +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Logic.Expressions; + +public class EqualsExpression : FilterExpression<bool>, IWithExpressionParameter, IWithSecondExpressionParameter, IWithBoolParameter +{ + public EqualsExpression(FilterExpression<bool> left, FilterExpression<bool> right) + { + Left = left; + Right = right; + } + + public EqualsExpression(FilterExpression<bool> left, bool parameter) + { + Left = left; + Parameter = parameter; + } + + public EqualsExpression() { } + + public override bool TimeDependent => Left.TimeDependent || (Right?.TimeDependent ?? false); + public override bool UserDependent => Left.UserDependent || (Right?.TimeDependent ?? false); + public override string HelpDescription => "This condition passes if the left expression is equal to the right expression or the parameter."; + public override FilterExpressionGroup Group => FilterExpressionGroup.Logic; + + public FilterExpression<bool> Left { get; set; } + public FilterExpression<bool> Right { get; set; } + + public bool Parameter { get; set; } + + public override bool Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + var left = Left.Evaluate(filterable, userInfo); + var right = Right?.Evaluate(filterable, userInfo) ?? Parameter; + return left == right; + } + + protected bool Equals(EqualsExpression other) + { + return base.Equals(other) && Equals(Left, other.Left) && Equals(Right, other.Right); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((EqualsExpression)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), Left, Right); + } + + public static bool operator ==(EqualsExpression left, EqualsExpression right) + { + return Equals(left, right); + } + + public static bool operator !=(EqualsExpression left, EqualsExpression right) + { + return !Equals(left, right); + } + + public override bool IsType(FilterExpression expression) + { + return expression is EqualsExpression exp && Left.IsType(exp.Left) && (Right?.IsType(exp.Right) ?? true); + } +} diff --git a/Shoko.Server/Filters/Selectors/NumberSelectors/AutomaticTmdbEpisodeLinksSelector.cs b/Shoko.Server/Filters/Selectors/NumberSelectors/AutomaticTmdbEpisodeLinksSelector.cs new file mode 100644 index 000000000..f9124d0ae --- /dev/null +++ b/Shoko.Server/Filters/Selectors/NumberSelectors/AutomaticTmdbEpisodeLinksSelector.cs @@ -0,0 +1,56 @@ +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Selectors.NumberSelectors; + +public class AutomaticTmdbEpisodeLinkSelector : FilterExpression<double> +{ + public override bool TimeDependent => false; + public override bool UserDependent => false; + public override string HelpDescription => "This returns the number of automatic TMDB episode links for a series"; + public override FilterExpressionGroup Group => FilterExpressionGroup.Selector; + + public override double Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.AutomaticTmdbEpisodeLinks; + } + + protected bool Equals(AutomaticTmdbEpisodeLinkSelector other) + { + return base.Equals(other); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((AutomaticTmdbEpisodeLinkSelector)obj); + } + + public override int GetHashCode() + { + return GetType().FullName!.GetHashCode(); + } + + public static bool operator ==(AutomaticTmdbEpisodeLinkSelector left, AutomaticTmdbEpisodeLinkSelector right) + { + return Equals(left, right); + } + + public static bool operator !=(AutomaticTmdbEpisodeLinkSelector left, AutomaticTmdbEpisodeLinkSelector right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/Filters/Selectors/NumberSelectors/UserVerifiedTmdbEpisodeLinkSelector.cs b/Shoko.Server/Filters/Selectors/NumberSelectors/UserVerifiedTmdbEpisodeLinkSelector.cs new file mode 100644 index 000000000..3443a152d --- /dev/null +++ b/Shoko.Server/Filters/Selectors/NumberSelectors/UserVerifiedTmdbEpisodeLinkSelector.cs @@ -0,0 +1,56 @@ +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Selectors.NumberSelectors; + +public class UserVerifiedTmdbEpisodeLinkSelector : FilterExpression<double> +{ + public override bool TimeDependent => false; + public override bool UserDependent => false; + public override string HelpDescription => "This returns the number of user verified TMDB episode links for a series"; + public override FilterExpressionGroup Group => FilterExpressionGroup.Selector; + + public override double Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.AutomaticTmdbEpisodeLinks; + } + + protected bool Equals(UserVerifiedTmdbEpisodeLinkSelector other) + { + return base.Equals(other); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((UserVerifiedTmdbEpisodeLinkSelector)obj); + } + + public override int GetHashCode() + { + return GetType().FullName!.GetHashCode(); + } + + public static bool operator ==(UserVerifiedTmdbEpisodeLinkSelector left, UserVerifiedTmdbEpisodeLinkSelector right) + { + return Equals(left, right); + } + + public static bool operator !=(UserVerifiedTmdbEpisodeLinkSelector left, UserVerifiedTmdbEpisodeLinkSelector right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/Filters/Selectors/StringSetSelectors/AvailableImageTypesSelector.cs b/Shoko.Server/Filters/Selectors/StringSetSelectors/AvailableImageTypesSelector.cs new file mode 100644 index 000000000..87657e948 --- /dev/null +++ b/Shoko.Server/Filters/Selectors/StringSetSelectors/AvailableImageTypesSelector.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Selectors.StringSetSelectors; + +public class AvailableImageTypesSelector : FilterExpression<IReadOnlySet<string>> +{ + public override bool TimeDependent => false; + public override bool UserDependent => false; + public override string HelpDescription => "This returns a set of all the available image types in a filterable."; + public override FilterExpressionGroup Group => FilterExpressionGroup.Selector; + + public override IReadOnlySet<string> Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.AvailableImageTypes.Select(t => t.ToString()).ToHashSet(); + } + + protected bool Equals(AvailableImageTypesSelector other) + { + return base.Equals(other); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((AvailableImageTypesSelector)obj); + } + + public override int GetHashCode() + { + return GetType().FullName!.GetHashCode(); + } + + public static bool operator ==(AvailableImageTypesSelector left, AvailableImageTypesSelector right) + { + return Equals(left, right); + } + + public static bool operator !=(AvailableImageTypesSelector left, AvailableImageTypesSelector right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/Filters/Selectors/StringSetSelectors/ImportFolderIDsSelector.cs b/Shoko.Server/Filters/Selectors/StringSetSelectors/ImportFolderIDsSelector.cs new file mode 100644 index 000000000..aac40615d --- /dev/null +++ b/Shoko.Server/Filters/Selectors/StringSetSelectors/ImportFolderIDsSelector.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Selectors.StringSetSelectors; + +public class ImportFolderIDsSelector : FilterExpression<IReadOnlySet<string>> +{ + public override bool TimeDependent => false; + public override bool UserDependent => false; + public override string HelpDescription => "This returns a set of the import folder IDs in a filterable"; + public override FilterExpressionGroup Group => FilterExpressionGroup.Selector; + + public override IReadOnlySet<string> Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.ImportFolderIDs; + } + + protected bool Equals(ImportFolderIDsSelector other) + { + return base.Equals(other); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((ImportFolderIDsSelector)obj); + } + + public override int GetHashCode() + { + return GetType().FullName!.GetHashCode(); + } + + public static bool operator ==(ImportFolderIDsSelector left, ImportFolderIDsSelector right) + { + return Equals(left, right); + } + + public static bool operator !=(ImportFolderIDsSelector left, ImportFolderIDsSelector right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/Filters/Selectors/StringSetSelectors/ImportFolderNamesSelector.cs b/Shoko.Server/Filters/Selectors/StringSetSelectors/ImportFolderNamesSelector.cs new file mode 100644 index 000000000..d4183a5da --- /dev/null +++ b/Shoko.Server/Filters/Selectors/StringSetSelectors/ImportFolderNamesSelector.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Selectors.StringSetSelectors; + +public class ImportFolderNamesSelector : FilterExpression<IReadOnlySet<string>> +{ + public override bool TimeDependent => false; + public override bool UserDependent => false; + public override string HelpDescription => "This returns a set of the import folder names in a filterable"; + public override FilterExpressionGroup Group => FilterExpressionGroup.Selector; + + public override IReadOnlySet<string> Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.ImportFolderNames; + } + + protected bool Equals(ImportFolderNamesSelector other) + { + return base.Equals(other); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((ImportFolderNamesSelector)obj); + } + + public override int GetHashCode() + { + return GetType().FullName!.GetHashCode(); + } + + public static bool operator ==(ImportFolderNamesSelector left, ImportFolderNamesSelector right) + { + return Equals(left, right); + } + + public static bool operator !=(ImportFolderNamesSelector left, ImportFolderNamesSelector right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/Filters/Selectors/StringSetSelectors/PreferredImageTypesSelector.cs b/Shoko.Server/Filters/Selectors/StringSetSelectors/PreferredImageTypesSelector.cs new file mode 100644 index 000000000..22e18aaa1 --- /dev/null +++ b/Shoko.Server/Filters/Selectors/StringSetSelectors/PreferredImageTypesSelector.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Filters.Interfaces; + +namespace Shoko.Server.Filters.Selectors.StringSetSelectors; + +public class PreferredImageTypesSelector : FilterExpression<IReadOnlySet<string>> +{ + public override bool TimeDependent => false; + public override bool UserDependent => false; + public override string HelpDescription => "This returns a set of all the preferred image types in a filterable."; + public override FilterExpressionGroup Group => FilterExpressionGroup.Selector; + + public override IReadOnlySet<string> Evaluate(IFilterable filterable, IFilterableUserInfo userInfo) + { + return filterable.PreferredImageTypes.Select(t => t.ToString()).ToHashSet(); + } + + protected bool Equals(PreferredImageTypesSelector other) + { + return base.Equals(other); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((PreferredImageTypesSelector)obj); + } + + public override int GetHashCode() + { + return GetType().FullName!.GetHashCode(); + } + + public static bool operator ==(PreferredImageTypesSelector left, PreferredImageTypesSelector right) + { + return Equals(left, right); + } + + public static bool operator !=(PreferredImageTypesSelector left, PreferredImageTypesSelector right) + { + return !Equals(left, right); + } +} diff --git a/Shoko.Server/ImageDownload/ImageDetails.cs b/Shoko.Server/ImageDownload/ImageDetails.cs deleted file mode 100644 index fb81f19bf..000000000 --- a/Shoko.Server/ImageDownload/ImageDetails.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Shoko.Models.Enums; - -namespace Shoko.Server.ImageDownload; - -public class ImageDetails -{ - public ImageEntityType ImageType { get; set; } - public int ImageID { get; set; } -} diff --git a/Shoko.Server/ImageDownload/ImageDownloadResult.cs b/Shoko.Server/ImageDownload/ImageDownloadResult.cs deleted file mode 100644 index c054d0e07..000000000 --- a/Shoko.Server/ImageDownload/ImageDownloadResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -#nullable enable -namespace Shoko.Server.ImageDownload; - -/// <summary> -/// Represents the result of an image download operation. -/// </summary> -public enum ImageDownloadResult -{ - /// <summary> - /// The image was successfully downloaded and saved. - /// </summary> - Success = 1, - - /// <summary> - /// The image was not downloaded because it was already available in the cache. - /// </summary> - Cached = 2, - - /// <summary> - /// The image could not be downloaded due to not being able to get the - /// source or destination. - /// </summary> - Failure = 3, - - /// <summary> - /// The image was not downloaded because the resource has been removed or is - /// no longer available, but we could not remove the local entry because of - /// its type. - /// </summary> - InvalidResource = 4, - - /// <summary> - /// The image was not downloaded because the resource has been removed or is - /// no longer available, and thus have also been removed from the local - /// database. - /// </summary> - RemovedResource = 5, -} diff --git a/Shoko.Server/ImageDownload/ImageUtils.cs b/Shoko.Server/ImageDownload/ImageUtils.cs deleted file mode 100644 index eb56f5934..000000000 --- a/Shoko.Server/ImageDownload/ImageUtils.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.IO; -using Shoko.Server.Utilities; - -namespace Shoko.Server.ImageDownload; - -public class ImageUtils -{ - public static string GetBaseImagesPath() - { - var settings = Utils.SettingsProvider?.GetSettings(); - if (!string.IsNullOrEmpty(settings?.ImagesPath) && - Directory.Exists(settings.ImagesPath)) - { - return settings.ImagesPath; - } - - var imagepath = Utils.DefaultImagePath; - if (!Directory.Exists(imagepath)) - { - Directory.CreateDirectory(imagepath); - } - - return imagepath; - } - - public static string GetBaseAniDBImagesPath() - { - var filePath = Path.Combine(GetBaseImagesPath(), "AniDB"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetBaseAniDBCharacterImagesPath() - { - var filePath = Path.Combine(GetBaseImagesPath(), "AniDB_Char"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetBaseAniDBCreatorImagesPath() - { - var filePath = Path.Combine(GetBaseImagesPath(), "AniDB_Creator"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetBaseTvDBImagesPath() - { - var filePath = Path.Combine(GetBaseImagesPath(), "TvDB"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetBaseMovieDBImagesPath() - { - var filePath = Path.Combine(GetBaseImagesPath(), "MovieDB"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetBaseTraktImagesPath() - { - var filePath = Path.Combine(GetBaseImagesPath(), "Trakt"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetImagesTempFolder() - { - var filePath = Path.Combine(GetBaseImagesPath(), "_Temp_"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetAniDBCharacterImagePath(int charID) - { - var subFolder = string.Empty; - var sid = charID.ToString(); - if (sid.Length == 1) - { - subFolder = sid; - } - else - { - subFolder = sid.Substring(0, 2); - } - - var filePath = Path.Combine(GetBaseAniDBCharacterImagesPath(), subFolder); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetAniDBCreatorImagePath(int creatorID) - { - var subFolder = string.Empty; - var sid = creatorID.ToString(); - if (sid.Length == 1) - { - subFolder = sid; - } - else - { - subFolder = sid.Substring(0, 2); - } - - var filePath = Path.Combine(GetBaseAniDBCreatorImagesPath(), subFolder); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetAniDBImagePath(int animeID) - { - var subFolder = string.Empty; - var sid = animeID.ToString(); - if (sid.Length == 1) - { - subFolder = sid; - } - else - { - subFolder = sid.Substring(0, 2); - } - - var filePath = Path.Combine(GetBaseAniDBImagesPath(), subFolder); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetTvDBImagePath() - { - var filePath = GetBaseTvDBImagesPath(); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetMovieDBImagePath() - { - var filePath = GetBaseMovieDBImagesPath(); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetTraktImagePath() - { - var filePath = GetBaseTraktImagesPath(); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } - - public static string GetTraktImagePath_Avatars() - { - var filePath = Path.Combine(GetTraktImagePath(), "Avatars"); - - if (!Directory.Exists(filePath)) - { - Directory.CreateDirectory(filePath); - } - - return filePath; - } -} diff --git a/Shoko.Server/Mappings/AniDB_AnimeMap.cs b/Shoko.Server/Mappings/AniDB_AnimeMap.cs index de601731f..6027b2022 100644 --- a/Shoko.Server/Mappings/AniDB_AnimeMap.cs +++ b/Shoko.Server/Mappings/AniDB_AnimeMap.cs @@ -33,7 +33,9 @@ public AniDB_AnimeMap() Map(x => x.AvgReviewRating).Not.Nullable(); Map(x => x.BeginYear).Not.Nullable(); Map(x => x.DateTimeDescUpdated).Not.Nullable(); +#pragma warning disable CS0618 Map(x => x.DateTimeUpdated).Not.Nullable(); +#pragma warning restore CS0618 Map(x => x.Description).CustomType("StringClob").Not.Nullable(); Map(x => x.EndDate); Map(x => x.EndYear).Not.Nullable(); diff --git a/Shoko.Server/Mappings/AniDB_Anime_DefaultImageMap.cs b/Shoko.Server/Mappings/AniDB_Anime_DefaultImageMap.cs deleted file mode 100644 index b2d409a9f..000000000 --- a/Shoko.Server/Mappings/AniDB_Anime_DefaultImageMap.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class AniDB_Anime_DefaultImageMap : ClassMap<AniDB_Anime_DefaultImage> -{ - public AniDB_Anime_DefaultImageMap() - { - Table("AniDB_Anime_DefaultImage"); - Not.LazyLoad(); - Id(x => x.AniDB_Anime_DefaultImageID); - - Map(x => x.AnimeID).Not.Nullable(); - Map(x => x.ImageParentID).Not.Nullable(); - Map(x => x.ImageParentType).Not.Nullable(); - Map(x => x.ImageType).Not.Nullable(); - } -} diff --git a/Shoko.Server/Mappings/AniDB_Anime_PreferredImageMap.cs b/Shoko.Server/Mappings/AniDB_Anime_PreferredImageMap.cs new file mode 100644 index 000000000..5564939d4 --- /dev/null +++ b/Shoko.Server/Mappings/AniDB_Anime_PreferredImageMap.cs @@ -0,0 +1,22 @@ +using FluentNHibernate.Mapping; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class AniDB_Anime_PreferredImageMap : ClassMap<AniDB_Anime_PreferredImage> +{ + public AniDB_Anime_PreferredImageMap() + { + Table("AniDB_Anime_PreferredImage"); + Not.LazyLoad(); + Id(x => x.AniDB_Anime_PreferredImageID); + + Map(x => x.AnidbAnimeID).Not.Nullable(); + Map(x => x.ImageID).Not.Nullable(); + Map(x => x.ImageSource).Not.Nullable().CustomType<DataSourceType>(); + Map(x => x.ImageType).Not.Nullable().CustomType<ImageEntityType>(); + } +} diff --git a/Shoko.Server/Mappings/AniDB_Anime_TitleMap.cs b/Shoko.Server/Mappings/AniDB_Anime_TitleMap.cs index 39b11be4e..85befea47 100644 --- a/Shoko.Server/Mappings/AniDB_Anime_TitleMap.cs +++ b/Shoko.Server/Mappings/AniDB_Anime_TitleMap.cs @@ -1,5 +1,5 @@ using FluentNHibernate.Mapping; -using Shoko.Server.Databases.NHIbernate; +using Shoko.Server.Databases.NHibernate; using Shoko.Server.Models; namespace Shoko.Server.Mappings; diff --git a/Shoko.Server/Mappings/AniDB_Character_CreatorMap.cs b/Shoko.Server/Mappings/AniDB_Character_CreatorMap.cs new file mode 100644 index 000000000..0c31136dd --- /dev/null +++ b/Shoko.Server/Mappings/AniDB_Character_CreatorMap.cs @@ -0,0 +1,17 @@ +using FluentNHibernate.Mapping; +using Shoko.Models.Server; +using Shoko.Server.Models.AniDB; + +namespace Shoko.Server.Mappings; + +public class AniDB_Character_CreatorMap : ClassMap<AniDB_Character_Creator> +{ + public AniDB_Character_CreatorMap() + { + Not.LazyLoad(); + Id(x => x.AniDB_Character_CreatorID); + + Map(x => x.CharacterID).Not.Nullable(); + Map(x => x.CreatorID).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/AniDB_Character_SeiyuuMap.cs b/Shoko.Server/Mappings/AniDB_Character_SeiyuuMap.cs deleted file mode 100644 index c2dc7a8cf..000000000 --- a/Shoko.Server/Mappings/AniDB_Character_SeiyuuMap.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class AniDB_Character_SeiyuuMap : ClassMap<AniDB_Character_Seiyuu> -{ - public AniDB_Character_SeiyuuMap() - { - Not.LazyLoad(); - Id(x => x.AniDB_Character_SeiyuuID); - - Map(x => x.CharID).Not.Nullable(); - Map(x => x.SeiyuuID).Not.Nullable(); - } -} diff --git a/Shoko.Server/Mappings/AniDB_CreatorMap.cs b/Shoko.Server/Mappings/AniDB_CreatorMap.cs new file mode 100644 index 000000000..dd1ec1b6b --- /dev/null +++ b/Shoko.Server/Mappings/AniDB_CreatorMap.cs @@ -0,0 +1,25 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Providers.AniDB; + +namespace Shoko.Server.Mappings; + +public class AniDB_CreatorMap : ClassMap<AniDB_Creator> +{ + public AniDB_CreatorMap() + { + Not.LazyLoad(); + Id(x => x.AniDB_CreatorID); + + Map(x => x.CreatorID).Not.Nullable(); + Map(x => x.Name).Not.Nullable(); + Map(x => x.OriginalName); + Map(x => x.Type).CustomType<CreatorType>().Not.Nullable(); + Map(x => x.ImagePath); + Map(x => x.EnglishHomepageUrl); + Map(x => x.JapaneseHomepageUrl); + Map(x => x.EnglishWikiUrl); + Map(x => x.JapaneseWikiUrl); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/AniDB_Episode_PreferredImageMap.cs b/Shoko.Server/Mappings/AniDB_Episode_PreferredImageMap.cs new file mode 100644 index 000000000..e02839cb6 --- /dev/null +++ b/Shoko.Server/Mappings/AniDB_Episode_PreferredImageMap.cs @@ -0,0 +1,23 @@ +using FluentNHibernate.Mapping; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class AniDB_Episode_PreferredImageMap : ClassMap<AniDB_Episode_PreferredImage> +{ + public AniDB_Episode_PreferredImageMap() + { + Table("AniDB_Episode_PreferredImage"); + Not.LazyLoad(); + Id(x => x.AniDB_Episode_PreferredImageID); + + Map(x => x.AnidbAnimeID).Not.Nullable(); + Map(x => x.AnidbEpisodeID).Not.Nullable(); + Map(x => x.ImageID).Not.Nullable(); + Map(x => x.ImageSource).Not.Nullable().CustomType<DataSourceType>(); + Map(x => x.ImageType).Not.Nullable().CustomType<ImageEntityType>(); + } +} diff --git a/Shoko.Server/Mappings/AniDB_Episode_TitleMap.cs b/Shoko.Server/Mappings/AniDB_Episode_TitleMap.cs index cb00e192c..346ab541e 100644 --- a/Shoko.Server/Mappings/AniDB_Episode_TitleMap.cs +++ b/Shoko.Server/Mappings/AniDB_Episode_TitleMap.cs @@ -1,5 +1,5 @@ using FluentNHibernate.Mapping; -using Shoko.Server.Databases.NHIbernate; +using Shoko.Server.Databases.NHibernate; using Shoko.Server.Models; namespace Shoko.Server.Mappings; diff --git a/Shoko.Server/Mappings/AniDB_MessageMap.cs b/Shoko.Server/Mappings/AniDB_MessageMap.cs new file mode 100644 index 000000000..db006b65c --- /dev/null +++ b/Shoko.Server/Mappings/AniDB_MessageMap.cs @@ -0,0 +1,25 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class AniDB_MessageMap : ClassMap<AniDB_Message> +{ + public AniDB_MessageMap() + { + Table("AniDB_Message"); + Not.LazyLoad(); + Id(x => x.AniDB_MessageID); + + Map(x => x.MessageID).Not.Nullable(); + Map(x => x.FromUserId).Not.Nullable(); + Map(x => x.FromUserName).Not.Nullable(); + Map(x => x.SentAt).Not.Nullable(); + Map(x => x.FetchedAt).Not.Nullable(); + Map(x => x.Type).Not.Nullable().CustomType<AniDBMessageType>(); + Map(x => x.Title).Not.Nullable(); + Map(x => x.Body).Not.Nullable(); + Map(x => x.Flags).Not.Nullable().CustomType<AniDBMessageFlags>(); + } +} diff --git a/Shoko.Server/Mappings/AniDB_NotifyQueueMap.cs b/Shoko.Server/Mappings/AniDB_NotifyQueueMap.cs new file mode 100644 index 000000000..07380bd33 --- /dev/null +++ b/Shoko.Server/Mappings/AniDB_NotifyQueueMap.cs @@ -0,0 +1,19 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class AniDB_NotifyQueueMap : ClassMap<AniDB_NotifyQueue> +{ + public AniDB_NotifyQueueMap() + { + Table("AniDB_NotifyQueue"); + Not.LazyLoad(); + Id(x => x.AniDB_NotifyQueueID); + + Map(x => x.Type).Not.Nullable().CustomType<AniDBNotifyType>(); + Map(x => x.ID).Not.Nullable(); + Map(x => x.AddedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/AniDB_ReleaseGroupMap.cs b/Shoko.Server/Mappings/AniDB_ReleaseGroupMap.cs index a75e67918..ee76851d9 100644 --- a/Shoko.Server/Mappings/AniDB_ReleaseGroupMap.cs +++ b/Shoko.Server/Mappings/AniDB_ReleaseGroupMap.cs @@ -1,5 +1,5 @@ using FluentNHibernate.Mapping; -using Shoko.Models.Server; +using Shoko.Server.Models.AniDB; namespace Shoko.Server.Mappings; diff --git a/Shoko.Server/Mappings/AniDB_SeiyuuMap.cs b/Shoko.Server/Mappings/AniDB_SeiyuuMap.cs deleted file mode 100644 index 509d6424c..000000000 --- a/Shoko.Server/Mappings/AniDB_SeiyuuMap.cs +++ /dev/null @@ -1,18 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class AniDB_SeiyuuMap : ClassMap<AniDB_Seiyuu> -{ - public AniDB_SeiyuuMap() - { - Table("AniDB_Seiyuu"); - Not.LazyLoad(); - Id(x => x.AniDB_SeiyuuID); - - Map(x => x.SeiyuuID).Not.Nullable(); - Map(x => x.SeiyuuName).Not.Nullable(); - Map(x => x.PicName); - } -} diff --git a/Shoko.Server/Mappings/CrossRef_AniDB_OtherMap.cs b/Shoko.Server/Mappings/CrossRef_AniDB_OtherMap.cs deleted file mode 100644 index e8e4c8ef1..000000000 --- a/Shoko.Server/Mappings/CrossRef_AniDB_OtherMap.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class CrossRef_AniDB_OtherMap : ClassMap<CrossRef_AniDB_Other> -{ - public CrossRef_AniDB_OtherMap() - { - Table("CrossRef_AniDB_Other"); - - Not.LazyLoad(); - Id(x => x.CrossRef_AniDB_OtherID); - - Map(x => x.AnimeID).Not.Nullable(); - Map(x => x.CrossRefID); - Map(x => x.CrossRefSource).Not.Nullable(); - Map(x => x.CrossRefType).Not.Nullable(); - } -} diff --git a/Shoko.Server/Mappings/CrossRef_AniDB_TvDBMap.cs b/Shoko.Server/Mappings/CrossRef_AniDB_TvDBMap.cs deleted file mode 100644 index 4ceaaf79a..000000000 --- a/Shoko.Server/Mappings/CrossRef_AniDB_TvDBMap.cs +++ /dev/null @@ -1,18 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Enums; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class CrossRef_AniDB_TvDBMap : ClassMap<CrossRef_AniDB_TvDB> -{ - public CrossRef_AniDB_TvDBMap() - { - Not.LazyLoad(); - Id(x => x.CrossRef_AniDB_TvDBID); - - Map(x => x.AniDBID).Not.Nullable(); - Map(x => x.TvDBID).Not.Nullable(); - Map(x => x.CrossRefSource).CustomType<CrossRefSource>().Not.Nullable(); - } -} diff --git a/Shoko.Server/Mappings/CrossRef_AniDB_TvDB_EpisodeMap.cs b/Shoko.Server/Mappings/CrossRef_AniDB_TvDB_EpisodeMap.cs deleted file mode 100644 index 478da644c..000000000 --- a/Shoko.Server/Mappings/CrossRef_AniDB_TvDB_EpisodeMap.cs +++ /dev/null @@ -1,18 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Enums; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class CrossRef_AniDB_TvDB_EpisodeMap : ClassMap<CrossRef_AniDB_TvDB_Episode> -{ - public CrossRef_AniDB_TvDB_EpisodeMap() - { - Not.LazyLoad(); - Id(x => x.CrossRef_AniDB_TvDB_EpisodeID); - - Map(x => x.AniDBEpisodeID).Not.Nullable(); - Map(x => x.TvDBEpisodeID).Not.Nullable(); - Map(x => x.MatchRating).CustomType<MatchRating>().Not.Nullable(); - } -} diff --git a/Shoko.Server/Mappings/CrossRef_AniDB_TvDB_Episode_OverrideMap.cs b/Shoko.Server/Mappings/CrossRef_AniDB_TvDB_Episode_OverrideMap.cs deleted file mode 100644 index 2eae2f98e..000000000 --- a/Shoko.Server/Mappings/CrossRef_AniDB_TvDB_Episode_OverrideMap.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class CrossRef_AniDB_TvDB_Episode_OverrideMap : ClassMap<CrossRef_AniDB_TvDB_Episode_Override> -{ - public CrossRef_AniDB_TvDB_Episode_OverrideMap() - { - Not.LazyLoad(); - Id(x => x.CrossRef_AniDB_TvDB_Episode_OverrideID); - - Map(x => x.AniDBEpisodeID).Not.Nullable(); - Map(x => x.TvDBEpisodeID).Not.Nullable(); - } -} diff --git a/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_EpisodeMap.cs b/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_EpisodeMap.cs new file mode 100644 index 000000000..c8cc0e9db --- /dev/null +++ b/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_EpisodeMap.cs @@ -0,0 +1,23 @@ +using FluentNHibernate.Mapping; +using Shoko.Models.Enums; +using Shoko.Server.Models.CrossReference; + +namespace Shoko.Server.Mappings; + +public class CrossRef_AniDB_TMDB_EpisodeMap : ClassMap<CrossRef_AniDB_TMDB_Episode> +{ + public CrossRef_AniDB_TMDB_EpisodeMap() + { + Table("CrossRef_AniDB_TMDB_Episode"); + + Not.LazyLoad(); + Id(x => x.CrossRef_AniDB_TMDB_EpisodeID); + + Map(x => x.AnidbAnimeID).Not.Nullable(); + Map(x => x.AnidbEpisodeID).Not.Nullable(); + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbEpisodeID).Not.Nullable(); + Map(x => x.Ordering).Not.Nullable(); + Map(x => x.MatchRating).CustomType<MatchRating>().Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_MovieMap.cs b/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_MovieMap.cs new file mode 100644 index 000000000..f8dc95308 --- /dev/null +++ b/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_MovieMap.cs @@ -0,0 +1,21 @@ +using FluentNHibernate.Mapping; +using Shoko.Models.Enums; +using Shoko.Server.Models.CrossReference; + +namespace Shoko.Server.Mappings; + +public class CrossRef_AniDB_TMDB_MovieMap : ClassMap<CrossRef_AniDB_TMDB_Movie> +{ + public CrossRef_AniDB_TMDB_MovieMap() + { + Table("CrossRef_AniDB_TMDB_Movie"); + + Not.LazyLoad(); + Id(x => x.CrossRef_AniDB_TMDB_MovieID); + + Map(x => x.AnidbAnimeID).Not.Nullable(); + Map(x => x.AnidbEpisodeID).Not.Nullable(); + Map(x => x.TmdbMovieID).Not.Nullable(); + Map(x => x.Source).CustomType<CrossRefSource>().Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_ShowMap.cs b/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_ShowMap.cs new file mode 100644 index 000000000..1c0765368 --- /dev/null +++ b/Shoko.Server/Mappings/CrossReference/CrossRef_AniDB_TMDB_ShowMap.cs @@ -0,0 +1,20 @@ +using FluentNHibernate.Mapping; +using Shoko.Models.Enums; +using Shoko.Server.Models.CrossReference; + +namespace Shoko.Server.Mappings; + +public class CrossRef_AniDB_TMDB_ShowMap : ClassMap<CrossRef_AniDB_TMDB_Show> +{ + public CrossRef_AniDB_TMDB_ShowMap() + { + Table("CrossRef_AniDB_TMDB_Show"); + + Not.LazyLoad(); + Id(x => x.CrossRef_AniDB_TMDB_ShowID); + + Map(x => x.AnidbAnimeID).Not.Nullable(); + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.Source).CustomType<CrossRefSource>().Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/FilterPresetMap.cs b/Shoko.Server/Mappings/FilterPresetMap.cs index 4c62d66ff..88fa099ea 100644 --- a/Shoko.Server/Mappings/FilterPresetMap.cs +++ b/Shoko.Server/Mappings/FilterPresetMap.cs @@ -1,6 +1,6 @@ using FluentNHibernate.Mapping; using Shoko.Models.Enums; -using Shoko.Server.Databases.NHIbernate; +using Shoko.Server.Databases.NHibernate; using Shoko.Server.Models; namespace Shoko.Server.Mappings; diff --git a/Shoko.Server/Mappings/MovieDB_FanartMap.cs b/Shoko.Server/Mappings/MovieDB_FanartMap.cs deleted file mode 100644 index e0d75fa2e..000000000 --- a/Shoko.Server/Mappings/MovieDB_FanartMap.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class MovieDB_FanartMap : ClassMap<MovieDB_Fanart> -{ - public MovieDB_FanartMap() - { - Not.LazyLoad(); - Id(x => x.MovieDB_FanartID); - - Map(x => x.Enabled).Not.Nullable(); - Map(x => x.ImageHeight).Not.Nullable(); - Map(x => x.ImageID).Not.Nullable(); - Map(x => x.ImageSize); - Map(x => x.ImageType); - Map(x => x.ImageWidth).Not.Nullable(); - Map(x => x.MovieId).Not.Nullable(); - Map(x => x.URL); - } -} diff --git a/Shoko.Server/Mappings/MovieDB_MovieMap.cs b/Shoko.Server/Mappings/MovieDB_MovieMap.cs deleted file mode 100644 index b5a503ea9..000000000 --- a/Shoko.Server/Mappings/MovieDB_MovieMap.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class MovieDB_MovieMap : ClassMap<MovieDB_Movie> -{ - public MovieDB_MovieMap() - { - Not.LazyLoad(); - Id(x => x.MovieDB_MovieID); - - Map(x => x.MovieId).Not.Nullable(); - Map(x => x.MovieName); - Map(x => x.OriginalName); - Map(x => x.Overview).CustomType("StringClob"); - Map(x => x.Rating).Not.Nullable(); - } -} diff --git a/Shoko.Server/Mappings/MovieDB_PosterMap.cs b/Shoko.Server/Mappings/MovieDB_PosterMap.cs deleted file mode 100644 index 4dcdd6bea..000000000 --- a/Shoko.Server/Mappings/MovieDB_PosterMap.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class MovieDB_PosterMap : ClassMap<MovieDB_Poster> -{ - public MovieDB_PosterMap() - { - Not.LazyLoad(); - Id(x => x.MovieDB_PosterID); - - Map(x => x.Enabled).Not.Nullable(); - Map(x => x.ImageHeight).Not.Nullable(); - Map(x => x.ImageID).Not.Nullable(); - Map(x => x.ImageSize); - Map(x => x.ImageType); - Map(x => x.ImageWidth).Not.Nullable(); - Map(x => x.MovieId).Not.Nullable(); - Map(x => x.URL); - } -} diff --git a/Shoko.Server/Mappings/RenameScriptMap.cs b/Shoko.Server/Mappings/RenameScriptMap.cs deleted file mode 100644 index cf71663fb..000000000 --- a/Shoko.Server/Mappings/RenameScriptMap.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class RenameScriptMap : ClassMap<RenameScript> -{ - public RenameScriptMap() - { - Not.LazyLoad(); - Id(x => x.RenameScriptID); - - Map(x => x.ScriptName); - Map(x => x.Script); - Map(x => x.IsEnabledOnImport).Not.Nullable(); - Map(x => x.RenamerType).Not.Nullable().Default("Legacy"); - Map(x => x.ExtraData).Nullable(); - } -} diff --git a/Shoko.Server/Mappings/RenamerConfigMap.cs b/Shoko.Server/Mappings/RenamerConfigMap.cs new file mode 100644 index 000000000..b7841be7e --- /dev/null +++ b/Shoko.Server/Mappings/RenamerConfigMap.cs @@ -0,0 +1,20 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models; + +namespace Shoko.Server.Mappings; + +public class RenamerConfigMap : ClassMap<RenamerConfig> +{ + public RenamerConfigMap() + { + // We could rename this, but it already exists in test databases.... + Table("RenamerInstance"); + Not.LazyLoad(); + Id(x => x.ID); + + Map(x => x.Name); + Map(x => x.Type).CustomType<TypeStringConverter>().Not.Nullable(); + Map(x => x.Settings).Nullable().CustomType<TypelessMessagePackConverter>(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrderingMap.cs b/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrderingMap.cs new file mode 100644 index 000000000..5208ed595 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrderingMap.cs @@ -0,0 +1,27 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_AlternateOrderingMap : ClassMap<TMDB_AlternateOrdering> +{ + public TMDB_AlternateOrderingMap() + { + Table("TMDB_AlternateOrdering"); + + Not.LazyLoad(); + Id(x => x.TMDB_AlternateOrderingID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbNetworkID); + Map(x => x.TmdbEpisodeGroupCollectionID).Not.Nullable(); + Map(x => x.EnglishTitle).Not.Nullable(); + Map(x => x.EnglishOverview).Not.Nullable(); + Map(x => x.EpisodeCount).Not.Nullable(); + Map(x => x.SeasonCount).Not.Nullable(); + Map(x => x.Type).Not.Nullable().CustomType<AlternateOrderingType>(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrdering_EpisodeMap.cs b/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrdering_EpisodeMap.cs new file mode 100644 index 000000000..e301f6433 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrdering_EpisodeMap.cs @@ -0,0 +1,24 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_AlternateOrdering_EpisodeMap : ClassMap<TMDB_AlternateOrdering_Episode> +{ + public TMDB_AlternateOrdering_EpisodeMap() + { + Table("TMDB_AlternateOrdering_Episode"); + + Not.LazyLoad(); + Id(x => x.TMDB_AlternateOrdering_EpisodeID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbEpisodeGroupCollectionID).Not.Nullable(); + Map(x => x.TmdbEpisodeGroupID).Not.Nullable(); + Map(x => x.TmdbEpisodeID).Not.Nullable(); + Map(x => x.SeasonNumber).Not.Nullable(); + Map(x => x.EpisodeNumber).Not.Nullable(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrdering_SeasonMap.cs b/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrdering_SeasonMap.cs new file mode 100644 index 000000000..10b965fc0 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Optional/TMDB_AlternateOrdering_SeasonMap.cs @@ -0,0 +1,25 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_AlternateOrdering_SeasonMap : ClassMap<TMDB_AlternateOrdering_Season> +{ + public TMDB_AlternateOrdering_SeasonMap() + { + Table("TMDB_AlternateOrdering_Season"); + + Not.LazyLoad(); + Id(x => x.TMDB_AlternateOrdering_SeasonID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbEpisodeGroupCollectionID).Not.Nullable(); + Map(x => x.TmdbEpisodeGroupID).Not.Nullable(); + Map(x => x.EnglishTitle).Not.Nullable(); + Map(x => x.EpisodeCount).Not.Nullable(); + Map(x => x.SeasonNumber).Not.Nullable(); + Map(x => x.IsLocked).Not.Nullable(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Optional/TMDB_CollectionMap.cs b/Shoko.Server/Mappings/TMDB/Optional/TMDB_CollectionMap.cs new file mode 100644 index 000000000..7f2a5dce3 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Optional/TMDB_CollectionMap.cs @@ -0,0 +1,22 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_CollectionMap : ClassMap<TMDB_Collection> +{ + public TMDB_CollectionMap() + { + Table("TMDB_Collection"); + + Not.LazyLoad(); + Id(x => x.TMDB_CollectionID); + + Map(x => x.TmdbCollectionID).Not.Nullable(); + Map(x => x.EnglishTitle).Not.Nullable(); + Map(x => x.EnglishOverview).Not.Nullable(); + Map(x => x.MovieCount).Not.Nullable(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Optional/TMDB_Collection_MovieMap.cs b/Shoko.Server/Mappings/TMDB/Optional/TMDB_Collection_MovieMap.cs new file mode 100644 index 000000000..b2000b2c8 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Optional/TMDB_Collection_MovieMap.cs @@ -0,0 +1,19 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_Collection_MovieMap : ClassMap<TMDB_Collection_Movie> +{ + public TMDB_Collection_MovieMap() + { + Table("TMDB_Collection_Movie"); + + Not.LazyLoad(); + Id(x => x.TMDB_Collection_MovieID); + + Map(x => x.TmdbCollectionID).Not.Nullable(); + Map(x => x.TmdbMovieID).Not.Nullable(); + Map(x => x.Ordering).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Optional/TMDB_NetworkMap.cs b/Shoko.Server/Mappings/TMDB/Optional/TMDB_NetworkMap.cs new file mode 100644 index 000000000..429a2b632 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Optional/TMDB_NetworkMap.cs @@ -0,0 +1,19 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_NetworkMap : ClassMap<TMDB_Network> +{ + public TMDB_NetworkMap() + { + Table("TMDB_Network"); + + Not.LazyLoad(); + Id(x => x.TMDB_NetworkID); + + Map(x => x.TmdbNetworkID).Not.Nullable(); + Map(x => x.Name).Not.Nullable(); + Map(x => x.CountryOfOrigin).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Optional/TMDB_Show_NetworkMap.cs b/Shoko.Server/Mappings/TMDB/Optional/TMDB_Show_NetworkMap.cs new file mode 100644 index 000000000..24a45a00a --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Optional/TMDB_Show_NetworkMap.cs @@ -0,0 +1,19 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_Show_NetworkMap : ClassMap<TMDB_Show_Network> +{ + public TMDB_Show_NetworkMap() + { + Table("TMDB_Show_Network"); + + Not.LazyLoad(); + Id(x => x.TMDB_Show_NetworkID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbNetworkID).Not.Nullable(); + Map(x => x.Ordering).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_CompanyMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_CompanyMap.cs new file mode 100644 index 000000000..f5627b8d9 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_CompanyMap.cs @@ -0,0 +1,19 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_CompanyMap : ClassMap<TMDB_Company> +{ + public TMDB_CompanyMap() + { + Table("TMDB_Company"); + + Not.LazyLoad(); + Id(x => x.TMDB_CompanyID); + + Map(x => x.TmdbCompanyID).Not.Nullable(); + Map(x => x.Name).Not.Nullable(); + Map(x => x.CountryOfOrigin).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_Company_EntityMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_Company_EntityMap.cs new file mode 100644 index 000000000..a982486b0 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_Company_EntityMap.cs @@ -0,0 +1,23 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class TMDB_Company_EntityMap : ClassMap<TMDB_Company_Entity> +{ + public TMDB_Company_EntityMap() + { + Table("TMDB_Company_Entity"); + + Not.LazyLoad(); + Id(x => x.TMDB_Company_EntityID); + + Map(x => x.TmdbCompanyID).Not.Nullable(); + Map(x => x.TmdbEntityType).Not.Nullable().CustomType<ForeignEntityType>(); + Map(x => x.TmdbEntityID).Not.Nullable(); + Map(x => x.Ordering).Not.Nullable(); + Map(x => x.ReleasedAt).CustomType<DateOnlyConverter>(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_EpisodeMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_EpisodeMap.cs new file mode 100644 index 000000000..e069454c6 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_EpisodeMap.cs @@ -0,0 +1,31 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_EpisodeMap : ClassMap<TMDB_Episode> +{ + public TMDB_EpisodeMap() + { + Table("TMDB_Episode"); + + Not.LazyLoad(); + Id(x => x.TMDB_EpisodeID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbSeasonID).Not.Nullable(); + Map(x => x.TmdbEpisodeID).Not.Nullable(); + Map(x => x.TvdbEpisodeID).Nullable(); + Map(x => x.EnglishTitle).Not.Nullable(); + Map(x => x.EnglishOverview).Not.Nullable(); + Map(x => x.EpisodeNumber).Not.Nullable(); + Map(x => x.SeasonNumber).Not.Nullable(); + Map(x => x.RuntimeMinutes).Column("Runtime"); + Map(x => x.UserRating).Not.Nullable(); + Map(x => x.UserVotes).Not.Nullable(); + Map(x => x.AiredAt).CustomType<DateOnlyConverter>(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_Episode_CastMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_Episode_CastMap.cs new file mode 100644 index 000000000..1ad639166 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_Episode_CastMap.cs @@ -0,0 +1,24 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_Episode_CastMap : ClassMap<TMDB_Episode_Cast> +{ + public TMDB_Episode_CastMap() + { + Table("TMDB_Episode_Cast"); + + Not.LazyLoad(); + Id(x => x.TMDB_Episode_CastID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbSeasonID).Not.Nullable(); + Map(x => x.TmdbEpisodeID).Not.Nullable(); + Map(x => x.TmdbPersonID).Not.Nullable(); + Map(x => x.TmdbCreditID).Not.Nullable(); + Map(x => x.CharacterName).Not.Nullable(); + Map(x => x.IsGuestRole).Not.Nullable(); + Map(x => x.Ordering).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_Episode_CrewMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_Episode_CrewMap.cs new file mode 100644 index 000000000..81ac5c633 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_Episode_CrewMap.cs @@ -0,0 +1,23 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_Episode_CrewMap : ClassMap<TMDB_Episode_Crew> +{ + public TMDB_Episode_CrewMap() + { + Table("TMDB_Episode_Crew"); + + Not.LazyLoad(); + Id(x => x.TMDB_Episode_CrewID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbSeasonID).Not.Nullable(); + Map(x => x.TmdbEpisodeID).Not.Nullable(); + Map(x => x.TmdbPersonID).Not.Nullable(); + Map(x => x.TmdbCreditID).Not.Nullable(); + Map(x => x.Job).Not.Nullable(); + Map(x => x.Department).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_ImageMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_ImageMap.cs new file mode 100644 index 000000000..744e562ab --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_ImageMap.cs @@ -0,0 +1,36 @@ +using FluentNHibernate.Mapping; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class TMDB_ImageMap : ClassMap<TMDB_Image> +{ + public TMDB_ImageMap() + { + Table("TMDB_Image"); + + Not.LazyLoad(); + Id(x => x.TMDB_ImageID); + + Map(x => x.TmdbMovieID); + Map(x => x.TmdbEpisodeID); + Map(x => x.TmdbSeasonID); + Map(x => x.TmdbShowID); + Map(x => x.TmdbCollectionID); + Map(x => x.TmdbNetworkID); + Map(x => x.TmdbCompanyID); + Map(x => x.TmdbPersonID); + Map(x => x.IsEnabled); + Map(x => x.ForeignType).Not.Nullable().CustomType<ForeignEntityType>(); + Map(x => x.ImageType).Not.Nullable().CustomType<ImageEntityType>(); + Map(x => x.Width).Not.Nullable(); + Map(x => x.Height).Not.Nullable(); + Map(x => x.Language).Not.Nullable().CustomType<TitleLanguageConverter>(); + Map(x => x.RemoteFileName).Not.Nullable(); + Map(x => x.UserRating).Not.Nullable(); + Map(x => x.UserVotes).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_MovieMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_MovieMap.cs new file mode 100644 index 000000000..fa364a491 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_MovieMap.cs @@ -0,0 +1,36 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_MovieMap : ClassMap<TMDB_Movie> +{ + public TMDB_MovieMap() + { + Table("TMDB_Movie"); + + Not.LazyLoad(); + Id(x => x.TMDB_MovieID); + + Map(x => x.TmdbMovieID).Not.Nullable(); + Map(x => x.TmdbCollectionID).Nullable(); + Map(x => x.ImdbMovieID).Nullable(); + Map(x => x.PosterPath).Nullable(); + Map(x => x.BackdropPath).Nullable(); + Map(x => x.EnglishTitle).Not.Nullable(); + Map(x => x.EnglishOverview).Not.Nullable(); + Map(x => x.OriginalTitle).Not.Nullable(); + Map(x => x.OriginalLanguageCode).Not.Nullable(); + Map(x => x.IsRestricted).Not.Nullable(); + Map(x => x.IsVideo).Not.Nullable(); + Map(x => x.Genres).Not.Nullable().CustomType<StringListConverter>(); + Map(x => x.ContentRatings).Not.Nullable().CustomType<TmdbContentRatingConverter>(); + Map(x => x.RuntimeMinutes).Column("Runtime"); + Map(x => x.UserRating).Not.Nullable(); + Map(x => x.UserVotes).Not.Nullable(); + Map(x => x.ReleasedAt).CustomType<DateOnlyConverter>(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_Movie_CastMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_Movie_CastMap.cs new file mode 100644 index 000000000..20a84d659 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_Movie_CastMap.cs @@ -0,0 +1,21 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_Movie_CastMap : ClassMap<TMDB_Movie_Cast> +{ + public TMDB_Movie_CastMap() + { + Table("TMDB_Movie_Cast"); + + Not.LazyLoad(); + Id(x => x.TMDB_Movie_CastID); + + Map(x => x.TmdbMovieID).Not.Nullable(); + Map(x => x.TmdbPersonID).Not.Nullable(); + Map(x => x.TmdbCreditID).Not.Nullable(); + Map(x => x.CharacterName).Not.Nullable(); + Map(x => x.Ordering).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_Movie_CrewMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_Movie_CrewMap.cs new file mode 100644 index 000000000..4b5e5fbbd --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_Movie_CrewMap.cs @@ -0,0 +1,21 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_Movie_CrewMap : ClassMap<TMDB_Movie_Crew> +{ + public TMDB_Movie_CrewMap() + { + Table("TMDB_Movie_Crew"); + + Not.LazyLoad(); + Id(x => x.TMDB_Movie_CrewID); + + Map(x => x.TmdbMovieID).Not.Nullable(); + Map(x => x.TmdbPersonID).Not.Nullable(); + Map(x => x.TmdbCreditID).Not.Nullable(); + Map(x => x.Job).Not.Nullable(); + Map(x => x.Department).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_PersonMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_PersonMap.cs new file mode 100644 index 000000000..59921afe9 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_PersonMap.cs @@ -0,0 +1,29 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_PersonMap : ClassMap<TMDB_Person> +{ + public TMDB_PersonMap() + { + Table("TMDB_Person"); + + Not.LazyLoad(); + Id(x => x.TMDB_PersonID); + + Map(x => x.TmdbPersonID).Not.Nullable(); + Map(x => x.EnglishName).Not.Nullable(); + Map(x => x.EnglishBiography).Not.Nullable(); + Map(x => x.Aliases).Not.Nullable().CustomType<StringListConverter>(); + Map(x => x.Gender).Not.Nullable().CustomType<PersonGender>(); + Map(x => x.IsRestricted).Not.Nullable(); + Map(x => x.BirthDay).CustomType<DateOnlyConverter>(); + Map(x => x.DeathDay).CustomType<DateOnlyConverter>(); + Map(x => x.PlaceOfBirth); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_SeasonMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_SeasonMap.cs new file mode 100644 index 000000000..12aac0a20 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_SeasonMap.cs @@ -0,0 +1,24 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_SeasonMap : ClassMap<TMDB_Season> +{ + public TMDB_SeasonMap() + { + Table("TMDB_Season"); + + Not.LazyLoad(); + Id(x => x.TMDB_SeasonID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TmdbSeasonID).Not.Nullable(); + Map(x => x.EnglishTitle).Not.Nullable(); + Map(x => x.EnglishOverview).Not.Nullable(); + Map(x => x.EpisodeCount).Not.Nullable(); + Map(x => x.SeasonNumber).Not.Nullable(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/TMDB_ShowMap.cs b/Shoko.Server/Mappings/TMDB/TMDB_ShowMap.cs new file mode 100644 index 000000000..0d76bf8bc --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/TMDB_ShowMap.cs @@ -0,0 +1,38 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Databases.NHibernate; +using Shoko.Server.Models.TMDB; + +namespace Shoko.Server.Mappings; + +public class TMDB_ShowMap : ClassMap<TMDB_Show> +{ + public TMDB_ShowMap() + { + Table("TMDB_Show"); + + Not.LazyLoad(); + Id(x => x.TMDB_ShowID); + + Map(x => x.TmdbShowID).Not.Nullable(); + Map(x => x.TvdbShowID).Nullable(); + Map(x => x.PosterPath).Nullable(); + Map(x => x.BackdropPath).Nullable(); + Map(x => x.EnglishTitle).Not.Nullable(); + Map(x => x.EnglishOverview).Not.Nullable(); + Map(x => x.OriginalTitle).Not.Nullable(); + Map(x => x.OriginalLanguageCode).Not.Nullable(); + Map(x => x.IsRestricted).Not.Nullable(); + Map(x => x.Genres).Not.Nullable().CustomType<StringListConverter>(); + Map(x => x.ContentRatings).Not.Nullable().CustomType<TmdbContentRatingConverter>(); + Map(x => x.EpisodeCount).Not.Nullable(); + Map(x => x.SeasonCount).Not.Nullable(); + Map(x => x.AlternateOrderingCount).Not.Nullable(); + Map(x => x.UserRating).Not.Nullable(); + Map(x => x.UserVotes).Not.Nullable(); + Map(x => x.FirstAiredAt).CustomType<DateOnlyConverter>(); + Map(x => x.LastAiredAt).CustomType<DateOnlyConverter>(); + Map(x => x.CreatedAt).Not.Nullable(); + Map(x => x.LastUpdatedAt).Not.Nullable(); + Map(x => x.PreferredAlternateOrderingID); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Text/TMDB_OverviewMap.cs b/Shoko.Server/Mappings/TMDB/Text/TMDB_OverviewMap.cs new file mode 100644 index 000000000..17118afaa --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Text/TMDB_OverviewMap.cs @@ -0,0 +1,22 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class TMDB_OverviewMap : ClassMap<TMDB_Overview> +{ + public TMDB_OverviewMap() + { + Table("TMDB_Overview"); + + Not.LazyLoad(); + Id(x => x.TMDB_OverviewID); + + Map(x => x.ParentID).Not.Nullable(); + Map(x => x.ParentType).Not.Nullable().CustomType<ForeignEntityType>(); + Map(x => x.LanguageCode).Not.Nullable(); + Map(x => x.CountryCode).Not.Nullable(); + Map(x => x.Value).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/TMDB/Text/TMDB_TitleMap.cs b/Shoko.Server/Mappings/TMDB/Text/TMDB_TitleMap.cs new file mode 100644 index 000000000..ee3c11da6 --- /dev/null +++ b/Shoko.Server/Mappings/TMDB/Text/TMDB_TitleMap.cs @@ -0,0 +1,22 @@ +using FluentNHibernate.Mapping; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +namespace Shoko.Server.Mappings; + +public class TMDB_TitleMap : ClassMap<TMDB_Title> +{ + public TMDB_TitleMap() + { + Table("TMDB_Title"); + + Not.LazyLoad(); + Id(x => x.TMDB_TitleID); + + Map(x => x.ParentID).Not.Nullable(); + Map(x => x.ParentType).Not.Nullable().CustomType<ForeignEntityType>(); + Map(x => x.LanguageCode).Not.Nullable(); + Map(x => x.CountryCode).Not.Nullable(); + Map(x => x.Value).Not.Nullable(); + } +} diff --git a/Shoko.Server/Mappings/Trakt_ShowMap.cs b/Shoko.Server/Mappings/Trakt_ShowMap.cs index 303e308c3..fb54d0e7b 100644 --- a/Shoko.Server/Mappings/Trakt_ShowMap.cs +++ b/Shoko.Server/Mappings/Trakt_ShowMap.cs @@ -1,5 +1,5 @@ using FluentNHibernate.Mapping; -using Shoko.Models.Server; +using Shoko.Server.Models.Trakt; namespace Shoko.Server.Mappings; @@ -10,11 +10,11 @@ public Trakt_ShowMap() Not.LazyLoad(); Id(x => x.Trakt_ShowID); - Map(x => x.Overview); - Map(x => x.Title); Map(x => x.TraktID); - Map(x => x.TvDB_ID); - Map(x => x.URL); + Map(x => x.TmdbShowID); + Map(x => x.Title); Map(x => x.Year); + Map(x => x.URL); + Map(x => x.Overview); } } diff --git a/Shoko.Server/Mappings/TvDB_EpisodeMap.cs b/Shoko.Server/Mappings/TvDB_EpisodeMap.cs deleted file mode 100644 index ccc0d61f9..000000000 --- a/Shoko.Server/Mappings/TvDB_EpisodeMap.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class TvDB_EpisodeMap : ClassMap<TvDB_Episode> -{ - public TvDB_EpisodeMap() - { - Not.LazyLoad(); - Id(x => x.TvDB_EpisodeID); - - Map(x => x.AbsoluteNumber); - Map(x => x.EpImgFlag).Not.Nullable(); - Map(x => x.EpisodeName); - Map(x => x.EpisodeNumber).Not.Nullable(); - Map(x => x.Filename); - Map(x => x.Id).Not.Nullable(); - Map(x => x.Overview).CustomType("StringClob"); - Map(x => x.SeasonID).Not.Nullable(); - Map(x => x.SeasonNumber).Not.Nullable(); - Map(x => x.SeriesID).Not.Nullable(); - - Map(x => x.AirsAfterSeason); - Map(x => x.AirsBeforeEpisode); - Map(x => x.AirsBeforeSeason); - Map(x => x.Rating); - Map(x => x.AirDate); - } -} diff --git a/Shoko.Server/Mappings/TvDB_ImageFanartMap.cs b/Shoko.Server/Mappings/TvDB_ImageFanartMap.cs deleted file mode 100644 index 38b132853..000000000 --- a/Shoko.Server/Mappings/TvDB_ImageFanartMap.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class TvDB_ImageFanartMap : ClassMap<TvDB_ImageFanart> -{ - public TvDB_ImageFanartMap() - { - Not.LazyLoad(); - Id(x => x.TvDB_ImageFanartID); - - Map(x => x.BannerPath); - Map(x => x.BannerType); - Map(x => x.BannerType2); - Map(x => x.Chosen).Not.Nullable(); - Map(x => x.Colors); - Map(x => x.Enabled).Not.Nullable(); - Map(x => x.Id).Not.Nullable(); - Map(x => x.Language); - Map(x => x.SeriesID).Not.Nullable(); - Map(x => x.VignettePath); - } -} diff --git a/Shoko.Server/Mappings/TvDB_ImagePosterMap.cs b/Shoko.Server/Mappings/TvDB_ImagePosterMap.cs deleted file mode 100644 index 5daba1113..000000000 --- a/Shoko.Server/Mappings/TvDB_ImagePosterMap.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class TvDB_ImagePosterMap : ClassMap<TvDB_ImagePoster> -{ - public TvDB_ImagePosterMap() - { - Not.LazyLoad(); - Id(x => x.TvDB_ImagePosterID); - - Map(x => x.BannerPath); - Map(x => x.BannerType); - Map(x => x.BannerType2); - Map(x => x.Enabled).Not.Nullable(); - Map(x => x.Id).Not.Nullable(); - Map(x => x.Language); - Map(x => x.SeriesID).Not.Nullable(); - Map(x => x.SeasonNumber); - } -} diff --git a/Shoko.Server/Mappings/TvDB_ImageWideBannerMap.cs b/Shoko.Server/Mappings/TvDB_ImageWideBannerMap.cs deleted file mode 100644 index f5679c9fa..000000000 --- a/Shoko.Server/Mappings/TvDB_ImageWideBannerMap.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class TvDB_ImageWideBannerMap : ClassMap<TvDB_ImageWideBanner> -{ - public TvDB_ImageWideBannerMap() - { - Not.LazyLoad(); - Id(x => x.TvDB_ImageWideBannerID); - - Map(x => x.BannerPath); - Map(x => x.BannerType); - Map(x => x.BannerType2); - Map(x => x.Enabled).Not.Nullable(); - Map(x => x.Id).Not.Nullable(); - Map(x => x.Language); - Map(x => x.SeriesID).Not.Nullable(); - Map(x => x.SeasonNumber); - } -} diff --git a/Shoko.Server/Mappings/TvDB_SeriesMap.cs b/Shoko.Server/Mappings/TvDB_SeriesMap.cs deleted file mode 100644 index 5523c019a..000000000 --- a/Shoko.Server/Mappings/TvDB_SeriesMap.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentNHibernate.Mapping; -using Shoko.Models.Server; - -namespace Shoko.Server.Mappings; - -public class TvDB_SeriesMap : ClassMap<TvDB_Series> -{ - public TvDB_SeriesMap() - { - Not.LazyLoad(); - Id(x => x.TvDB_SeriesID); - - Map(x => x.SeriesID).Not.Nullable(); - Map(x => x.Banner); - Map(x => x.Fanart); - Map(x => x.Lastupdated); - Map(x => x.Overview); - Map(x => x.Poster); - Map(x => x.SeriesName); - Map(x => x.Status); - Map(x => x.Rating); - } -} diff --git a/Shoko.Server/Mappings/VideoLocalMap.cs b/Shoko.Server/Mappings/VideoLocalMap.cs index 4e5385cc4..8fa55b3d0 100644 --- a/Shoko.Server/Mappings/VideoLocalMap.cs +++ b/Shoko.Server/Mappings/VideoLocalMap.cs @@ -15,7 +15,9 @@ public VideoLocalMap() Map(x => x.DateTimeUpdated).Not.Nullable(); Map(x => x.DateTimeCreated).Not.Nullable(); Map(x => x.DateTimeImported); +#pragma warning disable CS0618 Map(x => x.FileName).Not.Nullable(); +#pragma warning restore CS0618 Map(x => x.FileSize).Not.Nullable(); Map(x => x.Hash).Not.Nullable(); Map(x => x.CRC32); diff --git a/Shoko.Server/Models/AniDB/AniDB_Anime_PreferredImage.cs b/Shoko.Server/Models/AniDB/AniDB_Anime_PreferredImage.cs new file mode 100644 index 000000000..c4da10f31 --- /dev/null +++ b/Shoko.Server/Models/AniDB/AniDB_Anime_PreferredImage.cs @@ -0,0 +1,55 @@ + +using Shoko.Models.Enums; +using Shoko.Models.Interfaces; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Extensions; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.AniDB; + +public class AniDB_Anime_PreferredImage +{ + public int AniDB_Anime_PreferredImageID { get; set; } + + public int AnidbAnimeID { get; set; } + + public int ImageID { get; set; } + + public ImageEntityType ImageType { get; set; } + + public DataSourceType ImageSource { get; set; } + + public AniDB_Anime_PreferredImage() { } + + public AniDB_Anime_PreferredImage(int anidbAnimeId, ImageEntityType imageType) + { + AnidbAnimeID = anidbAnimeId; + ImageType = imageType; + } + + public IImageMetadata? GetImageMetadata() + { + return ImageSource switch + { + DataSourceType.AniDB when ImageType is ImageEntityType.Poster => RepoFactory.AniDB_Anime.GetByAnimeID(AnidbAnimeID) is { } anime ? anime.GetImageMetadata(true) : null, + DataSourceType.TMDB => RepoFactory.TMDB_Image.GetByID(ImageID)?.GetImageMetadata(true), + _ => null, + }; + } + + public IImageEntity? GetImageEntity() + => ImageSource switch + { + DataSourceType.TMDB => ImageType switch + { + ImageEntityType.Backdrop => + RepoFactory.TMDB_Image.GetByID(ImageID)?.ToClientFanart(), + ImageEntityType.Poster => + RepoFactory.TMDB_Image.GetByID(ImageID)?.ToClientPoster(), + _ => null, + }, + _ => null, + }; +} diff --git a/Shoko.Server/Models/AniDB/AniDB_Character_Creator.cs b/Shoko.Server/Models/AniDB/AniDB_Character_Creator.cs new file mode 100644 index 000000000..b830a74ce --- /dev/null +++ b/Shoko.Server/Models/AniDB/AniDB_Character_Creator.cs @@ -0,0 +1,16 @@ + +#nullable enable +namespace Shoko.Server.Models.AniDB; + +public class AniDB_Character_Creator +{ + #region DB columns + + public int AniDB_Character_CreatorID { get; set; } + + public int CharacterID { get; set; } + + public int CreatorID { get; set; } + + #endregion +} diff --git a/Shoko.Server/Models/AniDB/AniDB_Creator.cs b/Shoko.Server/Models/AniDB/AniDB_Creator.cs new file mode 100644 index 000000000..e69d16228 --- /dev/null +++ b/Shoko.Server/Models/AniDB/AniDB_Creator.cs @@ -0,0 +1,67 @@ +using System; +using Shoko.Server.Providers.AniDB; + +#nullable enable +namespace Shoko.Server.Models.AniDB; + +public class AniDB_Creator +{ + #region DB Columns + + /// <summary> + /// The local ID of the creator. + /// </summary> + public int AniDB_CreatorID { get; set; } + + /// <summary> + /// The global ID of the creator. + /// </summary> + public int CreatorID { get; set; } + + /// <summary> + /// The name of the creator, transcribed to use the latin alphabet. + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// The original name of the creator. + /// </summary> + public string? OriginalName { get; set; } + + /// <summary> + /// The type of creator. + /// </summary> + public CreatorType Type { get; set; } + + /// <summary> + /// The location of the image associated with the creator. + /// </summary> + public string? ImagePath { get; set; } + + /// <summary> + /// The URL of the creator's English homepage. + /// </summary> + public string? EnglishHomepageUrl { get; set; } + + /// <summary> + /// The URL of the creator's Japanese homepage. + /// </summary> + public string? JapaneseHomepageUrl { get; set; } + + /// <summary> + /// The URL of the creator's English Wikipedia page. + /// </summary> + public string? EnglishWikiUrl { get; set; } + + /// <summary> + /// The URL of the creator's Japanese Wikipedia page. + /// </summary> + public string? JapaneseWikiUrl { get; set; } + + /// <summary> + /// The date that the creator was last updated on AniDB. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion +} diff --git a/Shoko.Server/Models/AniDB/AniDB_Episode_PreferredImage.cs b/Shoko.Server/Models/AniDB/AniDB_Episode_PreferredImage.cs new file mode 100644 index 000000000..06a81b782 --- /dev/null +++ b/Shoko.Server/Models/AniDB/AniDB_Episode_PreferredImage.cs @@ -0,0 +1,44 @@ + +using Shoko.Models.Enums; +using Shoko.Models.Interfaces; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Extensions; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.AniDB; + +public class AniDB_Episode_PreferredImage +{ + public int AniDB_Episode_PreferredImageID { get; set; } + + public int AnidbAnimeID { get; set; } + + public int AnidbEpisodeID { get; set; } + + public int ImageID { get; set; } + + public ImageEntityType ImageType { get; set; } + + public DataSourceType ImageSource { get; set; } + + public AniDB_Episode_PreferredImage() { } + + public AniDB_Episode_PreferredImage(int anidbAnimeId, int anidbEpisodeId, ImageEntityType imageType) + { + AnidbAnimeID = anidbAnimeId; + AnidbEpisodeID = anidbEpisodeId; + ImageType = imageType; + } + + public IImageMetadata? GetImageMetadata() + { + return ImageSource switch + { + DataSourceType.AniDB when ImageType is ImageEntityType.Poster => RepoFactory.AniDB_Anime.GetByAnimeID(AnidbAnimeID) is { } anime ? anime.GetImageMetadata(true) : null, + DataSourceType.TMDB => RepoFactory.TMDB_Image.GetByID(ImageID)?.GetImageMetadata(true), + _ => null, + }; + } +} diff --git a/Shoko.Server/Models/AniDB/AniDB_ReleaseGroup.cs b/Shoko.Server/Models/AniDB/AniDB_ReleaseGroup.cs new file mode 100644 index 000000000..e24eaa1ef --- /dev/null +++ b/Shoko.Server/Models/AniDB/AniDB_ReleaseGroup.cs @@ -0,0 +1,48 @@ +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; + +#nullable enable +namespace Shoko.Server.Models.AniDB; + +public class AniDB_ReleaseGroup : IReleaseGroup +{ + public int AniDB_ReleaseGroupID { get; set; } + + public int GroupID { get; set; } + + public int Rating { get; set; } + + public int Votes { get; set; } + + public int AnimeCount { get; set; } + + public int FileCount { get; set; } + + public string? GroupName { get; set; } + + public string? GroupNameShort { get; set; } + + public string? IRCChannel { get; set; } + + public string? IRCServer { get; set; } + + public string? URL { get; set; } + + public string? Picname { get; set; } + + #region IMetadata Implementation + + DataSourceEnum IMetadata.Source => DataSourceEnum.AniDB; + + int IMetadata<int>.ID => GroupID; + + #endregion + + #region IReleaseGroup Implementation + + string? IReleaseGroup.Name => GroupName; + + string? IReleaseGroup.ShortName => GroupNameShort; + + #endregion +} diff --git a/Shoko.Server/Models/AniDB_Message.cs b/Shoko.Server/Models/AniDB_Message.cs new file mode 100644 index 000000000..8d0af6d42 --- /dev/null +++ b/Shoko.Server/Models/AniDB_Message.cs @@ -0,0 +1,87 @@ +using System; +using Shoko.Server.Server; + +namespace Shoko.Server.Models; + +public class AniDB_Message +{ + #region DB Columns + + public int AniDB_MessageID { get; set; } + public int MessageID { get; set; } + public int FromUserId { get; set; } + public string FromUserName { get; set; } + public DateTime SentAt { get; set; } + public DateTime FetchedAt { get; set; } + public AniDBMessageType Type { get; set; } + public string Title { get; set; } + public string Body { get; set; } + public AniDBMessageFlags Flags { get; set; } + + #endregion + + #region Flags + + public bool IsReadOnAniDB + { + get + { + return Flags.HasFlag(AniDBMessageFlags.ReadOnAniDB); + } + set + { + if (value) + Flags |= AniDBMessageFlags.ReadOnAniDB; + else + Flags &= ~AniDBMessageFlags.ReadOnAniDB; + } + } + + public bool IsReadOnShoko + { + get + { + return Flags.HasFlag(AniDBMessageFlags.ReadOnShoko); + } + set + { + if (value) + Flags |= AniDBMessageFlags.ReadOnShoko; + else + Flags &= ~AniDBMessageFlags.ReadOnShoko; + } + } + + public bool IsFileMoved + { + get + { + return Flags.HasFlag(AniDBMessageFlags.FileMoved); + } + set + { + if (value) + Flags |= AniDBMessageFlags.FileMoved; + else + Flags &= ~AniDBMessageFlags.FileMoved; + } + } + + public bool IsFileMoveHandled + { + get + { + return Flags.HasFlag(AniDBMessageFlags.FileMoveHandled); + } + set + { + if (value) + Flags |= AniDBMessageFlags.FileMoveHandled; + else + Flags &= ~AniDBMessageFlags.FileMoveHandled; + } + } + + #endregion + +} diff --git a/Shoko.Server/Models/AniDB_NotifyQueue.cs b/Shoko.Server/Models/AniDB_NotifyQueue.cs new file mode 100644 index 000000000..06bbd2a97 --- /dev/null +++ b/Shoko.Server/Models/AniDB_NotifyQueue.cs @@ -0,0 +1,12 @@ +using System; +using Shoko.Server.Server; + +namespace Shoko.Server.Models; + +public class AniDB_NotifyQueue +{ + public int AniDB_NotifyQueueID { get; set; } + public AniDBNotifyType Type { get; set; } + public int ID { get; set; } + public DateTime AddedAt { get; set; } +} diff --git a/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Episode.cs b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Episode.cs new file mode 100644 index 000000000..ab2f36613 --- /dev/null +++ b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Episode.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.CrossReference; + +public class CrossRef_AniDB_TMDB_Episode +{ + #region Database Columns + + public int CrossRef_AniDB_TMDB_EpisodeID { get; set; } + + public int AnidbAnimeID { get; set; } + + public int AnidbEpisodeID { get; set; } + + public int TmdbShowID { get; set; } + + public int TmdbEpisodeID { get; set; } + + public int Ordering { get; set; } + + public MatchRating MatchRating { get; set; } + + #endregion + #region Constructors + + public CrossRef_AniDB_TMDB_Episode() { } + + public CrossRef_AniDB_TMDB_Episode(int anidbEpisodeId, int anidbAnimeId, int tmdbEpisodeId, int tmdbShowId, MatchRating rating = MatchRating.UserVerified, int ordering = 0) + { + AnidbEpisodeID = anidbEpisodeId; + AnidbAnimeID = anidbAnimeId; + TmdbEpisodeID = tmdbEpisodeId; + TmdbShowID = tmdbShowId; + Ordering = ordering; + MatchRating = rating; + } + + #endregion + #region Methods + + public SVR_AniDB_Episode? AnidbEpisode => + RepoFactory.AniDB_Episode.GetByEpisodeID(AnidbEpisodeID); + + public SVR_AniDB_Anime? AnidbAnime => + RepoFactory.AniDB_Anime.GetByAnimeID(AnidbAnimeID); + + public SVR_AnimeEpisode? AnimeEpisode => + RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(AnidbEpisodeID); + + public SVR_AnimeSeries? AnimeSeries => + RepoFactory.AnimeSeries.GetByAnimeID(AnidbAnimeID); + + public TMDB_Episode? TmdbEpisode => + TmdbEpisodeID == 0 ? null : RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(TmdbEpisodeID); + + public CrossRef_AniDB_TMDB_Season? TmdbSeasonCrossReference => + TmdbEpisode is { } tmdbEpisode + ? new(AnidbAnimeID, tmdbEpisode.TmdbSeasonID, TmdbShowID, tmdbEpisode.SeasonNumber) + : null; + + public TMDB_Season? TmdbSeason => + TmdbEpisode?.TmdbSeason; + + public TMDB_Show? TmdbShow => + TmdbShowID == 0 ? null : RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all images for the episode, or all images for the given + /// <paramref name="entityType"/> provided for the episode. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the episode. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbEpisodeIDAndType(TmdbEpisodeID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbEpisodeID(TmdbEpisodeID); + + /// <summary> + /// Get all images for the episode, or all images for the given + /// <paramref name="entityType"/> provided for the episode. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <param name="preferredImages">The preferred images.</param> + /// <returns>A read-only list of images that are linked to the episode. + /// </returns> + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + #endregion +} diff --git a/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Movie.cs b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Movie.cs new file mode 100644 index 000000000..5e8b31c0f --- /dev/null +++ b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Movie.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.CrossReference; + +public class CrossRef_AniDB_TMDB_Movie +{ + #region Database Columns + + public int CrossRef_AniDB_TMDB_MovieID { get; set; } + + public int AnidbAnimeID { get; set; } + + public int AnidbEpisodeID { get; set; } + + public int TmdbMovieID { get; set; } + + public CrossRefSource Source { get; set; } + + #endregion + #region Constructors + + public CrossRef_AniDB_TMDB_Movie() { } + + public CrossRef_AniDB_TMDB_Movie(int anidbEpisodeId, int anidbAnimeId, int tmdbMovieId, CrossRefSource source = CrossRefSource.User) + { + AnidbEpisodeID = anidbEpisodeId; + AnidbAnimeID = anidbAnimeId; + TmdbMovieID = tmdbMovieId; + Source = source; + } + + #endregion + + #region Methods + + public SVR_AniDB_Episode? AnidbEpisode => RepoFactory.AniDB_Episode.GetByEpisodeID(AnidbEpisodeID); + + public SVR_AniDB_Anime? AnidbAnime => + RepoFactory.AniDB_Anime.GetByAnimeID(AnidbAnimeID); + + public SVR_AnimeEpisode? AnimeEpisode => RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(AnidbEpisodeID); + + public SVR_AnimeSeries? AnimeSeries => + RepoFactory.AnimeSeries.GetByAnimeID(AnidbAnimeID); + + public TMDB_Movie? TmdbMovie + => RepoFactory.TMDB_Movie.GetByTmdbMovieID(TmdbMovieID); + + /// <summary> + /// Get all images for the movie, or all images for the given + /// <paramref name="entityType"/> provided for the movie. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the movie. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbMovieIDAndType(TmdbMovieID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbMovieID(TmdbMovieID); + + /// <summary> + /// Get all images for the movie, or all images for the given + /// <paramref name="entityType"/> provided for the movie. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <param name="preferredImages">The preferred images.</param> + /// <returns>A read-only list of images that are linked to the movie. + /// </returns> + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + #endregion +} diff --git a/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Season.cs b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Season.cs new file mode 100644 index 000000000..46b75422f --- /dev/null +++ b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Season.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.CrossReference; + +/// <summary> +/// Not actually stored in the database, but made from the episode cross-reference. +/// </summary> +public class CrossRef_AniDB_TMDB_Season : IEquatable<CrossRef_AniDB_TMDB_Season> +{ + #region Columns + + public int AnidbAnimeID { get; set; } + + public int TmdbShowID { get; set; } + + public int TmdbSeasonID { get; set; } + + public int SeasonNumber { get; set; } + + #endregion + #region Constructors + + public CrossRef_AniDB_TMDB_Season(int anidbAnimeId, int tmdbSeasonId, int tmdbShowId, int seasonNumber = 1) + { + AnidbAnimeID = anidbAnimeId; + TmdbSeasonID = tmdbSeasonId; + TmdbShowID = tmdbShowId; + SeasonNumber = seasonNumber; + } + + #endregion + #region Methods + + public SVR_AniDB_Anime? AnidbAnime => + RepoFactory.AniDB_Anime.GetByAnimeID(AnidbAnimeID); + + public SVR_AnimeSeries? AnimeSeries => + RepoFactory.AnimeSeries.GetByAnimeID(AnidbAnimeID); + + public TMDB_Season? TmdbSeason => + TmdbSeasonID == 0 ? null : RepoFactory.TMDB_Season.GetByTmdbSeasonID(TmdbSeasonID); + + public TMDB_Show? TmdbShow => + TmdbShowID == 0 ? null : RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all images for the episode, or all images for the given + /// <paramref name="entityType"/> provided for the episode. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the episode. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbSeasonIDAndType(TmdbSeasonID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbSeasonID(TmdbSeasonID); + + /// <summary> + /// Get all images for the episode, or all images for the given + /// <paramref name="entityType"/> provided for the episode. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <param name="preferredImages">The preferred images.</param> + /// <returns>A read-only list of images that are linked to the episode. + /// </returns> + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + public bool Equals(CrossRef_AniDB_TMDB_Season? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return AnidbAnimeID == other.AnidbAnimeID + && TmdbSeasonID == other.TmdbSeasonID + && TmdbShowID == other.TmdbShowID + && SeasonNumber == other.SeasonNumber; + } + + public override bool Equals(object? obj) + => Equals(obj as CrossRef_AniDB_TMDB_Season); + + public override int GetHashCode() + => HashCode.Combine(AnidbAnimeID, TmdbSeasonID, TmdbShowID, SeasonNumber); + + #endregion +} diff --git a/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Show.cs b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Show.cs new file mode 100644 index 000000000..01bae3c99 --- /dev/null +++ b/Shoko.Server/Models/CrossReference/CrossRef_AniDB_TMDB_Show.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.CrossReference; + +public class CrossRef_AniDB_TMDB_Show +{ + #region Database Columns + + public int CrossRef_AniDB_TMDB_ShowID { get; set; } + + public int AnidbAnimeID { get; set; } + + public int TmdbShowID { get; set; } + + public CrossRefSource Source { get; set; } + + #endregion + #region Constructors + + public CrossRef_AniDB_TMDB_Show() { } + + public CrossRef_AniDB_TMDB_Show(int anidbAnimeId, int tmdbShowId, CrossRefSource source = CrossRefSource.User) + { + AnidbAnimeID = anidbAnimeId; + TmdbShowID = tmdbShowId; + Source = source; + } + + #endregion + #region Methods + + public SVR_AniDB_Anime? AnidbAnime => + RepoFactory.AniDB_Anime.GetByAnimeID(AnidbAnimeID); + + public SVR_AnimeSeries? AnimeSeries => + RepoFactory.AnimeSeries.GetByAnimeID(AnidbAnimeID); + + public TMDB_Show? TmdbShow => + RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all images for the show, or all images for the given + /// <paramref name="entityType"/> provided for the show. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the show. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbShowIDAndType(TmdbShowID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all images for the show, or all images for the given + /// <paramref name="entityType"/> provided for the show. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <param name="preferredImages">The preferred images.</param> + /// <returns>A read-only list of images that are linked to the show. + /// </returns> + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + #endregion +} diff --git a/Shoko.Server/Models/Image_Base.cs b/Shoko.Server/Models/Image_Base.cs new file mode 100644 index 000000000..727599c9b --- /dev/null +++ b/Shoko.Server/Models/Image_Base.cs @@ -0,0 +1,357 @@ + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using ImageMagick; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Retry; +using Shoko.Commons.Utils; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Utilities; + +#nullable enable +namespace Shoko.Server.Models; + +public class Image_Base : IImageMetadata +{ + #region Static Fields + + private static readonly object _lockObj = new(); + + private static ILogger<Image_Base>? _logger = null; + + private static ILogger<Image_Base> Logger + { + get + { + if (_logger is not null) + return _logger; + + lock (_lockObj) + { + _logger = Utils.ServiceContainer.GetService<ILogger<Image_Base>>()!; + return _logger; + } + } + } + + private static HttpClient? _httpClient = null; + + private static HttpClient Client + { + get + { + if (_httpClient is not null) + return _httpClient; + + lock (_lockObj) + { + if (_httpClient is not null) + return _httpClient; + _httpClient = new(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "JMM"); + _httpClient.Timeout = TimeSpan.FromMinutes(3); + return _httpClient; + } + } + } + + private static readonly TimeSpan[] _retryTimeSpans = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)]; + + private static readonly AsyncRetryPolicy _retryPolicy = Policy + .Handle<HttpRequestException>() + .Or<TaskCanceledException>() + .WaitAndRetryAsync(_retryTimeSpans, (exception, timeSpan) => + { + if (timeSpan == _retryTimeSpans[3] || (exception is HttpRequestException hre && hre.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden)) + throw exception; + }); + + #endregion + + private int InternalID { get; } = 0; + + /// <inheritdoc/> + public virtual int ID + { + get => InternalID; + } + + private string? _contentType = null; + + public string ContentType + { + get + { + if (_contentType is not null) + return _contentType; + + if (!string.IsNullOrEmpty(LocalPath)) + return _contentType = MimeMapping.MimeUtility.GetMimeMapping(LocalPath); + + return _contentType = MimeMapping.MimeUtility.UnknownMimeType; + } + } + + /// <inheritdoc/> + public DataSourceEnum Source { get; } + + /// <inheritdoc/> + public ImageEntityType ImageType { get; set; } + + /// <inheritdoc/> + public bool IsPreferred { get; set; } + + /// <inheritdoc/> + public bool IsEnabled { get; set; } + + /// <inheritdoc/> + public virtual bool IsLocked => true; + + /// <inheritdoc/> + public bool IsAvailable + { + get => IsLocalAvailable || IsRemoteAvailable; + } + + [MemberNotNullWhen(true, nameof(LocalPath))] + public bool IsLocalAvailable + { + get => !string.IsNullOrEmpty(LocalPath) && File.Exists(LocalPath) && Misc.IsImageValid(LocalPath); + } + + private bool? _urlExists = null; + + [MemberNotNullWhen(true, nameof(RemoteURL))] + public bool IsRemoteAvailable + { + get + { + if (_urlExists.HasValue) + return _urlExists.Value; + lock (this) + return CheckIsRemoteAvailableAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + set + { + _urlExists = null; + } + } + + [MemberNotNullWhen(true, nameof(RemoteURL))] + private async Task<bool> CheckIsRemoteAvailableAsync() + { + if (_urlExists.HasValue) + return _urlExists.Value; + + if (string.IsNullOrEmpty(RemoteURL)) + { + _urlExists = false; + return false; + } + + try + { + var stream = await _retryPolicy.ExecuteAsync(async () => await Client.GetStreamAsync(RemoteURL)); + var bytes = new byte[12]; + stream.Read(bytes, 0, 12); + stream.Close(); + _urlExists = Misc.IsImageValid(bytes); + return _urlExists.Value; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to retrieve resource at url: {RemoteURL}", RemoteURL); + _urlExists = false; + return false; + } + } + + /// <inheritdoc/> + public double AspectRatio + => Width / Height; + + internal int? _width = null; + + /// <inheritdoc/> + public virtual int Width + { + get + { + if (_width.HasValue) + return _width.Value; + + RefreshMetadata(); + + return _width ?? 0; + } + set { } + } + + internal int? _height = null; + + /// <inheritdoc/> + public virtual int Height + { + get + { + if (_height.HasValue) + return _height.Value; + + RefreshMetadata(); + + return _height ?? 0; + } + set { } + } + + /// <inheritdoc/> + public string? LanguageCode + { + get => Language == TitleLanguage.Unknown ? null : Language.GetString(); + set => Language = value?.GetTitleLanguage() ?? TitleLanguage.Unknown; + } + + /// <inheritdoc/> + public TitleLanguage Language { get; set; } + + private string? _remoteURL = null; + + /// <inheritdoc/> + public virtual string? RemoteURL + { + get => _remoteURL; + set + { + _contentType = null; + _width = null; + _height = null; + _urlExists = null; + _remoteURL = value; + } + } + + private string? _localPath = null; + + /// <inheritdoc/> + public virtual string? LocalPath + { + get => _localPath; + set + { + _contentType = null; + _width = null; + _height = null; + _localPath = value; + } + } + + public Image_Base(DataSourceEnum source, ImageEntityType type, int id, string? localPath = null, string? remoteURL = null) + { + InternalID = id; + ImageType = type; + IsPreferred = false; + IsEnabled = false; + RemoteURL = remoteURL; + LocalPath = localPath; + Source = source; + } + + private void RefreshMetadata() + { + try + { + var stream = GetStream(); + if (stream == null) + { + _width = 0; + _height = 0; + return; + } + + var info = new MagickImageInfo(stream); + if (info == null) + { + _width = 0; + _height = 0; + return; + } + + _width = info.Width; + _height = info.Height; + } + catch + { + _width = 0; + _height = 0; + return; + } + } + + public Stream? GetStream(bool allowLocal = true, bool allowRemote = true) + { + if (allowLocal && IsLocalAvailable) + return new FileStream(LocalPath, FileMode.Open, FileAccess.Read); + + if (allowRemote && DownloadImage().ConfigureAwait(false).GetAwaiter().GetResult() && IsLocalAvailable) + return new FileStream(LocalPath, FileMode.Open, FileAccess.Read); + + return null; + } + + public async Task<bool> DownloadImage(bool force = false) + { + if (string.IsNullOrEmpty(LocalPath) || string.IsNullOrEmpty(RemoteURL)) + return false; + + if (!force && IsLocalAvailable) + return true; + + var binary = await _retryPolicy.ExecuteAsync(async () => await Client.GetByteArrayAsync(RemoteURL)); + if (!Misc.IsImageValid(binary)) + throw new HttpRequestException($"Invalid image data format at remote resource: {RemoteURL}", null, HttpStatusCode.ExpectationFailed); + + // Ensure directory structure exists. + var dirPath = Path.GetDirectoryName(LocalPath); + if (!string.IsNullOrEmpty(dirPath)) + Directory.CreateDirectory(dirPath); + + // Delete existing file if re-downloading. + if (File.Exists(LocalPath)) + File.Delete(LocalPath); + + // Write the memory-cached image onto the disk. + File.WriteAllBytes(LocalPath, binary); + + // "Flush" the cached image. + _urlExists = null; + + return true; + } + + public override int GetHashCode() + => System.HashCode.Combine(ID, Source, ImageType); + + public override bool Equals(object? other) + { + if (other is null || other is not IImageMetadata imageMetadata) + return false; + return Equals(imageMetadata); + } + + public bool Equals(IImageMetadata? other) + { + if (other is null) + return false; + return other.Source == Source && + other.ImageType == ImageType && + other.ID == ID; + } +} diff --git a/Shoko.Server/Models/Interfaces/IEntityMetadata.cs b/Shoko.Server/Models/Interfaces/IEntityMetadata.cs new file mode 100644 index 000000000..0adda24a7 --- /dev/null +++ b/Shoko.Server/Models/Interfaces/IEntityMetadata.cs @@ -0,0 +1,69 @@ + +using System; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Models.Interfaces; + +public interface IEntityMetadata +{ + /// <summary> + /// Entity Id. + /// </summary> + public int Id { get; } + + /// <summary> + /// Entity type. + /// </summary> + public ForeignEntityType Type { get; } + + /// <summary> + /// Entity data source. + /// </summary> + public DataSourceEnum DataSource { get; } + + /// <summary> + /// The english title of the movie, used as a fallback for when no title + /// is available in the preferred language. + /// </summary> + public string? EnglishTitle { get; } + + /// <summary> + /// The english overview, used as a fallback for when no overview is + /// available in the preferred language. + /// </summary> + public string? EnglishOverview { get; } + + /// <summary> + /// Original title in the original language. + /// </summary> + public string? OriginalTitle { get; } + + /// <summary> + /// The original language this show was shot in, just as a title language + /// enum instead. + /// </summary> + public TitleLanguage? OriginalLanguage { get; } + + /// <summary> + /// The original language this show was shot in. + /// </summary> + public string? OriginalLanguageCode { get; } + + /// <summary> + /// When the entity was first released, if applicable and known. + /// </summary> + public DateOnly? ReleasedAt { get; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } +} diff --git a/Shoko.Server/Models/RenamerConfig.cs b/Shoko.Server/Models/RenamerConfig.cs new file mode 100644 index 000000000..d701298c4 --- /dev/null +++ b/Shoko.Server/Models/RenamerConfig.cs @@ -0,0 +1,12 @@ +using System; +using Shoko.Plugin.Abstractions; + +namespace Shoko.Server.Models; + +public class RenamerConfig : IRenamerConfig +{ + public int ID { get; set; } + public string Name { get; set; } + public Type Type { get; set; } + public object Settings { get; set; } +} diff --git a/Shoko.Server/Models/SVR_AniDB_Anime.cs b/Shoko.Server/Models/SVR_AniDB_Anime.cs index 544a9fcf4..491d6a49d 100644 --- a/Shoko.Server/Models/SVR_AniDB_Anime.cs +++ b/Shoko.Server/Models/SVR_AniDB_Anime.cs @@ -3,539 +3,358 @@ using System.IO; using System.Linq; using System.Xml.Serialization; -using NLog; using Shoko.Commons.Extensions; -using Shoko.Models.Client; using Shoko.Models.Enums; +using Shoko.Server.Extensions; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; using Shoko.Plugin.Abstractions.Enums; -using Shoko.Server.Extensions; -using Shoko.Server.ImageDownload; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Utilities; using AnimeType = Shoko.Plugin.Abstractions.DataModels.AnimeType; using AbstractEpisodeType = Shoko.Plugin.Abstractions.DataModels.EpisodeType; -using EpisodeType = Shoko.Models.Enums.EpisodeType; -using Shoko.Plugin.Abstractions.DataModels.Shoko; +#nullable enable namespace Shoko.Server.Models; -public class SVR_AniDB_Anime : AniDB_Anime, IAnime, ISeries +public class SVR_AniDB_Anime : AniDB_Anime, ISeries { - #region Properties and fields + #region Properties & Methods - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + #region General - [XmlIgnore] - public string PosterPath + public bool IsRestricted { - get - { - if (string.IsNullOrEmpty(Picname)) - { - return string.Empty; - } - - return Path.Combine(ImageUtils.GetAniDBImagePath(AnimeID), Picname); - } + get => Restricted > 0; + set => Restricted = value ? 1 : 0; } - public List<TvDB_Episode> TvDBEpisodes - { - get - { - var results = new List<TvDB_Episode>(); - var id = GetCrossRefTvDB()?.FirstOrDefault()?.TvDBID ?? -1; - if (id != -1) - { - results.AddRange(RepoFactory.TvDB_Episode.GetBySeriesID(id).OrderBy(a => a.SeasonNumber) - .ThenBy(a => a.EpisodeNumber)); - } - - return results; - } - } - - public List<CrossRef_AniDB_TvDB> GetCrossRefTvDB() - { - return RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(AnimeID); - } - - public List<CrossRef_AniDB_TraktV2> GetCrossRefTraktV2() - { - return RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(AnimeID); - } + public AnimeType AbstractAnimeType => (AnimeType)AnimeType; - public List<CrossRef_AniDB_MAL> GetCrossRefMAL() - { - return RepoFactory.CrossRef_AniDB_MAL.GetByAnimeID(AnimeID); - } + [XmlIgnore] + public AniDB_Vote? UserVote + => RepoFactory.AniDB_Vote.GetByAnimeID(AnimeID); - public List<TvDB_ImageFanart> TvDBImageFanarts + public List<(string Type, string Name, string URL)> Resources { get { - var results = new List<TvDB_ImageFanart>(); - var id = GetCrossRefTvDB()?.FirstOrDefault()?.TvDBID ?? -1; - if (id != -1) - { - results.AddRange(RepoFactory.TvDB_ImageFanart.GetBySeriesID(id)); - } + var result = new List<(string Type, string Name, string URL)>(); + if (!string.IsNullOrEmpty(Site_EN)) + foreach (var site in Site_EN.Split('|')) + result.Add((Type: "source", Name: "Official Site (EN)", URL: site)); - return results; - } - } + if (!string.IsNullOrEmpty(Site_JP)) + foreach (var site in Site_JP.Split('|')) + result.Add((Type: "source", Name: "Official Site (JP)", URL: site)); - public List<TvDB_ImagePoster> TvDBImagePosters - { - get - { - var results = new List<TvDB_ImagePoster>(); - var id = GetCrossRefTvDB()?.FirstOrDefault()?.TvDBID ?? -1; - if (id != -1) - { - results.AddRange(RepoFactory.TvDB_ImagePoster.GetBySeriesID(id)); - } + if (!string.IsNullOrEmpty(Wikipedia_ID)) + result.Add((Type: "wiki", Name: "Wikipedia (EN)", URL: $"https://en.wikipedia.org/{Wikipedia_ID}")); - return results; - } - } + if (!string.IsNullOrEmpty(WikipediaJP_ID)) + result.Add((Type: "wiki", Name: "Wikipedia (JP)", URL: $"https://en.wikipedia.org/{WikipediaJP_ID}")); - public List<TvDB_ImageWideBanner> TvDBImageWideBanners - { - get - { - var results = new List<TvDB_ImageWideBanner>(); - var id = GetCrossRefTvDB()?.FirstOrDefault()?.TvDBID ?? -1; - if (id != -1) - { - results.AddRange(RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(id)); - } + if (!string.IsNullOrEmpty(CrunchyrollID)) + result.Add((Type: "streaming", Name: "Crunchyroll", URL: $"https://crunchyroll.com/series/{CrunchyrollID}")); - return results; - } - } + if (!string.IsNullOrEmpty(FunimationID)) + result.Add((Type: "streaming", Name: "Funimation", URL: FunimationID)); - public CrossRef_AniDB_Other CrossRefMovieDB => RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(AnimeID, CrossRefType.MovieDB); + if (!string.IsNullOrEmpty(HiDiveID)) + result.Add((Type: "streaming", Name: "HiDive", URL: $"https://www.hidive.com/{HiDiveID}")); - public MovieDB_Movie MovieDBMovie - { - get - { - var xref = CrossRefMovieDB; - if (xref == null) - { - return null; - } + if (AllCinemaID.HasValue && AllCinemaID.Value > 0) + result.Add((Type: "foreign-metadata", Name: "allcinema", URL: $"https://allcinema.net/cinema/{AllCinemaID.Value}")); - return RepoFactory.MovieDb_Movie.GetByOnlineID(int.Parse(xref.CrossRefID)); - } - } + if (AnisonID.HasValue && AnisonID.Value > 0) + result.Add((Type: "foreign-metadata", Name: "Anison", URL: $"https://anison.info/data/program/{AnisonID.Value}.html")); - public List<MovieDB_Fanart> MovieDBFanarts - { - get - { - var xref = CrossRefMovieDB; - if (xref == null) - { - return new List<MovieDB_Fanart>(); - } + if (SyoboiID.HasValue && SyoboiID.Value > 0) + result.Add((Type: "foreign-metadata", Name: "syoboi", URL: $"https://cal.syoboi.jp/tid/{SyoboiID.Value}/time")); - return RepoFactory.MovieDB_Fanart.GetByMovieID(int.Parse(xref.CrossRefID)); - } - } + if (BangumiID.HasValue && BangumiID.Value > 0) + result.Add((Type: "foreign-metadata", Name: "bangumi", URL: $"https://bgm.tv/subject/{BangumiID.Value}")); - public List<MovieDB_Poster> MovieDBPosters - { - get - { - var xref = CrossRefMovieDB; - if (xref == null) - { - return new List<MovieDB_Poster>(); - } + if (LainID.HasValue && LainID.Value > 0) + result.Add((Type: "foreign-metadata", Name: ".lain", URL: $"http://lain.gr.jp/mediadb/media/{LainID.Value}")); - return RepoFactory.MovieDB_Poster.GetByMovieID(int.Parse(xref.CrossRefID)); - } - } + if (ANNID.HasValue && ANNID.Value > 0) + result.Add((Type: "english-metadata", Name: "AnimeNewsNetwork", URL: $"https://www.animenewsnetwork.com/encyclopedia/php?id={ANNID.Value}")); - public AniDB_Anime_DefaultImage DefaultPoster => RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(AnimeID, ImageSizeType.Poster); + if (VNDBID.HasValue && VNDBID.Value > 0) + result.Add((Type: "english-metadata", Name: "VNDB", URL: $"https://vndb.org/v{VNDBID.Value}")); - public string PosterPathNoDefault - { - get - { - var fileName = Path.Combine(ImageUtils.GetAniDBImagePath(AnimeID), Picname); - return fileName; + return result; } } - private List<AniDB_Anime_DefaultImage> allPosters; - - public List<AniDB_Anime_DefaultImage> AllPosters + public IEnumerable<(int Year, AnimeSeason Season)> Seasons { get { - if (allPosters != null) - { - return allPosters; - } + if (AirDate is null) yield break; - var posters = new List<AniDB_Anime_DefaultImage>(); - posters.Add(new AniDB_Anime_DefaultImage - { - AniDB_Anime_DefaultImageID = AnimeID, - ImageType = (int)ImageEntityType.AniDB_Cover - }); - var tvdbposters = TvDBImagePosters?.Where(img => img != null).Select(img => - new AniDB_Anime_DefaultImage - { - AniDB_Anime_DefaultImageID = img.TvDB_ImagePosterID, - ImageType = (int)ImageEntityType.TvDB_Cover - }); - if (tvdbposters != null) - { - posters.AddRange(tvdbposters); - } - - var moviebposters = MovieDBPosters?.Where(img => img != null).Select(img => - new AniDB_Anime_DefaultImage - { - AniDB_Anime_DefaultImageID = img.MovieDB_PosterID, - ImageType = (int)ImageEntityType.MovieDB_Poster - }); - if (moviebposters != null) - { - posters.AddRange(moviebposters); - } - - allPosters = posters; - return posters; - } - } - - public List<AniDB_Anime_DefaultImage> AllFanarts - { - get - { - var fanarts = new List<AniDB_Anime_DefaultImage>(); - var movDbFanart = MovieDBFanarts; - if (movDbFanart != null && movDbFanart.Any()) + var beginYear = AirDate.Value.Year; + var endYear = EndDate?.Year ?? DateTime.Today.Year; + for (var year = beginYear; year <= endYear; year++) { - fanarts.AddRange(movDbFanart.Select(a => new CL_AniDB_Anime_DefaultImage + if (beginYear < year && year < endYear) { - ImageType = (int)ImageEntityType.MovieDB_FanArt, MovieFanart = a, AniDB_Anime_DefaultImageID = a.MovieDB_FanartID - })); - } + yield return (year, AnimeSeason.Winter); + yield return (year, AnimeSeason.Spring); + yield return (year, AnimeSeason.Summer); + yield return (year, AnimeSeason.Fall); + continue; + } - var tvDbFanart = TvDBImageFanarts; - if (tvDbFanart != null && tvDbFanart.Any()) - { - fanarts.AddRange(tvDbFanart.Select(a => new CL_AniDB_Anime_DefaultImage - { - ImageType = (int)ImageEntityType.TvDB_FanArt, TVFanart = a, AniDB_Anime_DefaultImageID = a.TvDB_ImageFanartID - })); + if (this.IsInSeason(AnimeSeason.Winter, year)) yield return (year, AnimeSeason.Winter); + if (this.IsInSeason(AnimeSeason.Spring, year)) yield return (year, AnimeSeason.Spring); + if (this.IsInSeason(AnimeSeason.Summer, year)) yield return (year, AnimeSeason.Summer); + if (this.IsInSeason(AnimeSeason.Fall, year)) yield return (year, AnimeSeason.Fall); } - - return fanarts; } } - public string GetDefaultPosterPathNoBlanks() - { - var defaultPoster = DefaultPoster; - if (defaultPoster == null) - { - return PosterPathNoDefault; - } - - var imageType = (ImageEntityType)defaultPoster.ImageParentType; - - switch (imageType) - { - case ImageEntityType.AniDB_Cover: - return PosterPath; + public List<CustomTag> CustomTags + => RepoFactory.CustomTag.GetByAnimeID(AnimeID); - case ImageEntityType.TvDB_Cover: - var tvPoster = - RepoFactory.TvDB_ImagePoster.GetByID(defaultPoster.ImageParentID); - if (tvPoster != null) - { - return tvPoster.GetFullImagePath(); - } - else - { - return PosterPath; - } + public List<AniDB_Anime_Tag> AnimeTags + => RepoFactory.AniDB_Anime_Tag.GetByAnimeID(AnimeID); - case ImageEntityType.MovieDB_Poster: - var moviePoster = - RepoFactory.MovieDB_Poster.GetByID(defaultPoster.ImageParentID); - if (moviePoster != null) - { - return moviePoster.GetFullImagePath(); - } - else - { - return PosterPath; - } - } + public List<AniDB_Tag> Tags + => GetAniDBTags(); - return PosterPath; - } + public List<AniDB_Tag> GetAniDBTags(bool onlyVerified = true) + => onlyVerified + ? AnimeTags + .Select(tag => RepoFactory.AniDB_Tag.GetByTagID(tag.TagID)) + .WhereNotNull() + .Where(tag => tag.Verified) + .ToList() + : AnimeTags + .Select(tag => RepoFactory.AniDB_Tag.GetByTagID(tag.TagID)) + .WhereNotNull() + .ToList(); - public ImageDetails GetDefaultPosterDetailsNoBlanks() - { - var details = new ImageDetails { ImageType = ImageEntityType.AniDB_Cover, ImageID = AnimeID }; - var defaultPoster = DefaultPoster; + public List<SVR_AniDB_Anime_Relation> RelatedAnime + => RepoFactory.AniDB_Anime_Relation.GetByAnimeID(AnimeID); - if (defaultPoster == null) - { - return details; - } + public List<AniDB_Anime_Similar> SimilarAnime + => RepoFactory.AniDB_Anime_Similar.GetByAnimeID(AnimeID); - var imageType = (ImageEntityType)defaultPoster.ImageParentType; + public List<AniDB_Anime_Character> Characters + => RepoFactory.AniDB_Anime_Character.GetByAnimeID(AnimeID); - switch (imageType) - { - case ImageEntityType.AniDB_Cover: - return details; + #endregion - case ImageEntityType.TvDB_Cover: - var tvPoster = - RepoFactory.TvDB_ImagePoster.GetByID(defaultPoster.ImageParentID); - if (tvPoster != null) - { - details = new ImageDetails - { - ImageType = ImageEntityType.TvDB_Cover, - ImageID = tvPoster.TvDB_ImagePosterID - }; - } + #region Titles - return details; + public List<SVR_AniDB_Anime_Title> Titles + => RepoFactory.AniDB_Anime_Title.GetByAnimeID(AnimeID); - case ImageEntityType.MovieDB_Poster: - var moviePoster = - RepoFactory.MovieDB_Poster.GetByID(defaultPoster.ImageParentID); - if (moviePoster != null) - { - details = new ImageDetails - { - ImageType = ImageEntityType.MovieDB_Poster, - ImageID = moviePoster.MovieDB_PosterID - }; - } + private string? _preferredTitle = null; - return details; - } + public string PreferredTitle => LoadPreferredTitle(); - return details; + public void ResetPreferredTitle() + { + _preferredTitle = null; + LoadPreferredTitle(); } - public AniDB_Anime_DefaultImage DefaultFanart => RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(AnimeID, ImageSizeType.Fanart); - - public ImageDetails GetDefaultFanartDetailsNoBlanks() + private string LoadPreferredTitle() { - var fanartRandom = new Random(); + // Check if we have already loaded the preferred title. + if (_preferredTitle is not null) + return _preferredTitle; - ImageDetails details = null; - var fanart = DefaultFanart; - if (fanart == null) - { - var fanarts = AllFanarts; - if (fanarts.Count == 0) return null; - - var art = fanarts[fanartRandom.Next(0, fanarts.Count)]; - details = new ImageDetails - { - ImageID = art.AniDB_Anime_DefaultImageID, - ImageType = (ImageEntityType)art.ImageType - }; - return details; - } - - var imageType = (ImageEntityType)fanart.ImageParentType; - - switch (imageType) + // Check each preferred language in order. + var titles = Titles; + foreach (var namingLanguage in Languages.PreferredNamingLanguages) { - case ImageEntityType.TvDB_FanArt: - var tvFanart = RepoFactory.TvDB_ImageFanart.GetByID(fanart.ImageParentID); - if (tvFanart != null) - { - details = new ImageDetails - { - ImageType = ImageEntityType.TvDB_FanArt, - ImageID = tvFanart.TvDB_ImageFanartID - }; - } + var thisLanguage = namingLanguage.Language; + if (thisLanguage == TitleLanguage.Main) + return _preferredTitle = MainTitle; - return details; + // First check the main title. + var title = titles.FirstOrDefault(t => t.TitleType == TitleType.Main && t.Language == thisLanguage); + if (title != null) + return _preferredTitle = title.Title; - case ImageEntityType.MovieDB_FanArt: - var movieFanart = RepoFactory.MovieDB_Fanart.GetByID(fanart.ImageParentID); - if (movieFanart != null) - { - details = new ImageDetails - { - ImageType = ImageEntityType.MovieDB_FanArt, - ImageID = movieFanart.MovieDB_FanartID - }; - } + // Then check for an official title. + title = titles.FirstOrDefault(t => t.TitleType == TitleType.Official && t.Language == thisLanguage); + if (title != null) + return _preferredTitle = title.Title; - return details; + // Then check for _any_ title at all, if there is no main or official title in the language. + if (Utils.SettingsProvider.GetSettings().Language.UseSynonyms) + { + title = titles.FirstOrDefault(t => t.Language == thisLanguage); + if (title != null) + return _preferredTitle = title.Title; + } } - return null; + // Otherwise just use the cached main title. + return _preferredTitle = MainTitle; } - public AniDB_Anime_DefaultImage DefaultWideBanner => RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeIDAndImagezSizeType(AnimeID, ImageSizeType.WideBanner); + #endregion + + #region Images [XmlIgnore] - public string TagsString + public string PosterPath { get { - var tags = Tags; - var temp = string.Empty; - foreach (var tag in tags) - { - temp += tag.TagName + "|"; - } - - if (temp.Length > 2) + if (string.IsNullOrEmpty(Picname)) { - temp = temp.Substring(0, temp.Length - 2); + return string.Empty; } - return temp; + return Path.Combine(ImageUtils.GetAniDBImagePath(AnimeID), Picname); } } - public List<AniDB_Tag> Tags + public AniDB_Anime_PreferredImage? PreferredPoster + => RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(AnimeID, ImageEntityType.Poster); + + public AniDB_Anime_PreferredImage? PreferredBackdrop + => RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(AnimeID, ImageEntityType.Backdrop); + + public AniDB_Anime_PreferredImage? PreferredBanner + => RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(AnimeID, ImageEntityType.Banner); + + public string PreferredOrDefaultPosterPath + => PreferredPoster?.GetImageMetadata() is { } defaultPoster ? defaultPoster.LocalPath! : PosterPath; + + public IImageMetadata PreferredOrDefaultPoster + => PreferredPoster?.GetImageMetadata() ?? this.GetImageMetadata(); + + + public IImageMetadata? GetPreferredImageForType(ImageEntityType entityType) + => RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(AnimeID, entityType)?.GetImageMetadata(); + + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType = null) { - get + var preferredImages = (entityType.HasValue ? [RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(AnimeID, entityType.Value)!] : RepoFactory.AniDB_Anime_PreferredImage.GetByAnimeID(AnimeID)) + .WhereNotNull() + .Select(preferredImage => preferredImage.GetImageMetadata()) + .WhereNotNull() + .ToDictionary(image => image.ImageType); + var images = new List<IImageMetadata>(); + if (!entityType.HasValue || entityType.Value is ImageEntityType.Poster) { - var tags = new List<AniDB_Tag>(); - foreach (var tag in AnimeTags) - { - var newTag = RepoFactory.AniDB_Tag.GetByTagID(tag.TagID); - if (newTag != null) - { - tags.Add(newTag); - } - } - - return tags; + var poster = this.GetImageMetadata(false); + if (poster is not null) + images.Add(preferredImages.TryGetValue(ImageEntityType.Poster, out var preferredPoster) && poster.Equals(preferredPoster) + ? preferredPoster + : poster + ); } + foreach (var xref in TmdbShowCrossReferences) + images.AddRange(xref.GetImages(entityType, preferredImages)); + foreach (var xref in TmdbSeasonCrossReferences) + images.AddRange(xref.GetImages(entityType, preferredImages)); + foreach (var xref in TmdbMovieCrossReferences) + images.AddRange(xref.GetImages(entityType, preferredImages)); + + return images + .DistinctBy(image => (image.ImageType, image.Source, image.ID)) + .ToList(); } - public IEnumerable<(int Year, AnimeSeason Season)> Seasons - { - get - { - if (AirDate == null) yield break; + #endregion - var beginYear = AirDate.Value.Year; - var endYear = EndDate?.Year ?? DateTime.Today.Year; - for (var year = beginYear; year <= endYear; year++) - { - if (beginYear < year && year < endYear) - { - yield return (year, AnimeSeason.Winter); - yield return (year, AnimeSeason.Spring); - yield return (year, AnimeSeason.Summer); - yield return (year, AnimeSeason.Fall); - continue; - } + #region AniDB - if (this.IsInSeason(AnimeSeason.Winter, year)) yield return (year, AnimeSeason.Winter); - if (this.IsInSeason(AnimeSeason.Spring, year)) yield return (year, AnimeSeason.Spring); - if (this.IsInSeason(AnimeSeason.Summer, year)) yield return (year, AnimeSeason.Summer); - if (this.IsInSeason(AnimeSeason.Fall, year)) yield return (year, AnimeSeason.Fall); - } - } - } + public List<SVR_AniDB_Episode> AniDBEpisodes => RepoFactory.AniDB_Episode.GetByAnimeID(AnimeID); - public List<CustomTag> CustomTags => RepoFactory.CustomTag.GetByAnimeID(AnimeID); + #endregion - public List<AniDB_Tag> GetAniDBTags(bool onlyVerified = true) - { - if (onlyVerified) - return RepoFactory.AniDB_Tag.GetByAnimeID(AnimeID) - .Where(tag => tag.Verified) - .ToList(); + #region Trakt - return RepoFactory.AniDB_Tag.GetByAnimeID(AnimeID); + public List<CrossRef_AniDB_TraktV2> GetCrossRefTraktV2() + { + return RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(AnimeID); } - public List<AniDB_Anime_Tag> AnimeTags => RepoFactory.AniDB_Anime_Tag.GetByAnimeID(AnimeID); - public List<SVR_AniDB_Anime_Relation> RelatedAnime => RepoFactory.AniDB_Anime_Relation.GetByAnimeID(AnimeID); - public List<AniDB_Anime_Similar> SimilarAnime => RepoFactory.AniDB_Anime_Similar.GetByAnimeID(AnimeID); - public List<AniDB_Anime_Character> Characters => RepoFactory.AniDB_Anime_Character.GetByAnimeID(AnimeID); - public List<SVR_AniDB_Anime_Title> Titles => RepoFactory.AniDB_Anime_Title.GetByAnimeID(AnimeID); + #endregion - private string GetFormattedTitle(List<SVR_AniDB_Anime_Title> titles = null) - { - // Get the titles now if they were not provided as an argument. - titles ??= Titles; + #region TMDB - // Check each preferred language in order. - foreach (var thisLanguage in Languages.PreferredNamingLanguageNames) - { - // First check the main title. - var title = titles.FirstOrDefault(t => t.TitleType == TitleType.Main && t.Language == thisLanguage); - if (title != null) return title.Title; + public IReadOnlyList<CrossRef_AniDB_TMDB_Show> TmdbShowCrossReferences + => RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(AnimeID); - // Then check for an official title. - title = titles.FirstOrDefault(t => t.TitleType == TitleType.Official && t.Language == thisLanguage); - if (title != null) return title.Title; + public IReadOnlyList<TMDB_Show> TmdbShows + => TmdbShowCrossReferences + .Select(xref => RepoFactory.TMDB_Show.GetByTmdbShowID(xref.TmdbShowID)) + .WhereNotNull() + .ToList(); - // Then check for _any_ title at all, if there is no main or official title in the langugage. - if (Utils.SettingsProvider.GetSettings().LanguageUseSynonyms) - { - title = titles.FirstOrDefault(t => t.Language == thisLanguage); - if (title != null) return title.Title; - } - } + public IReadOnlyList<TMDB_Image> TmdbShowBackdrops + => TmdbShowCrossReferences + .SelectMany(xref => RepoFactory.TMDB_Image.GetByTmdbShowIDAndType(xref.TmdbShowID, ImageEntityType.Backdrop)) + .ToList(); - // Otherwise just use the cached main title. - return MainTitle; - } + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> TmdbEpisodeCrossReferences => RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(AnimeID); - [XmlIgnore] - public AniDB_Vote UserVote - { - get - { - try - { - return RepoFactory.AniDB_Vote.GetByAnimeID(AnimeID); - } - catch (Exception ex) - { - logger.Error($"Error in UserVote: {ex}"); - return null; - } - } - } + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetTmdbEpisodeCrossReferences(int? tmdbShowId = null) => tmdbShowId.HasValue + ? RepoFactory.CrossRef_AniDB_TMDB_Episode.GetOnlyByAnidbAnimeAndTmdbShowIDs(AnimeID, tmdbShowId.Value) + : RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(AnimeID); - private string _cachedTitle; - public string PreferredTitle => _cachedTitle ??= GetFormattedTitle(); + public IReadOnlyList<CrossRef_AniDB_TMDB_Season> TmdbSeasonCrossReferences => + TmdbEpisodeCrossReferences + .Select(xref => xref.TmdbSeasonCrossReference) + .WhereNotNull() + .DistinctBy(xref => xref.TmdbSeasonID) + .ToList(); - public List<SVR_AniDB_Episode> AniDBEpisodes => RepoFactory.AniDB_Episode.GetByAnimeID(AnimeID); + public IReadOnlyList<TMDB_Season> TmdbSeasons => TmdbSeasonCrossReferences + .Select(xref => xref.TmdbSeason) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Season> GetTmdbSeasonCrossReferences(int? tmdbShowId = null) => + GetTmdbEpisodeCrossReferences(tmdbShowId) + .Select(xref => xref.TmdbSeasonCrossReference) + .WhereNotNull().Distinct() + .ToList(); + + + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> TmdbMovieCrossReferences + => RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(AnimeID); + + public IReadOnlyList<TMDB_Movie> TmdbMovies + => TmdbMovieCrossReferences + .Select(xref => RepoFactory.TMDB_Movie.GetByTmdbMovieID(xref.TmdbMovieID)) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<TMDB_Image> TmdbMovieBackdrops + => TmdbMovieCrossReferences + .SelectMany(xref => RepoFactory.TMDB_Image.GetByTmdbMovieIDAndType(xref.TmdbMovieID, ImageEntityType.Backdrop)) + .ToList(); #endregion - public DateTime GetDateTimeUpdated() + #region MAL + + public List<CrossRef_AniDB_MAL> GetCrossRefMAL() { - var update = RepoFactory.AniDB_AnimeUpdate.GetByAnimeID(AnimeID); - return update?.UpdatedAt ?? DateTime.MinValue; + return RepoFactory.CrossRef_AniDB_MAL.GetByAnimeID(AnimeID); } + #endregion + + #endregion + #region IMetadata Implementation DataSourceEnum IMetadata.Source => DataSourceEnum.AniDB; @@ -548,7 +367,7 @@ public DateTime GetDateTimeUpdated() string IWithTitles.DefaultTitle => MainTitle; - string IWithTitles.PreferredTitle => RepoFactory.AnimeSeries.GetByAnimeID(AnimeID)?.SeriesName ?? PreferredTitle; + string IWithTitles.PreferredTitle => PreferredTitle; IReadOnlyList<AnimeTitle> IWithTitles.Titles => Titles .Select(a => new AnimeTitle @@ -584,79 +403,51 @@ public DateTime GetDateTimeUpdated() #region ISeries Implementation - AnimeType ISeries.Type => (AnimeType)AnimeType; + AnimeType ISeries.Type => AbstractAnimeType; IReadOnlyList<int> ISeries.ShokoSeriesIDs => RepoFactory.AnimeSeries.GetByAnimeID(AnimeID) is { } series ? [series.AnimeSeriesID] : []; - IReadOnlyList<int> ISeries.ShokoGroupIDs => RepoFactory.AnimeSeries.GetByAnimeID(AnimeID)?.AllGroupsAbove.Select(a => a.AnimeGroupID).ToList() ?? []; - double ISeries.Rating => Rating / 100D; - bool ISeries.Restricted => Restricted == 1; + bool ISeries.Restricted => IsRestricted; IReadOnlyList<IShokoSeries> ISeries.ShokoSeries => RepoFactory.AnimeSeries.GetByAnimeID(AnimeID) is { } series ? [series] : []; - IReadOnlyList<IShokoGroup> ISeries.ShokoGroups => RepoFactory.AnimeSeries.GetByAnimeID(AnimeID)?.AllGroupsAbove ?? []; - - IReadOnlyList<ISeries> ISeries.LinkedSeries - { - get - { - var seriesList = new List<ISeries>(); - - var shokoSeries = RepoFactory.AnimeSeries.GetByAnimeID(AnimeID); - if (shokoSeries is not null) - seriesList.Add(shokoSeries); - - // TODO: Add more series here. - - return seriesList; - } - } + IImageMetadata? ISeries.DefaultPoster => this.GetImageMetadata(); IReadOnlyList<IRelatedMetadata<ISeries>> ISeries.RelatedSeries => RepoFactory.AniDB_Anime_Relation.GetByAnimeID(AnimeID); + IReadOnlyList<IRelatedMetadata<IMovie>> ISeries.RelatedMovies => []; + IReadOnlyList<IVideoCrossReference> ISeries.CrossReferences => RepoFactory.CrossRef_File_Episode.GetByAnimeID(AnimeID); - IReadOnlyList<IEpisode> ISeries.EpisodeList => AniDBEpisodes; + IReadOnlyList<IEpisode> ISeries.Episodes => AniDBEpisodes; - IReadOnlyList<IVideo> ISeries.VideoList => + IReadOnlyList<IVideo> ISeries.Videos => RepoFactory.CrossRef_File_Episode.GetByAnimeID(AnimeID) .DistinctBy(xref => xref.Hash) .Select(xref => xref.VideoLocal) .WhereNotNull() .ToList(); - IReadOnlyDictionary<AbstractEpisodeType, int> ISeries.EpisodeCountDict + EpisodeCounts ISeries.EpisodeCounts { get { - var episodes = (this as ISeries).EpisodeList; - return Enum.GetValues<AbstractEpisodeType>() - .ToDictionary(a => a, a => episodes.Count(e => e.Type == a)); + var episodes = (this as ISeries).Episodes; + return new() + { + Episodes = episodes.Count(a => a.Type == AbstractEpisodeType.Episode), + Credits = episodes.Count(a => a.Type == AbstractEpisodeType.Credits), + Others = episodes.Count(a => a.Type == AbstractEpisodeType.Other), + Parodies = episodes.Count(a => a.Type == AbstractEpisodeType.Parody), + Specials = episodes.Count(a => a.Type == AbstractEpisodeType.Special), + Trailers = episodes.Count(a => a.Type == AbstractEpisodeType.Trailer) + }; } } #endregion - - #region IAnime Implementation - - IReadOnlyList<IRelatedAnime> IAnime.Relations => - RepoFactory.AniDB_Anime_Relation.GetByAnimeID(AnimeID); - - EpisodeCounts IAnime.EpisodeCounts => new() - { - Episodes = AniDBEpisodes.Count(a => a.EpisodeType == (int)EpisodeType.Episode), - Credits = AniDBEpisodes.Count(a => a.EpisodeType == (int)EpisodeType.Credits), - Others = AniDBEpisodes.Count(a => a.EpisodeType == (int)EpisodeType.Other), - Parodies = AniDBEpisodes.Count(a => a.EpisodeType == (int)EpisodeType.Parody), - Specials = AniDBEpisodes.Count(a => a.EpisodeType == (int)EpisodeType.Special), - Trailers = AniDBEpisodes.Count(a => a.EpisodeType == (int)EpisodeType.Trailer) - }; - - int IAnime.AnimeID => AnimeID; - - #endregion } diff --git a/Shoko.Server/Models/SVR_AniDB_Anime_Relation.cs b/Shoko.Server/Models/SVR_AniDB_Anime_Relation.cs index 8c5325a36..07b452a8b 100644 --- a/Shoko.Server/Models/SVR_AniDB_Anime_Relation.cs +++ b/Shoko.Server/Models/SVR_AniDB_Anime_Relation.cs @@ -8,7 +8,7 @@ #nullable enable namespace Shoko.Server.Models; -public class SVR_AniDB_Anime_Relation : AniDB_Anime_Relation, IRelatedAnime, IRelatedMetadata<ISeries> +public class SVR_AniDB_Anime_Relation : AniDB_Anime_Relation, IRelatedMetadata<ISeries> { #region IMetadata implementation @@ -45,15 +45,4 @@ public class SVR_AniDB_Anime_Relation : AniDB_Anime_Relation, IRelatedAnime, IRe RepoFactory.AniDB_Anime.GetByAnimeID(RelatedAnimeID); #endregion - - #region IRelatedAnime implementation - - IAnime? IRelatedAnime.RelatedAnime => - RepoFactory.AniDB_Anime.GetByAnimeID(RelatedAnimeID); - - RType IRelatedAnime.RelationType => (this as IRelatedMetadata).RelationType; - - #endregion - - } diff --git a/Shoko.Server/Models/SVR_AniDB_Episode.cs b/Shoko.Server/Models/SVR_AniDB_Episode.cs index 26f231add..87dc7d418 100644 --- a/Shoko.Server/Models/SVR_AniDB_Episode.cs +++ b/Shoko.Server/Models/SVR_AniDB_Episode.cs @@ -4,7 +4,11 @@ using Shoko.Commons.Extensions; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Extensions; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Utilities; @@ -15,32 +19,34 @@ namespace Shoko.Server.Models; public class SVR_AniDB_Episode : AniDB_Episode, IEpisode { + public EpisodeType AbstractEpisodeType => (EpisodeType)EpisodeType; + public EpisodeTypeEnum EpisodeTypeEnum => (EpisodeTypeEnum)EpisodeType; public TimeSpan Runtime => TimeSpan.FromSeconds(LengthSeconds); - public string DefaultTitle => + public SVR_AniDB_Episode_Title DefaultTitle => RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(EpisodeID, TitleLanguage.English) - .FirstOrDefault() - ?.Title ?? $"Episode {EpisodeNumber}"; + .FirstOrDefault() ?? new() { AniDB_Episode_TitleID = 0, AniDB_EpisodeID = EpisodeID, Language = TitleLanguage.Unknown, Title = $"Episode {EpisodeNumber}" }; + + public SVR_AniDB_Episode_Title PreferredTitle => GetPreferredTitle(true)!; - public string PreferredTitle + public SVR_AniDB_Episode_Title? GetPreferredTitle(bool useFallback) { - get + // Try finding one of the preferred languages. + foreach (var language in Languages.PreferredEpisodeNamingLanguages) { - // Try finding one of the preferred languages. - foreach (var language in Languages.PreferredEpisodeNamingLanguages) - { - var title = RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(EpisodeID, language.Language) - .FirstOrDefault() - ?.Title; - if (!string.IsNullOrEmpty(title)) - return title; - } - - // Fallback to English if available. - return DefaultTitle; + if (language.Language == TitleLanguage.Main) + return DefaultTitle; + + var title = RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(EpisodeID, language.Language) + .FirstOrDefault(); + if (title is not null) + return title; } + + // Fallback to English if available. + return useFallback ? DefaultTitle : null; } public bool HasAired @@ -59,12 +65,76 @@ public IReadOnlyList<SVR_AniDB_Episode_Title> GetTitles(TitleLanguage? language ? RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(EpisodeID, language.Value) : RepoFactory.AniDB_Episode_Title.GetByEpisodeID(EpisodeID); - public SVR_AniDB_Anime? AniDB_Anime => RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); + #region Images - public SVR_AnimeEpisode? AnimeEpisode => RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(EpisodeID); + public IImageMetadata? GetPreferredImageForType(ImageEntityType entityType) + => RepoFactory.AniDB_Episode_PreferredImage.GetByAnidbEpisodeIDAndType(EpisodeID, entityType)?.GetImageMetadata(); + + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType = null) + { + var preferredImages = (entityType.HasValue ? [RepoFactory.AniDB_Episode_PreferredImage.GetByAnidbEpisodeIDAndType(EpisodeID, entityType.Value)!] : RepoFactory.AniDB_Episode_PreferredImage.GetByEpisodeID(EpisodeID)) + .WhereNotNull() + .Select(preferredImage => preferredImage.GetImageMetadata()) + .WhereNotNull() + .ToDictionary(image => image.ImageType); + var images = new List<IImageMetadata>(); + if (!entityType.HasValue || entityType.Value is ImageEntityType.Poster) + { + var poster = AniDB_Anime?.GetImageMetadata(false); + if (poster is not null) + images.Add(preferredImages.TryGetValue(ImageEntityType.Poster, out var preferredPoster) && poster.Equals(preferredPoster) + ? preferredPoster + : poster + ); + } + foreach (var tmdbEpisode in TmdbEpisodes) + images.AddRange(tmdbEpisode.GetImages(entityType, preferredImages)); + foreach (var tmdbMovie in TmdbMovies) + images.AddRange(tmdbMovie.GetImages(entityType, preferredImages)); + + return images + .DistinctBy(image => (image.ImageType, image.Source, image.ID)) + .ToList(); + } + + #endregion + + #region Shoko public SVR_AnimeSeries? AnimeSeries => RepoFactory.AnimeSeries.GetByAnimeID(AnimeID); + public SVR_AnimeEpisode? AnimeEpisode => RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(EpisodeID); + + #endregion + + #region AniDB + + public SVR_AniDB_Anime? AniDB_Anime => RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); + + #endregion + + #region TMDB + + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> TmdbMovieCrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbEpisodeID(EpisodeID); + + public IReadOnlyList<TMDB_Movie> TmdbMovies => + TmdbMovieCrossReferences + .Select(xref => xref.TmdbMovie) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> TmdbEpisodeCrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(EpisodeID); + + public IReadOnlyList<TMDB_Episode> TmdbEpisodes => + TmdbEpisodeCrossReferences + .Select(xref => xref.TmdbEpisode) + .WhereNotNull() + .ToList(); + + #endregion + #region IMetadata Implementation DataSourceEnum IMetadata.Source => DataSourceEnum.AniDB; @@ -75,9 +145,9 @@ public IReadOnlyList<SVR_AniDB_Episode_Title> GetTitles(TitleLanguage? language #region IWithTitles Implementation - string IWithTitles.DefaultTitle => DefaultTitle; + string IWithTitles.DefaultTitle => DefaultTitle.Title; - string IWithTitles.PreferredTitle => PreferredTitle; + string IWithTitles.PreferredTitle => PreferredTitle.Title; IReadOnlyList<AnimeTitle> IWithTitles.Titles { @@ -91,7 +161,7 @@ IReadOnlyList<AnimeTitle> IWithTitles.Titles LanguageCode = a.LanguageCode, Language = a.Language, Title = a.Title, - Type = string.Equals(a.Title, defaultTitle) ? TitleType.Main : TitleType.None, + Type = string.Equals(a.Title, defaultTitle.Title) ? TitleType.Main : TitleType.None, }) .ToList(); } @@ -111,7 +181,7 @@ IReadOnlyList<AnimeTitle> IWithTitles.Titles Source = DataSourceEnum.AniDB, Language = TitleLanguage.English, LanguageCode = "en", - Value = string.Empty, + Value = Description ?? string.Empty, }, ]; @@ -127,23 +197,9 @@ IReadOnlyList<AnimeTitle> IWithTitles.Titles DateTime? IEpisode.AirDate => this.GetAirDateAsDate(); - ISeries? IEpisode.SeriesInfo => AniDB_Anime; + ISeries? IEpisode.Series => AniDB_Anime; - IReadOnlyList<IEpisode> IEpisode.LinkedEpisodes - { - get - { - var episodeList = new List<IEpisode>(); - - var shokoEpisode = AnimeEpisode; - if (shokoEpisode is not null) - episodeList.Add(shokoEpisode); - - // TODO: Add more episodes here. - - return episodeList; - } - } + IReadOnlyList<IShokoEpisode> IEpisode.ShokoEpisodes => AnimeEpisode is IShokoEpisode shokoEpisode ? [shokoEpisode] : []; IReadOnlyList<IVideoCrossReference> IEpisode.CrossReferences => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(EpisodeID); @@ -152,16 +208,10 @@ IReadOnlyList<IEpisode> IEpisode.LinkedEpisodes RepoFactory.CrossRef_File_Episode.GetByEpisodeID(EpisodeID) .DistinctBy(xref => xref.Hash) .Select(xref => xref.VideoLocal) - .OfType<SVR_VideoLocal>() + .WhereNotNull() .ToList(); - int IEpisode.EpisodeID => EpisodeID; - - int IEpisode.AnimeID => AnimeID; - - int IEpisode.Number => EpisodeNumber; - - int IEpisode.Duration => LengthSeconds; + IReadOnlyList<int> IEpisode.ShokoEpisodeIDs => RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(EpisodeID) is { } episode ? [episode.AnimeEpisodeID] : []; #endregion } diff --git a/Shoko.Server/Models/SVR_AniDB_Episode_Title.cs b/Shoko.Server/Models/SVR_AniDB_Episode_Title.cs index 843e9ad24..89dbcd51f 100644 --- a/Shoko.Server/Models/SVR_AniDB_Episode_Title.cs +++ b/Shoko.Server/Models/SVR_AniDB_Episode_Title.cs @@ -4,13 +4,19 @@ namespace Shoko.Server.Models; -public class SVR_AniDB_Episode_Title : AniDB_Episode_Title +public class SVR_AniDB_Episode_Title { + public int AniDB_Episode_TitleID { get; set; } + + public int AniDB_EpisodeID { get; set; } + + public string Title { get; set; } + /// <summary> /// The language. /// </summary> /// <value></value> - public new TitleLanguage Language { get; set; } + public TitleLanguage Language { get; set; } /// <summary> /// The language code. @@ -29,7 +35,7 @@ protected bool Equals(SVR_AniDB_Episode_Title other) public override bool Equals(object obj) { - if (ReferenceEquals(null, obj)) + if (obj is null) { return false; } @@ -39,7 +45,7 @@ public override bool Equals(object obj) return true; } - if (obj.GetType() != this.GetType()) + if (obj.GetType() != GetType()) { return false; } @@ -52,7 +58,7 @@ public override int GetHashCode() unchecked { var hashCode = AniDB_EpisodeID; - hashCode = (hashCode * 397) ^ (Language != null ? Language.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ Language.GetHashCode(); hashCode = (hashCode * 397) ^ (Title != null ? Title.GetHashCode() : 0); return hashCode; } diff --git a/Shoko.Server/Models/SVR_AniDB_File.cs b/Shoko.Server/Models/SVR_AniDB_File.cs index 073492b00..331c26901 100644 --- a/Shoko.Server/Models/SVR_AniDB_File.cs +++ b/Shoko.Server/Models/SVR_AniDB_File.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.Linq; using System.Xml.Serialization; +using Shoko.Commons.Extensions; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.Models.AniDB; using Shoko.Server.Repositories; namespace Shoko.Server.Models; @@ -24,22 +26,22 @@ public class SVR_AniDB_File : AniDB_File, IAniDBFile [XmlIgnore] public List<SVR_AniDB_Episode> Episodes => RepoFactory.CrossRef_File_Episode.GetByHash(Hash) - .Select(crossref => crossref.AniDBEpisode).Where(ep => ep != null).ToList(); + .Select(crossref => crossref.AniDBEpisode) + .WhereNotNull() + .OrderBy(ep => ep.EpisodeTypeEnum) + .OrderBy(ep => ep.EpisodeNumber) + .ToList(); [XmlIgnore] public List<SVR_CrossRef_File_Episode> EpisodeCrossRefs => RepoFactory.CrossRef_File_Episode.GetByHash(Hash); // NOTE: I want to cache it, but i won't for now. not until the anidb files and release groups are stored in a non-cached repo. public AniDB_ReleaseGroup ReleaseGroup => - RepoFactory.AniDB_ReleaseGroup.GetByGroupID(GroupID) ?? new() - { - GroupID = GroupID, - GroupName = "", - GroupNameShort = "", - }; + RepoFactory.AniDB_ReleaseGroup.GetByGroupID(GroupID) ?? new() { GroupID = GroupID }; + + public string Anime_GroupName => ReleaseGroup?.GroupName; - public string Anime_GroupName => ReleaseGroup?.Name; - public string Anime_GroupNameShort => ReleaseGroup?.ShortName; + public string Anime_GroupNameShort => ReleaseGroup?.GroupNameShort; public string SubtitlesRAW { @@ -259,18 +261,7 @@ public static string[] GetPossibleSubtitleLanguages() int IAniDBFile.AniDBFileID => FileID; IReleaseGroup IAniDBFile.ReleaseGroup - { - get - { - var group = RepoFactory.AniDB_ReleaseGroup.GetByGroupID(GroupID); - if (group == null) - { - return null; - } - - return new AniDB_ReleaseGroup { GroupName = group.Name, GroupNameShort = group.ShortName }; - } - } + => RepoFactory.AniDB_ReleaseGroup.GetByGroupID(GroupID) ?? new() { GroupID = GroupID }; string IAniDBFile.Source => File_Source; string IAniDBFile.Description => File_Description; diff --git a/Shoko.Server/Models/SVR_AnimeEpisode.cs b/Shoko.Server/Models/SVR_AnimeEpisode.cs index b97427f50..705672d2f 100644 --- a/Shoko.Server/Models/SVR_AnimeEpisode.cs +++ b/Shoko.Server/Models/SVR_AnimeEpisode.cs @@ -5,7 +5,13 @@ using Shoko.Models.Enums; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Extensions; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Extensions; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Utilities; @@ -16,7 +22,7 @@ #nullable enable namespace Shoko.Server.Models; -public class SVR_AnimeEpisode : AnimeEpisode, IEpisode +public class SVR_AnimeEpisode : AnimeEpisode, IShokoEpisode { #region DB Columns @@ -30,61 +36,18 @@ public class SVR_AnimeEpisode : AnimeEpisode, IEpisode public EpisodeType EpisodeTypeEnum => (EpisodeType)(AniDB_Episode?.EpisodeType ?? 1); - public SVR_AniDB_Episode? AniDB_Episode => RepoFactory.AniDB_Episode.GetByEpisodeID(AniDB_EpisodeID); - - public SVR_AnimeEpisode_User? GetUserRecord(int userID) - { - return RepoFactory.AnimeEpisode_User.GetByUserIDAndEpisodeID(userID, AnimeEpisodeID); - } - - /// <summary> - /// Gets the AnimeSeries this episode belongs to - /// </summary> - public SVR_AnimeSeries? AnimeSeries => RepoFactory.AnimeSeries.GetByID(AnimeSeriesID); - - public List<SVR_VideoLocal> VideoLocals => RepoFactory.VideoLocal.GetByAniDBEpisodeID(AniDB_EpisodeID); - - public List<SVR_CrossRef_File_Episode> FileCrossRefs => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(AniDB_EpisodeID); - - public TvDB_Episode? TvDBEpisode - { - get - { - // Try Overrides first, then regular - return RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBEpisodeID(AniDB_EpisodeID) - .Select(a => RepoFactory.TvDB_Episode.GetByTvDBID(a.TvDBEpisodeID)).Where(a => a != null) - .OrderBy(a => a.SeasonNumber).ThenBy(a => a.EpisodeNumber).FirstOrDefault() ?? RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBEpisodeID(AniDB_EpisodeID) - .Select(a => RepoFactory.TvDB_Episode.GetByTvDBID(a.TvDBEpisodeID)).Where(a => a != null) - .OrderBy(a => a.SeasonNumber).ThenBy(a => a.EpisodeNumber).FirstOrDefault(); - } - } - - public List<TvDB_Episode> TvDBEpisodes - { - get - { - // Try Overrides first, then regular - var overrides = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBEpisodeID(AniDB_EpisodeID) - .Select(a => RepoFactory.TvDB_Episode.GetByTvDBID(a.TvDBEpisodeID)).Where(a => a != null) - .OrderBy(a => a.SeasonNumber).ThenBy(a => a.EpisodeNumber).ToList(); - return overrides.Count > 0 - ? overrides - : RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBEpisodeID(AniDB_EpisodeID) - .Select(a => RepoFactory.TvDB_Episode.GetByTvDBID(a.TvDBEpisodeID)).Where(a => a != null) - .OrderBy(a => a.SeasonNumber).ThenBy(a => a.EpisodeNumber).ToList(); - } - } - public double UserRating { get { - AniDB_Vote vote = RepoFactory.AniDB_Vote.GetByEntityAndType(AnimeEpisodeID, AniDBVoteType.Episode); + var vote = RepoFactory.AniDB_Vote.GetByEntityAndType(AnimeEpisodeID, AniDBVoteType.Episode); if (vote != null) return vote.VoteValue / 100D; return -1; } } + #region Titles + public string DefaultTitle { get @@ -92,7 +55,7 @@ public string DefaultTitle // Fallback to English if available. return RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(AniDB_EpisodeID, TitleLanguage.English) .FirstOrDefault() - ?.Title ?? $"Shoko Episode {AnimeEpisodeID}"; + ?.Title ?? $"<AniDB Episode {AniDB_EpisodeID}>"; } } @@ -100,62 +63,193 @@ public string PreferredTitle { get { + // Return the override if it's set. if (!string.IsNullOrEmpty(EpisodeNameOverride)) return EpisodeNameOverride; - // Try finding one of the preferred languages. - foreach (var language in Languages.PreferredEpisodeNamingLanguages) - { - var title = RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(AniDB_EpisodeID, language.Language) - .FirstOrDefault() - ?.Title; - if (!string.IsNullOrEmpty(title)) - return title; - } + var settings = Utils.SettingsProvider.GetSettings(); + var sourceOrder = settings.Language.SeriesTitleSourceOrder; + var languageOrder = Languages.PreferredEpisodeNamingLanguages; + + // Lazy load AniDB titles if needed. + List<SVR_AniDB_Episode_Title>? anidbTitles = null; + List<SVR_AniDB_Episode_Title> GetAnidbTitles() + => anidbTitles ??= RepoFactory.AniDB_Episode_Title.GetByEpisodeID(AniDB_EpisodeID); + + // Lazy load TMDB titles if needed. + IReadOnlyList<TMDB_Title>? tmdbTitles = null; + IReadOnlyList<TMDB_Title> GetTmdbTitles() + => tmdbTitles ??= ( + TmdbEpisodes is { Count: > 0 } tmdbEpisodes + ? tmdbEpisodes[0].GetAllTitles() + : TmdbMovies is { Count: 1 } tmdbMovies + ? tmdbMovies[0].GetAllTitles() + : [] + ); + + // Loop through all languages and sources, first by language, then by source. + foreach (var language in languageOrder) + foreach (var source in sourceOrder) + { + var title = source switch + { + DataSourceType.AniDB => + language.Language is TitleLanguage.Main + ? GetAnidbTitles().FirstOrDefault(x => x.Language is TitleLanguage.English)?.Title ?? $"<AniDB Episode {AniDB_EpisodeID}>" + : GetAnidbTitles().FirstOrDefault(x => x.Language == language.Language)?.Title, + DataSourceType.TMDB => + GetTmdbTitles().GetByLanguage(language.Language)?.Value, + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title; + } + // The most "default" title we have, even if AniDB isn't a preferred source. return DefaultTitle; } } - protected bool Equals(SVR_AnimeEpisode other) - { - return AnimeEpisodeID == other.AnimeEpisodeID && AnimeSeriesID == other.AnimeSeriesID && - AniDB_EpisodeID == other.AniDB_EpisodeID && DateTimeUpdated.Equals(other.DateTimeUpdated) && - DateTimeCreated.Equals(other.DateTimeCreated); - } - - public override bool Equals(object? obj) + public string PreferredOverview { - if (ReferenceEquals(null, obj)) + get { - return false; + var settings = Utils.SettingsProvider.GetSettings(); + var sourceOrder = settings.Language.DescriptionSourceOrder; + var languageOrder = Languages.PreferredDescriptionNamingLanguages; + var anidbOverview = AniDB_Episode?.Description; + + // Lazy load TMDB overviews if needed. + IReadOnlyList<TMDB_Overview>? tmdbOverviews = null; + IReadOnlyList<TMDB_Overview> GetTmdbOverviews() + => tmdbOverviews ??= ( + TmdbEpisodes is { Count: > 0 } tmdbEpisodes + ? tmdbEpisodes[0].GetAllOverviews() + : TmdbMovies is { Count: 1 } tmdbMovies + ? tmdbMovies[0].GetAllOverviews() + : [] + ); + + // Check each language and source in the most preferred order. + foreach (var language in languageOrder) + foreach (var source in sourceOrder) + { + var overview = source switch + { + DataSourceType.AniDB => + language.Language is TitleLanguage.English && !string.IsNullOrEmpty(anidbOverview) + ? anidbOverview + : null, + DataSourceType.TMDB => + GetTmdbOverviews().GetByLanguage(language.Language)?.Value, + _ => null, + }; + if (!string.IsNullOrEmpty(overview)) + return overview; + } + // Return nothing if no provider had an overview in the preferred language. + return string.Empty; } + } - if (ReferenceEquals(this, obj)) - { - return true; - } + #endregion - if (obj.GetType() != this.GetType()) - { - return false; - } + #region Images - return Equals((SVR_AnimeEpisode)obj); - } + public IImageMetadata? GetPreferredImageForType(ImageEntityType entityType) + => RepoFactory.AniDB_Episode_PreferredImage.GetByAnidbEpisodeIDAndType(AniDB_EpisodeID, entityType)?.GetImageMetadata(); - public override int GetHashCode() + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType = null) { - unchecked + var preferredImages = (entityType.HasValue ? [RepoFactory.AniDB_Episode_PreferredImage.GetByAnidbEpisodeIDAndType(AniDB_EpisodeID, entityType.Value)!] : RepoFactory.AniDB_Episode_PreferredImage.GetByEpisodeID(AniDB_EpisodeID)) + .WhereNotNull() + .Select(preferredImage => preferredImage.GetImageMetadata()) + .WhereNotNull() + .ToDictionary(image => image.ImageType); + var images = new List<IImageMetadata>(); + if (!entityType.HasValue || entityType.Value is ImageEntityType.Poster) { - var hashCode = AnimeEpisodeID; - hashCode = (hashCode * 397) ^ AnimeSeriesID; - hashCode = (hashCode * 397) ^ AniDB_EpisodeID; - hashCode = (hashCode * 397) ^ DateTimeUpdated.GetHashCode(); - hashCode = (hashCode * 397) ^ DateTimeCreated.GetHashCode(); - return hashCode; + var poster = AniDB_Anime?.GetImageMetadata(false); + if (poster is not null) + images.Add(preferredImages.TryGetValue(ImageEntityType.Poster, out var preferredPoster) && poster.Equals(preferredPoster) + ? preferredPoster + : poster + ); } + foreach (var xref in TmdbEpisodeCrossReferences) + images.AddRange(xref.GetImages(entityType, preferredImages)); + foreach (var xref in TmdbMovieCrossReferences) + images.AddRange(xref.GetImages(entityType, preferredImages)); + + return images + .DistinctBy(image => (image.ImageType, image.Source, image.ID)) + .ToList(); } + + #endregion + + #region Shoko + + public SVR_AnimeEpisode_User? GetUserRecord(int userID) + => userID <= 0 ? null : RepoFactory.AnimeEpisode_User.GetByUserIDAndEpisodeID(userID, AnimeEpisodeID); + + /// <summary> + /// Gets the AnimeSeries this episode belongs to + /// </summary> + public SVR_AnimeSeries? AnimeSeries + => RepoFactory.AnimeSeries.GetByID(AnimeSeriesID); + + public List<SVR_VideoLocal> VideoLocals + => RepoFactory.VideoLocal.GetByAniDBEpisodeID(AniDB_EpisodeID); + + public List<SVR_CrossRef_File_Episode> FileCrossReferences + => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(AniDB_EpisodeID); + + #endregion + + #region AniDB + + public SVR_AniDB_Episode? AniDB_Episode => RepoFactory.AniDB_Episode.GetByEpisodeID(AniDB_EpisodeID); + + public SVR_AniDB_Anime? AniDB_Anime => AniDB_Episode?.AniDB_Anime; + + #endregion + + #region TMDB + + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> TmdbMovieCrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbEpisodeID(AniDB_EpisodeID); + + public IReadOnlyList<TMDB_Movie> TmdbMovies => + TmdbMovieCrossReferences + .Select(xref => xref.TmdbMovie) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> TmdbEpisodeCrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(AniDB_EpisodeID); + + public IReadOnlyList<TMDB_Episode> TmdbEpisodes => + TmdbEpisodeCrossReferences + .Select(xref => xref.TmdbEpisode) + .WhereNotNull() + .ToList(); + + #endregion + + protected bool Equals(SVR_AnimeEpisode other) + => AnimeEpisodeID == other.AnimeEpisodeID && + AnimeSeriesID == other.AnimeSeriesID && + AniDB_EpisodeID == other.AniDB_EpisodeID && + DateTimeUpdated == other.DateTimeUpdated && + DateTimeCreated == other.DateTimeCreated; + + public override bool Equals(object? obj) + => obj is not null && (ReferenceEquals(this, obj) || (obj is SVR_AnimeEpisode ep && Equals(ep))); + + public override int GetHashCode() + => HashCode.Combine(AnimeEpisodeID, AnimeSeriesID, AniDB_EpisodeID, DateTimeUpdated, DateTimeCreated); + #region IMetadata Implementation DataSourceEnum IMetadata.Source => DataSourceEnum.Shoko; @@ -175,34 +269,28 @@ IReadOnlyList<AnimeTitle> IWithTitles.Titles get { var titles = new List<AnimeTitle>(); - var episodeOverrideTitle = false; - if (!string.IsNullOrEmpty(EpisodeNameOverride)) + var episodeOverrideTitle = !string.IsNullOrEmpty(EpisodeNameOverride); + if (episodeOverrideTitle) { titles.Add(new() { Source = DataSourceEnum.Shoko, Language = TitleLanguage.Unknown, LanguageCode = "unk", - Title = EpisodeNameOverride, + Title = EpisodeNameOverride!, Type = TitleType.Main, }); - episodeOverrideTitle = true; } - var episode = AniDB_Episode; - if (episode is not null && episode is IEpisode animeEpisode) + var animeTitles = (this as IShokoEpisode).AnidbEpisode.Titles; + if (episodeOverrideTitle) { - var animeTitles = animeEpisode.Titles; - if (episodeOverrideTitle) - { - var mainTitle = animeTitles.FirstOrDefault(title => title.Type == TitleType.Main); - if (mainTitle is not null) - mainTitle.Type = TitleType.Official; - } - titles.AddRange(animeTitles); + var mainTitle = animeTitles.FirstOrDefault(title => title.Type == TitleType.Main); + if (mainTitle is not null) + mainTitle.Type = TitleType.Official; } - - // TODO: Add other sources here. + titles.AddRange(animeTitles); + titles.AddRange((this as IShokoEpisode).LinkedEpisodes.Where(e => e.Source is not DataSourceEnum.AniDB).SelectMany(ep => ep.Titles)); return titles; } @@ -216,34 +304,9 @@ IReadOnlyList<AnimeTitle> IWithTitles.Titles string IWithDescriptions.PreferredDescription => AniDB_Episode?.Description ?? string.Empty; - IReadOnlyList<TextDescription> IWithDescriptions.Descriptions - { - get - { - var titles = new List<TextDescription>(); - - var episode = AniDB_Episode; - if (episode is not null && episode is IEpisode anidbEpisode) - { - var episodetitles = anidbEpisode.Descriptions; - titles.AddRange(episodetitles); - } - - // TODO: Add other sources here. - - // Fallback in the off-chance that the AniDB data is unavailable for whatever reason. It should never reach this code. - if (titles.Count is 0) - titles.Add(new() - { - Source = DataSourceEnum.AniDB, - Language = TitleLanguage.English, - LanguageCode = "en", - Value = string.Empty, - }); - - return titles; - } - } + IReadOnlyList<TextDescription> IWithDescriptions.Descriptions => (this as IShokoEpisode).LinkedEpisodes.SelectMany(ep => ep.Descriptions).ToList() is { Count: > 0 } titles + ? titles + : [new() { Source = DataSourceEnum.AniDB, Language = TitleLanguage.English, LanguageCode = "en", Value = string.Empty }]; #endregion @@ -251,6 +314,8 @@ IReadOnlyList<TextDescription> IWithDescriptions.Descriptions int IEpisode.SeriesID => AnimeSeriesID; + IReadOnlyList<int> IEpisode.ShokoEpisodeIDs => [AnimeEpisodeID]; + AbstractEpisodeType IEpisode.Type => (AbstractEpisodeType)EpisodeTypeEnum; int IEpisode.EpisodeNumber => AniDB_Episode?.EpisodeNumber ?? 1; @@ -261,9 +326,11 @@ IReadOnlyList<TextDescription> IWithDescriptions.Descriptions DateTime? IEpisode.AirDate => AniDB_Episode?.GetAirDateAsDate(); - ISeries? IEpisode.SeriesInfo => AnimeSeries; + ISeries? IEpisode.Series => AnimeSeries; + + IReadOnlyList<IShokoEpisode> IEpisode.ShokoEpisodes => [this]; - IReadOnlyList<IEpisode> IEpisode.LinkedEpisodes + IReadOnlyList<IEpisode> IShokoEpisode.LinkedEpisodes { get { @@ -273,12 +340,28 @@ IReadOnlyList<IEpisode> IEpisode.LinkedEpisodes if (anidbEpisode is not null) episodeList.Add(anidbEpisode); - // TODO: Add more episodes here. + episodeList.AddRange(TmdbEpisodes); + + // Add more episodes here as needed. return episodeList; } } + IReadOnlyList<IMovie> IShokoEpisode.LinkedMovies + { + get + { + var movieList = new List<IMovie>(); + + movieList.AddRange(TmdbMovies); + + // Add more movies here as needed. + + return movieList; + } + } + IReadOnlyList<IVideoCrossReference> IEpisode.CrossReferences => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(AniDB_EpisodeID); @@ -286,16 +369,19 @@ IReadOnlyList<IEpisode> IEpisode.LinkedEpisodes RepoFactory.CrossRef_File_Episode.GetByEpisodeID(AniDB_EpisodeID) .DistinctBy(xref => xref.Hash) .Select(xref => xref.VideoLocal) - .OfType<SVR_VideoLocal>() + .WhereNotNull() .ToList(); - int IEpisode.EpisodeID => AnimeEpisodeID; + #endregion + + #region IShokoEpisode Implementation - int IEpisode.AnimeID => AnimeSeriesID; + int IShokoEpisode.AnidbEpisodeID => AniDB_EpisodeID; - int IEpisode.Number => AniDB_Episode?.EpisodeNumber ?? 1; + IShokoSeries? IShokoEpisode.Series => AnimeSeries; - int IEpisode.Duration => AniDB_Episode?.LengthSeconds ?? 0; + IEpisode IShokoEpisode.AnidbEpisode => AniDB_Episode ?? + throw new NullReferenceException($"Unable to find AniDB Episode {AniDB_EpisodeID} for AnimeEpisode {AnimeEpisodeID}"); #endregion } diff --git a/Shoko.Server/Models/SVR_AnimeGroup.cs b/Shoko.Server/Models/SVR_AnimeGroup.cs index 21874030f..f677b5223 100644 --- a/Shoko.Server/Models/SVR_AnimeGroup.cs +++ b/Shoko.Server/Models/SVR_AnimeGroup.cs @@ -13,9 +13,9 @@ #nullable enable namespace Shoko.Server.Models; -public class SVR_AnimeGroup : AnimeGroup, IGroup, IShokoGroup +public class SVR_AnimeGroup : AnimeGroup, IShokoGroup { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); /// <summary> /// Get a predictable sort name that stuffs everything that's not between @@ -33,8 +33,32 @@ public string SortName public SVR_AnimeGroup? Parent => AnimeGroupParentID.HasValue ? RepoFactory.AnimeGroup.GetByID(AnimeGroupParentID.Value) : null; + public List<SVR_AnimeGroup> AllGroupsAbove + { + get + { + var allGroupsAbove = new List<SVR_AnimeGroup>(); + var groupID = AnimeGroupParentID; + while (groupID.HasValue && groupID.Value != 0) + { + var grp = RepoFactory.AnimeGroup.GetByID(groupID.Value); + if (grp != null) + { + allGroupsAbove.Add(grp); + groupID = grp.AnimeGroupParentID; + } + else + { + groupID = 0; + } + } + + return allGroupsAbove; + } + } + public List<SVR_AniDB_Anime> Anime => - RepoFactory.AnimeSeries.GetByGroupID(AnimeGroupID).Select(s => s.AniDB_Anime).Where(anime => anime != null).ToList(); + RepoFactory.AnimeSeries.GetByGroupID(AnimeGroupID).Select(s => s.AniDB_Anime).WhereNotNull().ToList(); public decimal AniDBRating { @@ -60,7 +84,7 @@ public decimal AniDBRating } catch (Exception ex) { - logger.Error($"Error in AniDBRating: {ex}"); + _logger.Error($"Error in AniDBRating: {ex}"); return 0; } } @@ -176,7 +200,7 @@ public List<SVR_AnimeSeries> AllSeries public List<AniDB_Tag> Tags => AllSeries - .SelectMany(ser => ser.AniDB_Anime.AnimeTags) + .SelectMany(ser => ser.AniDB_Anime?.AnimeTags ?? []) .OrderByDescending(a => a.Weight) .Select(animeTag => RepoFactory.AniDB_Tag.GetByTagID(animeTag.TagID)) .WhereNotNull() @@ -191,10 +215,18 @@ public List<SVR_AnimeSeries> AllSeries public HashSet<int> Years => AllSeries.SelectMany(a => a.Years).ToHashSet(); - public HashSet<(int Year, AnimeSeason Season)> Seasons => AllSeries.SelectMany(a => a.AniDB_Anime.Seasons).ToHashSet(); + public HashSet<(int Year, AnimeSeason Season)> Seasons => AllSeries.SelectMany(a => a.AniDB_Anime?.Seasons ?? []).ToHashSet(); + + public HashSet<ImageEntityType> AvailableImageTypes => AllSeries + .SelectMany(ser => ser.GetAvailableImageTypes()) + .ToHashSet(); + + public HashSet<ImageEntityType> PreferredImageTypes => AllSeries + .SelectMany(ser => ser.GetPreferredImageTypes()) + .ToHashSet(); public List<SVR_AniDB_Anime_Title> Titles => AllSeries - .SelectMany(ser => ser.AniDB_Anime.Titles) + .SelectMany(ser => ser.AniDB_Anime?.Titles ?? []) .DistinctBy(tit => tit.AniDB_Anime_TitleID) .ToList(); @@ -245,22 +277,6 @@ public bool IsDescendantOf(IEnumerable<int> groupIDs) return false; } - #region IGroup Implementation - - string IGroup.Name => GroupName; - IAnime IGroup.MainSeries => (MainSeries ?? AllSeries.First()).AniDB_Anime; - - IReadOnlyList<IAnime> IGroup.Series => AllSeries - .Select(a => a.AniDB_Anime) - .Where(a => a != null) - .OrderBy(a => a.BeginYear) - .ThenBy(a => a.AirDate ?? DateTime.MaxValue) - .ThenBy(a => a.MainTitle) - .Cast<IAnime>() - .ToList(); - - #endregion - #region IMetadata Implementation DataSourceEnum IMetadata.Source => DataSourceEnum.Shoko; diff --git a/Shoko.Server/Models/SVR_AnimeSeries.cs b/Shoko.Server/Models/SVR_AnimeSeries.cs index ad41e3519..bce80c171 100644 --- a/Shoko.Server/Models/SVR_AnimeSeries.cs +++ b/Shoko.Server/Models/SVR_AnimeSeries.cs @@ -10,14 +10,16 @@ using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Databases; using Shoko.Server.Extensions; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Models.Trakt; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Utilities; -using AbstractAnimeType = Shoko.Plugin.Abstractions.DataModels.AnimeType; -using AbstractEpisodeType = Shoko.Plugin.Abstractions.DataModels.EpisodeType; -using EpisodeType = Shoko.Models.Enums.EpisodeType; -using Range = System.Range; +using AnimeType = Shoko.Plugin.Abstractions.DataModels.AnimeType; +#nullable enable namespace Shoko.Server.Models; public class SVR_AnimeSeries : AnimeSeries, IShokoSeries @@ -32,21 +34,6 @@ public class SVR_AnimeSeries : AnimeSeries, IShokoSeries #region Disabled Auto Matching - public bool IsTvDBAutoMatchingDisabled - { - get - { - return DisableAutoMatchFlags.HasFlag(DataSourceType.TvDB); - } - set - { - if (value) - DisableAutoMatchFlags |= DataSourceType.TvDB; - else - DisableAutoMatchFlags &= ~DataSourceType.TvDB; - } - } - public bool IsTMDBAutoMatchingDisabled { get @@ -139,107 +126,351 @@ public bool IsKitsuAutoMatchingDisabled #endregion - public string SeriesName + #region Titles & Overviews + + private string? _preferredTitle = null; + + public string PreferredTitle => LoadPreferredTitle(); + + public void ResetPreferredTitle() { - get + _preferredTitle = null; + LoadPreferredTitle(); + } + + private string LoadPreferredTitle() + { + if (_preferredTitle is not null) + return _preferredTitle; + + lock (this) { + if (_preferredTitle is not null) + return _preferredTitle; + // Return the override if it's set. if (!string.IsNullOrEmpty(SeriesNameOverride)) - return SeriesNameOverride; + return _preferredTitle = SeriesNameOverride; + + var settings = Utils.SettingsProvider.GetSettings(); + var sourceOrder = settings.Language.SeriesTitleSourceOrder; + var languageOrder = Languages.PreferredNamingLanguages; + var anime = AniDB_Anime; + + // Lazy load AniDB titles if needed. + List<SVR_AniDB_Anime_Title>? anidbTitles = null; + List<SVR_AniDB_Anime_Title> GetAnidbTitles() + => anidbTitles ??= RepoFactory.AniDB_Anime_Title.GetByAnimeID(AniDB_ID); + + // Lazy load TMDB titles if needed. + IReadOnlyList<TMDB_Title>? tmdbTitles = null; + IReadOnlyList<TMDB_Title> GetTmdbTitles() + => tmdbTitles ??= ( + TmdbShows is { Count: > 0 } tmdbShows + ? tmdbShows[0].GetAllTitles() + : TmdbMovies is { Count: 1 } tmdbMovies + ? tmdbMovies[0].GetAllTitles() + : [] + ); + + // Loop through all languages and sources, first by language, then by source. + foreach (var language in languageOrder) + foreach (var source in sourceOrder) + { + var title = source switch + { + DataSourceType.AniDB => + language.Language is TitleLanguage.Main + ? anime?.MainTitle ?? GetAnidbTitles().FirstOrDefault(x => x.TitleType is TitleType.Main)?.Title ?? $"<AniDB Anime {AniDB_ID}>" + : GetAnidbTitles().FirstOrDefault(x => x.TitleType is TitleType.Main or TitleType.Official && x.Language == language.Language)?.Title ?? + (settings.Language.UseSynonyms ? GetAnidbTitles().FirstOrDefault(x => x.Language == language.Language)?.Title : null), + DataSourceType.TMDB => + GetTmdbTitles().GetByLanguage(language.Language)?.Value, + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return _preferredTitle = title; + } + + // The most "default" title we have, even if AniDB isn't a preferred source. + return _preferredTitle = anime?.MainTitle ?? GetAnidbTitles().FirstOrDefault(x => x.TitleType is TitleType.Main)?.Title ?? $"<AniDB Anime {AniDB_ID}>"; + } + } + + private List<AnimeTitle>? _animeTitles = null; + + public IReadOnlyList<AnimeTitle> Titles => LoadAnimeTitles(); + + public void ResetAnimeTitles() + { + _animeTitles = null; + LoadAnimeTitles(); + } + + private List<AnimeTitle> LoadAnimeTitles() + { + if (_animeTitles is not null) + return _animeTitles; + + lock (this) + { + if (_animeTitles is not null) + return _animeTitles; + + var titles = new List<AnimeTitle>(); + var seriesOverrideTitle = false; + if (!string.IsNullOrEmpty(SeriesNameOverride)) + { + titles.Add(new() + { + Source = DataSourceEnum.Shoko, + Language = TitleLanguage.Unknown, + LanguageCode = "unk", + Title = SeriesNameOverride, + Type = TitleType.Main, + }); + seriesOverrideTitle = true; + } - // Try to find the TvDB title if we prefer TvDB titles. - if (Utils.SettingsProvider.GetSettings().SeriesNameSource == DataSourceType.TvDB) + var animeTitles = (this as IShokoSeries).AnidbAnime.Titles; + if (seriesOverrideTitle) { - var tvdbShows = TvDBSeries; - var tvdbShowTitle = tvdbShows - .FirstOrDefault(show => - !string.IsNullOrEmpty(show.SeriesName) && !show.SeriesName.Contains("**DUPLICATE", StringComparison.InvariantCultureIgnoreCase))?.SeriesName; - if (!string.IsNullOrEmpty(tvdbShowTitle)) - return tvdbShowTitle; + var mainTitle = animeTitles.FirstOrDefault(title => title.Type == TitleType.Main); + if (mainTitle is not null) + mainTitle.Type = TitleType.Official; } + titles.AddRange(animeTitles); + titles.AddRange((this as IShokoSeries).LinkedSeries.Where(e => e.Source is not DataSourceEnum.AniDB).SelectMany(ep => ep.Titles)); - // Otherwise just return the anidb title. - return AniDB_Anime.PreferredTitle; + return _animeTitles = titles; } } + private string? _preferredOverview = null; + + public string PreferredOverview => LoadPreferredOverview(); + + public void ResetPreferredOverview() + { + _preferredOverview = null; + LoadPreferredOverview(); + } + + private string LoadPreferredOverview() + { + // Return the cached value if it's set. + if (_preferredOverview is not null) + return _preferredOverview; + + lock (this) + { + // Return the cached value if it's set. + if (_preferredOverview is not null) + return _preferredOverview; + + var settings = Utils.SettingsProvider.GetSettings(); + var sourceOrder = settings.Language.DescriptionSourceOrder; + var languageOrder = Languages.PreferredDescriptionNamingLanguages; + var anidbOverview = AniDB_Anime?.Description; + + // Lazy load TMDB overviews if needed. + IReadOnlyList<TMDB_Overview>? tmdbOverviews = null; + IReadOnlyList<TMDB_Overview> GetTmdbOverviews() + => tmdbOverviews ??= ( + TmdbShows is { Count: > 0 } tmdbShows + ? tmdbShows[0].GetAllOverviews() + : TmdbMovies is { Count: 1 } tmdbMovies + ? tmdbMovies[0].GetAllOverviews() + : [] + ); + + // Check each language and source in the most preferred order. + foreach (var language in languageOrder) + foreach (var source in sourceOrder) + { + var overview = source switch + { + DataSourceType.AniDB => + language.Language is TitleLanguage.English && !string.IsNullOrEmpty(anidbOverview) + ? anidbOverview + : null, + DataSourceType.TMDB => + GetTmdbOverviews().GetByLanguage(language.Language)?.Value, + _ => null, + }; + if (!string.IsNullOrEmpty(overview)) + return _preferredOverview = overview; + } + + // Return nothing if no provider had an overview in the preferred language. + return _preferredOverview = string.Empty; + } + + } + + #endregion + public List<SVR_VideoLocal> VideoLocals => RepoFactory.VideoLocal.GetByAniDBAnimeID(AniDB_ID); public IReadOnlyList<SVR_AnimeEpisode> AnimeEpisodes => RepoFactory.AnimeEpisode.GetBySeriesID(AnimeSeriesID) .Where(episode => !episode.IsHidden) .Select(episode => (episode, anidbEpisode: episode.AniDB_Episode)) - .OrderBy(tuple => tuple.anidbEpisode.EpisodeType) - .ThenBy(tuple => tuple.anidbEpisode.EpisodeNumber) + .OrderBy(tuple => tuple.anidbEpisode?.EpisodeType) + .ThenBy(tuple => tuple.anidbEpisode?.EpisodeNumber) .Select(tuple => tuple.episode) .ToList(); public IReadOnlyList<SVR_AnimeEpisode> AllAnimeEpisodes => RepoFactory.AnimeEpisode.GetBySeriesID(AnimeSeriesID) .Select(episode => (episode, anidbEpisode: episode.AniDB_Episode)) - .OrderBy(tuple => tuple.anidbEpisode.EpisodeType) - .ThenBy(tuple => tuple.anidbEpisode.EpisodeNumber) + .OrderBy(tuple => tuple.anidbEpisode?.EpisodeType) + .ThenBy(tuple => tuple.anidbEpisode?.EpisodeNumber) .Select(tuple => tuple.episode) .ToList(); - public MovieDB_Movie MovieDB_Movie - { - get - { - var movieDBXRef = RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(AniDB_ID, CrossRefType.MovieDB); - if (movieDBXRef?.CrossRefID == null || !int.TryParse(movieDBXRef.CrossRefID, out var movieID)) - { - return null; - } + #region Images - var movieDB = RepoFactory.MovieDb_Movie.GetByOnlineID(movieID); - return movieDB; - } + public HashSet<ImageEntityType> GetAvailableImageTypes() + { + var images = new List<IImageMetadata>(); + var poster = AniDB_Anime?.GetImageMetadata(false); + if (poster is not null) + images.Add(poster); + foreach (var xref in TmdbShowCrossReferences) + images.AddRange(xref.GetImages()); + foreach (var xref in TmdbSeasonCrossReferences) + images.AddRange(xref.GetImages()); + foreach (var xref in TmdbMovieCrossReferences.DistinctBy(xref => xref.TmdbMovieID)) + images.AddRange(xref.GetImages()); + return images + .DistinctBy(image => image.ImageType) + .Select(image => image.ImageType) + .ToHashSet(); } + public HashSet<ImageEntityType> GetPreferredImageTypes() + { + return RepoFactory.AniDB_Anime_PreferredImage.GetByAnimeID(AniDB_ID) + .WhereNotNull() + .Select(preferredImage => preferredImage.GetImageMetadata()) + .WhereNotNull() + .DistinctBy(image => image.ImageType) + .Select(image => image.ImageType) + .ToHashSet(); + } - #region TvDB - - public List<CrossRef_AniDB_TvDB> TvDBXrefs => RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(AniDB_ID); + public IImageMetadata? GetPreferredImageForType(ImageEntityType entityType) + => RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(AniDB_ID, entityType)?.GetImageMetadata(); - public List<TvDB_Series> TvDBSeries + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType = null) { - get + var preferredImages = (entityType.HasValue ? [RepoFactory.AniDB_Anime_PreferredImage.GetByAnidbAnimeIDAndType(AniDB_ID, entityType.Value)!] : RepoFactory.AniDB_Anime_PreferredImage.GetByAnimeID(AniDB_ID)) + .WhereNotNull() + .Select(preferredImage => preferredImage.GetImageMetadata()) + .WhereNotNull() + .DistinctBy(image => image.ImageType) + .ToDictionary(image => image.ImageType); + var images = new List<IImageMetadata>(); + if (!entityType.HasValue || entityType.Value is ImageEntityType.Poster) { - var xrefs = TvDBXrefs?.WhereNotNull().ToArray(); - if (xrefs == null || xrefs.Length == 0) return []; - return xrefs.Select(xref => xref.GetTvDBSeries()).WhereNotNull().ToList(); + var poster = AniDB_Anime?.GetImageMetadata(false); + if (poster is not null) + images.Add(preferredImages.TryGetValue(ImageEntityType.Poster, out var preferredPoster) && poster.Equals(preferredPoster) + ? preferredPoster + : poster + ); } + foreach (var xref in TmdbShowCrossReferences) + images.AddRange(xref.GetImages(entityType, preferredImages)); + foreach (var xref in TmdbSeasonCrossReferences) + images.AddRange(xref.GetImages(entityType, preferredImages)); + foreach (var xref in TmdbMovieCrossReferences.DistinctBy(xref => xref.TmdbMovieID)) + images.AddRange(xref.GetImages(entityType, preferredImages)); + + return images + .DistinctBy(image => (image.ImageType, image.Source, image.ID)) + .ToList(); } #endregion + #region AniDB + + public SVR_AniDB_Anime? AniDB_Anime => RepoFactory.AniDB_Anime.GetByAnimeID(AniDB_ID); + + #endregion + + #region TMDB + + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> TmdbMovieCrossReferences => RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(AniDB_ID); + + public IReadOnlyList<TMDB_Movie> TmdbMovies => TmdbMovieCrossReferences + .DistinctBy(xref => xref.TmdbMovieID) + .Select(xref => xref.TmdbMovie) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Show> TmdbShowCrossReferences => RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(AniDB_ID); + + public IReadOnlyList<TMDB_Show> TmdbShows => TmdbShowCrossReferences + .Select(xref => xref.TmdbShow) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> TmdbEpisodeCrossReferences => RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(AniDB_ID); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetTmdbEpisodeCrossReferences(int? tmdbShowId = null) => tmdbShowId.HasValue + ? RepoFactory.CrossRef_AniDB_TMDB_Episode.GetOnlyByAnidbAnimeAndTmdbShowIDs(AniDB_ID, tmdbShowId.Value) + : RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(AniDB_ID); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Season> TmdbSeasonCrossReferences => + TmdbEpisodeCrossReferences + .Select(xref => xref.TmdbSeasonCrossReference) + .WhereNotNull() + .DistinctBy(xref => xref.TmdbSeasonID) + .ToList(); + + public IReadOnlyList<TMDB_Season> TmdbSeasons => TmdbSeasonCrossReferences + .Select(xref => xref.TmdbSeason) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Season> GetTmdbSeasonCrossReferences(int? tmdbShowId = null) => + GetTmdbEpisodeCrossReferences(tmdbShowId) + .Select(xref => xref.TmdbSeasonCrossReference) + .WhereNotNull().Distinct() + .ToList(); + + #endregion + + #region Trakt + + public List<CrossRef_AniDB_TraktV2> TraktShowCrossReferences => RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(AniDB_ID); + public List<Trakt_Show> TraktShow { get { - using var session = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().SessionFactory.OpenSession(); - var sers = new List<Trakt_Show>(); - - var xrefs = RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(AniDB_ID); - if (xrefs == null || xrefs.Count == 0) - { - return sers; - } - - foreach (var xref in xrefs) - { - sers.Add(xref.GetByTraktShow(session)); - } + var series = new List<Trakt_Show>(); + var xrefs = TraktShowCrossReferences; + if (xrefs.Count == 0) + return []; - return sers; + using var session = Utils.ServiceContainer.GetRequiredService<DatabaseFactory>().SessionFactory.OpenSession(); + return xrefs + .Select(xref => xref.GetByTraktShow(session)) + .WhereNotNull() + .ToList(); } } - public CrossRef_AniDB_Other CrossRefMovieDB => - RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(AniDB_ID, CrossRefType.MovieDB); + #endregion + + #region MAL - public List<CrossRef_AniDB_MAL> CrossRefMAL => RepoFactory.CrossRef_AniDB_MAL.GetByAnimeID(AniDB_ID); + public List<CrossRef_AniDB_MAL> MALCrossReferences + => RepoFactory.CrossRef_AniDB_MAL.GetByAnimeID(AniDB_ID); - public SVR_AniDB_Anime AniDB_Anime => RepoFactory.AniDB_Anime.GetByAnimeID(AniDB_ID); + #endregion private DateTime? _airDate; @@ -254,7 +485,7 @@ public DateTime? AirDate // This will be slower, but hopefully more accurate var ep = RepoFactory.AniDB_Episode.GetByAnimeID(AniDB_ID) - .Where(a => a.EpisodeType == (int)EpisodeType.Episode && a.LengthSeconds > 0 && a.AirDate != 0) + .Where(a => a.EpisodeType == (int)Shoko.Models.Enums.EpisodeType.Episode && a.LengthSeconds > 0 && a.AirDate != 0) .MinBy(a => a.AirDate); return _airDate = ep?.GetAirDateAsDate(); } @@ -275,15 +506,15 @@ public HashSet<int> Years get { var anime = AniDB_Anime; - var startyear = anime?.BeginYear ?? 0; - if (startyear == 0) return []; + var startYear = anime?.BeginYear ?? 0; + if (startYear == 0) return []; - var endyear = anime?.EndYear ?? 0; - if (endyear == 0) endyear = DateTime.Today.Year; - if (endyear < startyear) endyear = startyear; - if (startyear == endyear) return [startyear]; + var endYear = anime?.EndYear ?? 0; + if (endYear == 0) endYear = DateTime.Today.Year; + if (endYear < startYear) endYear = startYear; + if (startYear == endYear) return [startYear]; - return Enumerable.Range(startyear, endyear - startyear + 1).Where(anime.IsInYear).ToHashSet(); + return Enumerable.Range(startYear, endYear - startYear + 1).Where(anime.IsInYear).ToHashSet(); } } @@ -299,12 +530,14 @@ public SVR_AnimeGroup TopLevelAnimeGroup { get { - var parentGroup = RepoFactory.AnimeGroup.GetByID(AnimeGroupID); + var parentGroup = RepoFactory.AnimeGroup.GetByID(AnimeGroupID) ?? + throw new NullReferenceException($"Unable to find parent AnimeGroup {AnimeGroupID} for AnimeSeries {AnimeSeriesID}"); int parentID; - while ((parentID = parentGroup?.AnimeGroupParentID ?? 0) != 0) + while ((parentID = parentGroup.AnimeGroupParentID ?? 0) != 0) { - parentGroup = RepoFactory.AnimeGroup.GetByID(parentID); + parentGroup = RepoFactory.AnimeGroup.GetByID(parentID) ?? + throw new NullReferenceException($"Unable to find parent AnimeGroup {parentGroup.AnimeGroupParentID} for AnimeGroup {parentGroup.AnimeGroupID}"); } return parentGroup; @@ -337,7 +570,7 @@ public List<SVR_AnimeGroup> AllGroupsAbove public override string ToString() { - return $"Series: {AniDB_Anime.MainTitle} ({AnimeSeriesID})"; + return $"Series: {AniDB_Anime?.MainTitle} ({AnimeSeriesID})"; //return string.Empty; } @@ -353,45 +586,7 @@ public override string ToString() string IWithTitles.DefaultTitle => SeriesNameOverride ?? AniDB_Anime?.MainTitle ?? $"<Shoko Series {AnimeSeriesID}>"; - string IWithTitles.PreferredTitle => SeriesName; - - IReadOnlyList<AnimeTitle> IWithTitles.Titles - { - get - { - var titles = new List<AnimeTitle>(); - var seriesOverrideTitle = false; - if (!string.IsNullOrEmpty(SeriesNameOverride)) - { - titles.Add(new() - { - Source = DataSourceEnum.Shoko, - Language = TitleLanguage.Unknown, - LanguageCode = "unk", - Title = SeriesNameOverride, - Type = TitleType.Main, - }); - seriesOverrideTitle = true; - } - - var anime = AniDB_Anime; - if (anime is not null && anime is ISeries animeSeries) - { - var animeTitles = animeSeries.Titles; - if (seriesOverrideTitle) - { - var mainTitle = animeTitles.FirstOrDefault(title => title.Type == TitleType.Main); - if (mainTitle is not null) - mainTitle.Type = TitleType.Official; - } - titles.AddRange(animeTitles); - } - - // TODO: Add other sources here. - - return titles; - } - } + string IWithTitles.PreferredTitle => PreferredTitle; #endregion @@ -399,95 +594,49 @@ IReadOnlyList<AnimeTitle> IWithTitles.Titles string IWithDescriptions.DefaultDescription => AniDB_Anime?.Description ?? string.Empty; - string IWithDescriptions.PreferredDescription => AniDB_Anime?.Description ?? string.Empty; - - IReadOnlyList<TextDescription> IWithDescriptions.Descriptions - { - get - { - var titles = new List<TextDescription>(); - - var anime = AniDB_Anime; - if (anime is not null && anime is ISeries animeSeries) - { - var animeTitles = animeSeries.Descriptions; - titles.AddRange(animeTitles); - } + string IWithDescriptions.PreferredDescription => PreferredOverview; - // TODO: Add other sources here. + IReadOnlyList<TextDescription> IWithDescriptions.Descriptions => (this as IShokoSeries).LinkedSeries.SelectMany(ep => ep.Descriptions).ToList() is { Count: > 0 } titles + ? titles + : [new() { Source = DataSourceEnum.AniDB, Language = TitleLanguage.English, LanguageCode = "en", Value = string.Empty }]; - // Fallback in the off-chance that the AniDB data is unavailable for whatever reason. It should never reach this code. - if (titles.Count is 0) - titles.Add(new() - { - Source = DataSourceEnum.AniDB, - Language = TitleLanguage.English, - LanguageCode = "en", - Value = string.Empty, - }); + #endregion - return titles; - } - } + #region IWithImages Implementation #endregion #region ISeries Implementation - AbstractAnimeType ISeries.Type => (AbstractAnimeType)(AniDB_Anime?.AnimeType ?? -1); + AnimeType ISeries.Type => (AnimeType)(AniDB_Anime?.AnimeType ?? -1); IReadOnlyList<int> ISeries.ShokoSeriesIDs => [AnimeSeriesID]; - IReadOnlyList<int> ISeries.ShokoGroupIDs => AllGroupsAbove.Select(a => a.AnimeGroupID).Distinct().ToList(); - double ISeries.Rating => (AniDB_Anime?.Rating ?? 0) / 100D; - bool ISeries.Restricted => (AniDB_Anime?.Restricted ?? 0) == 1; + bool ISeries.Restricted => AniDB_Anime?.IsRestricted ?? false; IReadOnlyList<IShokoSeries> ISeries.ShokoSeries => [this]; - IReadOnlyList<IShokoGroup> ISeries.ShokoGroups => AllGroupsAbove; - - IReadOnlyList<ISeries> ISeries.LinkedSeries - { - get - { - var seriesList = new List<ISeries>(); - - var anidbAnime = RepoFactory.AniDB_Anime.GetByAnimeID(AniDB_ID); - if (anidbAnime is not null) - seriesList.Add(anidbAnime); + IImageMetadata? ISeries.DefaultPoster => AniDB_Anime?.GetImageMetadata(); - // TODO: Add more series here. + IReadOnlyList<IRelatedMetadata<ISeries>> ISeries.RelatedSeries => []; - return seriesList; - } - } - - IReadOnlyList<IRelatedMetadata<ISeries>> ISeries.RelatedSeries => - RepoFactory.AniDB_Anime_Relation.GetByAnimeID(AniDB_ID); + IReadOnlyList<IRelatedMetadata<IMovie>> ISeries.RelatedMovies => []; IReadOnlyList<IVideoCrossReference> ISeries.CrossReferences => RepoFactory.CrossRef_File_Episode.GetByAnimeID(AniDB_ID); - IReadOnlyList<IEpisode> ISeries.EpisodeList => AllAnimeEpisodes; + IReadOnlyList<IEpisode> ISeries.Episodes => AllAnimeEpisodes; - IReadOnlyList<IVideo> ISeries.VideoList => + IReadOnlyList<IVideo> ISeries.Videos => RepoFactory.CrossRef_File_Episode.GetByAnimeID(AniDB_ID) .DistinctBy(xref => xref.Hash) .Select(xref => xref.VideoLocal) .WhereNotNull() .ToList(); - IReadOnlyDictionary<AbstractEpisodeType, int> ISeries.EpisodeCountDict - { - get - { - var episodes = (this as ISeries).EpisodeList; - return Enum.GetValues<AbstractEpisodeType>() - .ToDictionary(a => a, a => episodes.Count(e => e.Type == a)); - } - } + EpisodeCounts ISeries.EpisodeCounts => (this as IShokoSeries).AnidbAnime.EpisodeCounts; #endregion @@ -506,5 +655,41 @@ IReadOnlyDictionary<AbstractEpisodeType, int> ISeries.EpisodeCountDict IShokoGroup IShokoSeries.TopLevelGroup => TopLevelAnimeGroup; + IReadOnlyList<IShokoGroup> IShokoSeries.AllParentGroups => AllGroupsAbove; + + + IReadOnlyList<ISeries> IShokoSeries.LinkedSeries + { + get + { + var seriesList = new List<ISeries>(); + + var anidbAnime = AniDB_Anime; + if (anidbAnime is not null) + seriesList.Add(anidbAnime); + + seriesList.AddRange(TmdbShows); + + // Add more series here. + + return seriesList; + } + } + IReadOnlyList<IMovie> IShokoSeries.LinkedMovies + { + get + { + var movieList = new List<IMovie>(); + + movieList.AddRange(TmdbMovies); + + // Add more movies here. + + return movieList; + } + } + + IReadOnlyList<IShokoEpisode> IShokoSeries.Episodes => AllAnimeEpisodes; + #endregion } diff --git a/Shoko.Server/Models/SVR_CrossRef_File_Episode.cs b/Shoko.Server/Models/SVR_CrossRef_File_Episode.cs index f08e9608d..817fdb25e 100644 --- a/Shoko.Server/Models/SVR_CrossRef_File_Episode.cs +++ b/Shoko.Server/Models/SVR_CrossRef_File_Episode.cs @@ -1,6 +1,7 @@ using Shoko.Models.Enums; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Repositories; @@ -16,9 +17,9 @@ public class SVR_CrossRef_File_Episode : CrossRef_File_Episode, IVideoCrossRefer public SVR_AnimeEpisode? AnimeEpisode => RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(EpisodeID); - public SVR_AniDB_Anime? AniDBAnime => RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); + public SVR_AniDB_Anime? AniDBAnime => AnimeID is 0 ? null : RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); - public SVR_AnimeSeries? AnimeSeries => RepoFactory.AnimeSeries.GetByAnimeID(AnimeID); + public SVR_AnimeSeries? AnimeSeries => AnimeID is 0 ? null : RepoFactory.AnimeSeries.GetByAnimeID(AnimeID); public override string ToString() => $"CrossRef_File_Episode (Anime={AnimeID},Episode={EpisodeID},Hash={Hash},FileSize={FileSize},EpisodeOrder={EpisodeOrder},Percentage={Percentage})"; @@ -39,7 +40,7 @@ public override string ToString() => int IVideoCrossReference.AnidbEpisodeID => EpisodeID; - int IVideoCrossReference.AnidbAnimeID => AnimeID; + int IVideoCrossReference.AnidbAnimeID => AnimeID is 0 ? AniDBEpisode?.AnimeID ?? 0 : AnimeID; int IVideoCrossReference.Order => EpisodeOrder; @@ -51,5 +52,9 @@ public override string ToString() => ISeries? IVideoCrossReference.AnidbAnime => AniDBAnime; + IShokoEpisode? IVideoCrossReference.ShokoEpisode => AnimeEpisode; + + IShokoSeries? IVideoCrossReference.ShokoSeries => AnimeSeries; + #endregion } diff --git a/Shoko.Server/Models/SVR_ImportFolder.cs b/Shoko.Server/Models/SVR_ImportFolder.cs index af317abae..6ba6a552e 100755 --- a/Shoko.Server/Models/SVR_ImportFolder.cs +++ b/Shoko.Server/Models/SVR_ImportFolder.cs @@ -60,11 +60,34 @@ public DirectoryInfo BaseDirectory [JsonIgnore, XmlIgnore] public bool FolderIsDropDestination => IsDropDestination == 1; + [JsonIgnore, XmlIgnore] + public long AvailableFreeSpace + { + get + { + var path = ImportFolderLocation; + if (!Directory.Exists(path)) + return -1L; + + try + { + return new DriveInfo(path).AvailableFreeSpace; + } + catch + { + return -2L; + } + } + } + public override string ToString() { return string.Format("{0} - {1} ({2})", ImportFolderName, ImportFolderLocation, ImportFolderID); } + public bool CanAcceptFile(IVideoFile file) + => file is not null && (file.ImportFolderID == ImportFolderID || file.Size < AvailableFreeSpace); + #region IImportFolder Implementation int IImportFolder.ID => ImportFolderID; @@ -73,8 +96,6 @@ public override string ToString() string IImportFolder.Path => ImportFolderLocation; - string IImportFolder.Location => ImportFolderLocation; - DropFolderType IImportFolder.DropFolderType { get diff --git a/Shoko.Server/Models/SVR_VideoLocal.cs b/Shoko.Server/Models/SVR_VideoLocal.cs index 7299a27cb..f99bc90b1 100644 --- a/Shoko.Server/Models/SVR_VideoLocal.cs +++ b/Shoko.Server/Models/SVR_VideoLocal.cs @@ -3,22 +3,23 @@ using System.IO; using System.Linq; using System.Text; -using MessagePack; -using NLog; using Shoko.Commons.Extensions; using Shoko.Models.Interfaces; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Repositories; + +using AniDB_ReleaseGroup = Shoko.Server.Models.AniDB.AniDB_ReleaseGroup; using MediaContainer = Shoko.Models.MediaInfo.MediaContainer; +#pragma warning disable CS0618 +#nullable enable namespace Shoko.Server.Models; -public class SVR_VideoLocal : VideoLocal, IHash, IHashes, IVideo +public class SVR_VideoLocal : VideoLocal, IHashes, IVideo { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - #region DB columns public new bool IsIgnored { get; set; } @@ -37,7 +38,7 @@ public class SVR_VideoLocal : VideoLocal, IHash, IHashes, IVideo /// The Version of AVDump from Last time we did a successful AVDump. /// </summary> /// <value></value> - public string LastAVDumpVersion { get; set; } + public string? LastAVDumpVersion { get; set; } #endregion @@ -51,7 +52,7 @@ public class SVR_VideoLocal : VideoLocal, IHash, IHashes, IVideo /// <remarks> /// MediaInfo model has it in seconds, with milliseconds after the decimal point. /// </remarks> - public long Duration => (long) (MediaInfo?.GeneralStream?.Duration * 1000 ?? 0); + public long Duration => (long)(MediaInfo?.GeneralStream?.Duration * 1000 ?? 0); /// <summary> /// Playback duration as a <see cref="TimeSpan"/>. @@ -73,14 +74,14 @@ public TimeSpan DurationTimeSpan public const int MEDIA_VERSION = 5; - public MediaContainer MediaInfo { get; set; } + public MediaContainer? MediaInfo { get; set; } public List<SVR_VideoLocal_Place> Places => VideoLocalID == 0 ? new List<SVR_VideoLocal_Place>() : RepoFactory.VideoLocalPlace.GetByVideoLocal(VideoLocalID); - public SVR_AniDB_File AniDBFile => RepoFactory.AniDB_File.GetByHash(Hash); + public SVR_AniDB_File? AniDBFile => RepoFactory.AniDB_File.GetByHash(Hash); - internal AniDB_ReleaseGroup ReleaseGroup + internal AniDB_ReleaseGroup? ReleaseGroup { get { @@ -97,9 +98,9 @@ internal AniDB_ReleaseGroup ReleaseGroup public List<SVR_CrossRef_File_Episode> EpisodeCrossRefs => string.IsNullOrEmpty(Hash) ? [] : RepoFactory.CrossRef_File_Episode.GetByHash(Hash); - public SVR_VideoLocal_Place FirstValidPlace => Places.Where(p => !string.IsNullOrEmpty(p?.FullServerPath)).MinBy(a => a.ImportFolderType); + public SVR_VideoLocal_Place? FirstValidPlace => Places.Where(p => !string.IsNullOrEmpty(p?.FullServerPath)).MinBy(a => a.ImportFolderType); - public SVR_VideoLocal_Place FirstResolvedPlace => Places.Where(p => !string.IsNullOrEmpty(p?.FullServerPath)).OrderBy(a => a.ImportFolderType) + public SVR_VideoLocal_Place? FirstResolvedPlace => Places.Where(p => !string.IsNullOrEmpty(p?.FullServerPath)).OrderBy(a => a.ImportFolderType) .FirstOrDefault(p => File.Exists(p.FullServerPath)); public override string ToString() @@ -123,7 +124,7 @@ public string ToStringDetailed() return sb.ToString(); } - // is the videolocal empty. This isn't complete, but without one or more of these the record is useless + // is the video local empty. This isn't complete, but without one or more of these the record is useless public bool IsEmpty() { if (!string.IsNullOrEmpty(Hash)) return false; @@ -150,36 +151,35 @@ public bool HasAnyEmptyHashes() #region IVideo Implementation - string IVideo.EarliestKnownName => RepoFactory.FileNameHash.GetByHash(Hash).MinBy(a => a.FileNameHashID)?.FileName; + string? IVideo.EarliestKnownName => RepoFactory.FileNameHash.GetByHash(Hash).MinBy(a => a.FileNameHashID)?.FileName; long IVideo.Size => FileSize; - IReadOnlyList<IVideoFile> IVideo.Locations => throw new NotImplementedException(); + IReadOnlyList<IVideoFile> IVideo.Locations => Places; - IAniDBFile IVideo.AniDB => AniDBFile; + IAniDBFile? IVideo.AniDB => AniDBFile; IHashes IVideo.Hashes => this; - IMediaContainer IVideo.MediaInfo => MediaInfo; + IMediaInfo? IVideo.MediaInfo => MediaInfo; IReadOnlyList<IVideoCrossReference> IVideo.CrossReferences => EpisodeCrossRefs; - IReadOnlyList<IEpisode> IVideo.EpisodeInfo => + IReadOnlyList<IShokoEpisode> IVideo.Episodes => EpisodeCrossRefs - .Select(x => x.AniDBEpisode) + .Select(x => x.AnimeEpisode) .WhereNotNull() .ToArray(); - IReadOnlyList<ISeries> IVideo.SeriesInfo => + IReadOnlyList<IShokoSeries> IVideo.Series => EpisodeCrossRefs .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) + .Select(x => x.AnimeSeries) .WhereNotNull() - .OrderBy(a => a.MainTitle) - .Cast<IAnime>() + .OrderBy(a => a.PreferredTitle) .ToArray(); - IReadOnlyList<IGroup> IVideo.GroupInfo => + IReadOnlyList<IShokoGroup> IVideo.Groups => EpisodeCrossRefs .DistinctBy(x => x.AnimeID) .Select(x => x.AnimeSeries) @@ -188,13 +188,27 @@ public bool HasAnyEmptyHashes() .Select(a => a.AnimeGroup) .WhereNotNull() .OrderBy(g => g.GroupName) - .Cast<IGroup>() .ToArray(); int IMetadata<int>.ID => VideoLocalID; DataSourceEnum IMetadata.Source => DataSourceEnum.Shoko; + Stream? IVideo.GetStream() + { + if (FirstResolvedPlace is not { } fileLocation) + return null; + + var filePath = fileLocation.FullServerPath; + if (string.IsNullOrEmpty(filePath)) + return null; + + if (!File.Exists(filePath)) + return null; + + return File.OpenRead(filePath); + } + #endregion #region IHashes Implementation @@ -208,19 +222,13 @@ public bool HasAnyEmptyHashes() string IHashes.SHA1 => SHA1; #endregion - - string IHash.ED2KHash - { - get => Hash; - set => Hash = value; - } } -// This is a comparer used to sort the completeness of a videolocal, more complete first. +// This is a comparer used to sort the completeness of a video local, more complete first. // Because this is only used for comparing completeness of hashes, it does NOT follow the strict equality rules public class VideoLocalComparer : IComparer<VideoLocal> { - public int Compare(VideoLocal x, VideoLocal y) + public int Compare(VideoLocal? x, VideoLocal? y) { if (x == null) return 1; if (y == null) return -1; diff --git a/Shoko.Server/Models/SVR_VideoLocal_Place.cs b/Shoko.Server/Models/SVR_VideoLocal_Place.cs index cb699f652..3057f41c1 100644 --- a/Shoko.Server/Models/SVR_VideoLocal_Place.cs +++ b/Shoko.Server/Models/SVR_VideoLocal_Place.cs @@ -1,32 +1,33 @@ -using System.IO; +using System; +using System.IO; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; using Shoko.Server.Repositories; +#nullable enable namespace Shoko.Server.Models; public class SVR_VideoLocal_Place : VideoLocal_Place, IVideoFile { - internal SVR_ImportFolder ImportFolder => RepoFactory.ImportFolder.GetByID(ImportFolderID); + internal SVR_ImportFolder? ImportFolder => RepoFactory.ImportFolder.GetByID(ImportFolderID); - public string FullServerPath + public string? FullServerPath { get { - if (string.IsNullOrEmpty(ImportFolder?.ImportFolderLocation) || string.IsNullOrEmpty(FilePath)) - { + var importFolderLocation = ImportFolder?.ImportFolderLocation; + if (string.IsNullOrEmpty(importFolderLocation) || string.IsNullOrEmpty(FilePath)) return null; - } - return Path.Combine(ImportFolder.ImportFolderLocation, FilePath); + return Path.Combine(importFolderLocation, FilePath); } } public string FileName => Path.GetFileName(FilePath); - public SVR_VideoLocal VideoLocal => VideoLocalID == 0 ? null : RepoFactory.VideoLocal.GetByID(VideoLocalID); + public SVR_VideoLocal? VideoLocal => VideoLocalID is 0 ? null : RepoFactory.VideoLocal.GetByID(VideoLocalID); - public FileInfo GetFile() + public FileInfo? GetFile() { if (!File.Exists(FullServerPath)) { @@ -40,20 +41,21 @@ public FileInfo GetFile() int IVideoFile.ID => VideoLocal_Place_ID; - int IVideoFile.ImportFolderID => ImportFolderID; - int IVideoFile.VideoID => VideoLocalID; - IVideo IVideoFile.VideoInfo => VideoLocal; + IVideo IVideoFile.Video => VideoLocal + ?? throw new NullReferenceException("Unable to get the associated IVideo for the IVideoFile with ID " + VideoLocal_Place_ID); - string IVideoFile.Path => FullServerPath; + string IVideoFile.Path => FullServerPath + ?? throw new NullReferenceException("Unable to get the absolute path for the IVideoFile with ID " + VideoLocal_Place_ID); string IVideoFile.RelativePath { get { var path = FilePath.Replace('\\', '/'); - if (path.Length > 0 && path[0] != '/') + // Windows compat. home/folder -> /home/folder, but not C:/folder -> /C:/folder + if (path.Length > 0 && path[0] != '/' && (path.Length < 2 || path[1] != ':')) path = '/' + path; return path; } @@ -61,21 +63,20 @@ string IVideoFile.RelativePath long IVideoFile.Size => VideoLocal?.FileSize ?? 0; - IImportFolder IVideoFile.ImportFolder => ImportFolder; - - int IVideoFile.VideoFileID => VideoLocalID; - - string IVideoFile.Filename => Path.GetFileName(FilePath); - - string IVideoFile.FilePath => FullServerPath; - - long IVideoFile.FileSize => VideoLocal?.FileSize ?? 0; + IImportFolder IVideoFile.ImportFolder => ImportFolder + ?? throw new NullReferenceException("Unable to get the associated IImportFolder for the IVideoFile with ID " + VideoLocal_Place_ID); - IAniDBFile IVideoFile.AniDBFileInfo => VideoLocal?.AniDBFile; + Stream? IVideoFile.GetStream() + { + var filePath = FullServerPath; + if (string.IsNullOrEmpty(filePath)) + return null; - public IHashes Hashes => VideoLocal; + if (!File.Exists(filePath)) + return null; - public IMediaContainer MediaInfo => VideoLocal?.MediaInfo; + return File.OpenRead(filePath); + } #endregion } diff --git a/Shoko.Server/Models/SVR_VideoLocal_User.cs b/Shoko.Server/Models/SVR_VideoLocal_User.cs index 1868e6d36..5e0d6a319 100644 --- a/Shoko.Server/Models/SVR_VideoLocal_User.cs +++ b/Shoko.Server/Models/SVR_VideoLocal_User.cs @@ -36,6 +36,8 @@ public SVR_VideoLocal GetVideoLocal() public override string ToString() { var file = GetVideoLocal(); +#pragma warning disable CS0618 return $"{file.FileName} --- {file.Hash} --- User {JMMUserID}"; +#pragma warning restore CS0618 } } diff --git a/Shoko.Server/Models/TMDB/Embedded/TMDB_ContentRating.cs b/Shoko.Server/Models/TMDB/Embedded/TMDB_ContentRating.cs new file mode 100644 index 000000000..02804d5f0 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Embedded/TMDB_ContentRating.cs @@ -0,0 +1,59 @@ +using System; +using System.Text.Json.Serialization; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Extensions; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +[Serializable] +public class TMDB_ContentRating +{ + /// <summary> + /// + /// </summary> + /// <value></value> + public string CountryCode { get; set; } + + [JsonIgnore] + public string LanguageCode + { + get => CountryCode.FromIso3166ToIso639(); + } + + [JsonIgnore] + public TitleLanguage Language + { + get => LanguageCode.GetTitleLanguage(); + } + + /// <summary> + /// content ratings (certifications) that have been added to a TV show. + /// </summary> + public string Rating { get; set; } + + public TMDB_ContentRating() + { + CountryCode = string.Empty; + Rating = string.Empty; + } + + public TMDB_ContentRating(string countryCode, string rating) + { + CountryCode = countryCode; + Rating = rating; + } + + public override string ToString() + { + return $"{CountryCode},{Rating}"; + } + + public static TMDB_ContentRating FromString(string str) + { + var (countryCode, rating) = str.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + return new(countryCode, rating); + } + +} diff --git a/Shoko.Server/Models/TMDB/Embedded/TMDB_Season_Cast.cs b/Shoko.Server/Models/TMDB/Embedded/TMDB_Season_Cast.cs new file mode 100644 index 000000000..79d20b23c --- /dev/null +++ b/Shoko.Server/Models/TMDB/Embedded/TMDB_Season_Cast.cs @@ -0,0 +1,37 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Cast member within a season. +/// </summary> +public class TMDB_Season_Cast : TMDB_Cast +{ + #region Properties + + /// <summary> + /// TMDB Show ID for the show this role belongs to. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Show ID for the season this role belongs to. + /// </summary> + public int TmdbSeasonID { get; set; } + + /// <summary> + /// Indicates the role is not a recurring role within the season. + /// </summary> + public bool IsGuestRole { get; set; } + + /// <summary> + /// Number of episodes within this season the cast member have worked on. + /// </summary> + public int EpisodeCount { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Embedded/TMDB_Season_Crew.cs b/Shoko.Server/Models/TMDB/Embedded/TMDB_Season_Crew.cs new file mode 100644 index 000000000..cc6f910d5 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Embedded/TMDB_Season_Crew.cs @@ -0,0 +1,32 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Crew member within a season. +/// </summary> +public class TMDB_Season_Crew : TMDB_Crew +{ + #region Properties + + /// <summary> + /// TMDB Show ID for the show this job belongs to. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Show ID for the season this job belongs to. + /// </summary> + public int TmdbSeasonID { get; set; } + + /// <summary> + /// Number of episodes within this season the crew member have worked on. + /// </summary> + public int EpisodeCount { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Embedded/TMDB_Show_Cast.cs b/Shoko.Server/Models/TMDB/Embedded/TMDB_Show_Cast.cs new file mode 100644 index 000000000..a463e9d03 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Embedded/TMDB_Show_Cast.cs @@ -0,0 +1,32 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Cast member within a show. +/// </summary> +public class TMDB_Show_Cast : TMDB_Cast +{ + #region Properties + + /// <summary> + /// TMDB Show ID for the show this role belongs to. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// Number of episodes within this show the cast member have worked on. + /// </summary> + public int EpisodeCount { get; set; } + + /// <summary> + /// Number of season within this show the cast member have worked on. + /// </summary> + public int SeasonCount { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Embedded/TMDB_Show_Crew.cs b/Shoko.Server/Models/TMDB/Embedded/TMDB_Show_Crew.cs new file mode 100644 index 000000000..cf824fca2 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Embedded/TMDB_Show_Crew.cs @@ -0,0 +1,32 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Crew member within a season. +/// </summary> +public class TMDB_Show_Crew : TMDB_Crew +{ + #region Properties + + /// <summary> + /// TMDB Show ID for the show this job belongs to. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// Number of episodes within this season the crew member have worked on. + /// </summary> + public int EpisodeCount { get; set; } + + /// <summary> + /// Number of season within this show the crew member have worked on. + /// </summary> + public int SeasonCount { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering.cs b/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering.cs new file mode 100644 index 000000000..ae928c9fc --- /dev/null +++ b/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NHibernate.Util; +using Shoko.Commons.Extensions; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using TMDbLib.Objects.TvShows; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Alternate Season and Episode ordering using TMDB's "Episode Group" feature. +/// Note: don't ask me why they called it that. +/// </summary> +public class TMDB_AlternateOrdering : TMDB_Base<string> +{ + #region Properties + + public override string Id => TmdbEpisodeGroupCollectionID; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_AlternateOrderingID { get; set; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Network ID. + /// </summary> + /// <remarks> + /// It may be null if the group is not tied to a network. + /// </remarks> + public int? TmdbNetworkID { get; set; } + + /// <summary> + /// TMDB Episode Group Collection ID. + /// </summary> + public string TmdbEpisodeGroupCollectionID { get; set; } = string.Empty; + + /// <summary> + /// The name of the alternate ordering scheme. + /// </summary> + public string EnglishTitle { get; set; } = string.Empty; + + /// <summary> + /// A short overview about what the scheme entails. + /// </summary> + public string EnglishOverview { get; set; } = string.Empty; + + /// <summary> + /// Number of episodes within the episode group. + /// </summary> + public int EpisodeCount { get; set; } + + /// <summary> + /// Number of seasons within the episode group. + /// </summary> + public int SeasonCount { get; set; } + + /// <summary> + /// + /// </summary> + public AlternateOrderingType Type { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + #region Constructors + + public TMDB_AlternateOrdering() { } + + public TMDB_AlternateOrdering(string collectionId) + { + TmdbEpisodeGroupCollectionID = collectionId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + #region Methods + + public bool Populate(TvGroupCollection collection, int showId) + { + var updates = new[] + { + UpdateProperty(TmdbShowID, showId, v => TmdbShowID = v), + UpdateProperty(TmdbNetworkID, collection.Network?.Id, v => TmdbNetworkID = v), + UpdateProperty(EnglishTitle, collection.Name, v => EnglishTitle = v), + UpdateProperty(EnglishOverview, collection.Description, v => EnglishOverview = v), + UpdateProperty(EpisodeCount, collection.EpisodeCount, v => EpisodeCount = v), + UpdateProperty(SeasonCount, collection.GroupCount, v => SeasonCount = v), + UpdateProperty(Type, Enum.Parse<AlternateOrderingType>(collection.Type.ToString()), v => Type = v), + }; + + return updates.Any(updated => updated); + } + + /// <summary> + /// Get all cast members that have worked on this season. + /// </summary> + /// <returns>All cast members that have worked on this season.</returns> + public IReadOnlyList<TMDB_Show_Cast> Cast => + TmdbAlternateOrderingEpisodes + .SelectMany(episode => episode.TmdbEpisode?.Cast ?? []) + .WhereNotNull() + .GroupBy(cast => new { cast.TmdbPersonID, cast.CharacterName, cast.IsGuestRole }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + var seasonCount = episodes.GroupBy(a => a.TmdbSeasonID).Count(); + return new TMDB_Show_Cast() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + CharacterName = firstEpisode.CharacterName, + Ordering = firstEpisode.Ordering, + EpisodeCount = episodes.Count, + SeasonCount = seasonCount, + }; + }) + .OrderBy(crew => crew.Ordering) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + /// <summary> + /// Get all crew members that have worked on this season. + /// </summary> + /// <returns>All crew members that have worked on this season.</returns> + public IReadOnlyList<TMDB_Show_Crew> Crew => + TmdbAlternateOrderingEpisodes + .Select(episode => episode.TmdbEpisode?.Crew) + .WhereNotNull() + .SelectMany(list => list) + .GroupBy(cast => new { cast.TmdbPersonID, cast.Department, cast.Job }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + var seasonCount = episodes.GroupBy(a => a.TmdbSeasonID).Count(); + return new TMDB_Show_Crew() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + Department = firstEpisode.Department, + Job = firstEpisode.Job, + EpisodeCount = episodes.Count, + SeasonCount = seasonCount, + }; + }) + .OrderBy(crew => crew.Department) + .OrderBy(crew => crew.Job) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + public TMDB_Show? TmdbShow => + RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + public IReadOnlyList<TMDB_AlternateOrdering_Season> TmdbAlternateOrderingSeasons => + RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupCollectionID(TmdbEpisodeGroupCollectionID); + + public IReadOnlyList<TMDB_AlternateOrdering_Episode> TmdbAlternateOrderingEpisodes => + RepoFactory.TMDB_AlternateOrdering_Episode.GetByTmdbEpisodeGroupCollectionID(TmdbEpisodeGroupCollectionID); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering_Episode.cs b/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering_Episode.cs new file mode 100644 index 000000000..543a883c1 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering_Episode.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_AlternateOrdering_Episode : TMDB_Base<string> +{ + #region Properties + + public override string Id => $"{TmdbEpisodeGroupID}:{TmdbEpisodeID}"; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_AlternateOrdering_EpisodeID { get; set; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Episode Group Collection ID. + /// </summary> + public string TmdbEpisodeGroupCollectionID { get; set; } = string.Empty; + + /// <summary> + /// TMDB Episode Group ID. + /// </summary> + public string TmdbEpisodeGroupID { get; set; } = string.Empty; + + /// <summary> + /// TMDB Episode ID. + /// </summary> + public int TmdbEpisodeID { get; set; } + + /// <summary> + /// Overridden season number for alternate ordering. + /// </summary> + public int SeasonNumber { get; set; } + + /// <summary> + /// Overridden episode number for alternate ordering. + /// </summary> + /// <value></value> + public int EpisodeNumber { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + #region Constructors + + public TMDB_AlternateOrdering_Episode() { } + + public TMDB_AlternateOrdering_Episode(string episodeGroupId, int episodeId) + { + TmdbEpisodeGroupID = episodeGroupId; + TmdbEpisodeID = episodeId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + #region Methods + + public bool Populate(string collectionId, int showId, int seasonNumber, int episodeNumber) + { + var updates = new[] + { + UpdateProperty(TmdbShowID, showId, v => TmdbShowID = v), + UpdateProperty(TmdbEpisodeGroupCollectionID, collectionId, v => TmdbEpisodeGroupCollectionID = v), + UpdateProperty(SeasonNumber, seasonNumber, v => SeasonNumber = v), + UpdateProperty(EpisodeNumber, episodeNumber, v => EpisodeNumber = v), + }; + + return updates.Any(updated => updated); + } + + public TMDB_Show? TmdbShow => + RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + public TMDB_AlternateOrdering? TmdbAlternateOrdering => + RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(TmdbEpisodeGroupCollectionID); + + public TMDB_AlternateOrdering_Season? TmdbAlternateOrderingSeason => + RepoFactory.TMDB_AlternateOrdering_Season.GetByTmdbEpisodeGroupID(TmdbEpisodeGroupID); + + public TMDB_Episode? TmdbEpisode => + RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(TmdbEpisodeID); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering_Season.cs b/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering_Season.cs new file mode 100644 index 000000000..d6f49d6be --- /dev/null +++ b/Shoko.Server/Models/TMDB/Optional/TMDB_AlternateOrdering_Season.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Server.Repositories; +using TMDbLib.Objects.TvShows; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_AlternateOrdering_Season : TMDB_Base<string> +{ + #region Properties + + public override string Id => TmdbEpisodeGroupID; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_AlternateOrdering_SeasonID { get; set; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Episode Group Collection ID. + /// </summary> + public string TmdbEpisodeGroupCollectionID { get; set; } = string.Empty; + + /// <summary> + /// TMDB Episode Group ID. + /// </summary> + public string TmdbEpisodeGroupID { get; set; } = string.Empty; + + /// <summary> + /// Episode Group Season name. + /// </summary> + public string EnglishTitle { get; set; } = string.Empty; + + /// <summary> + /// Overridden season number for alternate ordering. + /// </summary> + public int SeasonNumber { get; set; } + + /// <summary> + /// Number of episodes within the alternate ordering season. + /// </summary> + public int EpisodeCount { get; set; } + + /// <summary> + /// Indicates the alternate ordering season is locked. + /// </summary> + /// <remarks> + /// Exactly what this 'locked' status indicates is yet to be determined. + /// </remarks> + public bool IsLocked { get; set; } = true; + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + #region Constructors + + public TMDB_AlternateOrdering_Season() { } + + public TMDB_AlternateOrdering_Season(string episodeGroupId) + { + TmdbEpisodeGroupID = episodeGroupId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + #region Methods + + public bool Populate(TvGroup episodeGroup, string collectionId, int showId, int seasonNumber) + { + var updates = new[] + { + UpdateProperty(TmdbShowID, showId, v => TmdbShowID = v), + UpdateProperty(TmdbEpisodeGroupCollectionID, collectionId, v => TmdbEpisodeGroupCollectionID = v), + UpdateProperty(EnglishTitle, episodeGroup.Name, v => EnglishTitle = v), + UpdateProperty(SeasonNumber, seasonNumber, v => SeasonNumber = v), + UpdateProperty(EpisodeCount, episodeGroup.Episodes.Count, v => EpisodeCount = v), + UpdateProperty(IsLocked, episodeGroup.Locked, v => IsLocked = v), + }; + + return updates.Any(updated => updated); + } + + /// <summary> + /// Get all cast members that have worked on this season. + /// </summary> + /// <returns>All cast members that have worked on this season.</returns> + public IReadOnlyList<TMDB_Season_Cast> Cast => + TmdbAlternateOrderingEpisodes + .SelectMany(episode => episode.TmdbEpisode?.Cast ?? []) + .WhereNotNull() + .GroupBy(cast => new { cast.TmdbPersonID, cast.CharacterName, cast.IsGuestRole }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + return new TMDB_Season_Cast() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + TmdbSeasonID = firstEpisode.TmdbSeasonID, + IsGuestRole = firstEpisode.IsGuestRole, + CharacterName = firstEpisode.CharacterName, + Ordering = firstEpisode.Ordering, + EpisodeCount = episodes.Count, + }; + }) + .OrderBy(crew => crew.Ordering) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + /// <summary> + /// Get all crew members that have worked on this season. + /// </summary> + /// <returns>All crew members that have worked on this season.</returns> + public IReadOnlyList<TMDB_Season_Crew> Crew => + TmdbAlternateOrderingEpisodes + .SelectMany(episode => episode.TmdbEpisode?.Crew ?? []) + .WhereNotNull() + .GroupBy(cast => new { cast.TmdbPersonID, cast.Department, cast.Job }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + return new TMDB_Season_Crew() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + TmdbSeasonID = firstEpisode.TmdbSeasonID, + Department = firstEpisode.Department, + Job = firstEpisode.Job, + EpisodeCount = episodes.Count, + }; + }) + .OrderBy(crew => crew.Department) + .OrderBy(crew => crew.Job) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + public TMDB_Show? TmdbShow => + RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + public TMDB_AlternateOrdering? TmdbAlternateOrdering => + RepoFactory.TMDB_AlternateOrdering.GetByTmdbEpisodeGroupCollectionID(TmdbEpisodeGroupCollectionID); + + public IReadOnlyList<TMDB_AlternateOrdering_Episode> TmdbAlternateOrderingEpisodes => + RepoFactory.TMDB_AlternateOrdering_Episode.GetByTmdbEpisodeGroupID(TmdbEpisodeGroupID); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Optional/TMDB_Collection.cs b/Shoko.Server/Models/TMDB/Optional/TMDB_Collection.cs new file mode 100644 index 000000000..9a39c0ba3 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Optional/TMDB_Collection.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Server; +using Shoko.Server.Utilities; +using TMDbLib.Objects.Collections; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// The Movie DataBase (TMDB) Movie Collection Database Model. +/// </summary> +public class TMDB_Collection : TMDB_Base<int>, IEntityMetadata +{ + #region Properties + + /// <summary> + /// IEntityMetadata.Id + /// </summary> + public override int Id => TmdbCollectionID; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_CollectionID { get; set; } + + /// <summary> + /// TMDB Collection ID. + /// </summary> + public int TmdbCollectionID { get; set; } + + /// <summary> + /// The english title of the collection, used as a fallback for when no + /// title is available in the preferred language. + /// </summary> + public string EnglishTitle { get; set; } = string.Empty; + + /// <summary> + /// The english overview, used as a fallback for when no overview is + /// available in the preferred language. + /// </summary> + public string EnglishOverview { get; set; } = string.Empty; + + /// <summary> + /// Number of movies in the collection. + /// </summary> + public int MovieCount { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + + #region Constructors + + /// <summary> + /// Constructor for NHibernate to work correctly while hydrating the rows + /// from the database. + /// </summary> + public TMDB_Collection() { } + + /// <summary> + /// Constructor to create a new movie collection in the provider. + /// </summary> + /// <param name="collectionId">The TMDB movie collection id.</param> + public TMDB_Collection(int collectionId) + { + TmdbCollectionID = collectionId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + + #region Methods + + /// <summary> + /// Populate the fields from the raw data. + /// </summary> + /// <param name="collection">The raw TMDB Movie Collection object.</param> + /// <returns>True if any of the fields have been updated.</returns> + public bool Populate(Collection collection) + { + var translation = collection.Translations?.Translations.FirstOrDefault(translation => translation.Iso_639_1 == "en"); + var updates = new[] + { + UpdateProperty(EnglishTitle, string.IsNullOrEmpty(translation?.Data.Name) ? collection.Name : translation.Data.Name, v => EnglishTitle = v), + UpdateProperty(EnglishOverview, string.IsNullOrEmpty(translation?.Data.Overview) ? collection.Overview : translation.Data.Overview, v => EnglishOverview = v), + UpdateProperty(MovieCount, collection.Parts.Count, v => MovieCount = v), + }; + + return updates.Any(updated => updated); + } + + /// <summary> + /// Get the preferred title using the preferred episode title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback title if no title was found in + /// any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all movie collection titles if + /// they're already cached from a previous call to + /// <seealso cref="GetAllTitles"/>. + /// </param> + /// <returns>The preferred movie collection title, or null if no preferred + /// title was found.</returns> + public TMDB_Title? GetPreferredTitle(bool useFallback = true, bool force = false) + { + var titles = GetAllTitles(force); + + foreach (var preferredLanguage in Languages.PreferredEpisodeNamingLanguages) + { + if (preferredLanguage.Language == TitleLanguage.Main) + return new(ForeignEntityType.Collection, TmdbCollectionID, EnglishTitle, "en", "US"); + + var title = titles.GetByLanguage(preferredLanguage.Language); + if (title != null) + return title; + } + + return useFallback ? new(ForeignEntityType.Collection, TmdbCollectionID, EnglishTitle, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all titles for the movie collection, so we won't + /// have to hit the database twice to get all titles _and_ the preferred + /// title. + /// </summary> + private IReadOnlyList<TMDB_Title>? _allTitles = null; + + /// <summary> + /// Get all titles for the movie collection. + /// </summary> + /// <param name="force">Forcefully re-fetch all movie collection titles if + /// they're already cached from a previous call.</param> + /// <returns>All titles for the movie collection.</returns> + public IReadOnlyList<TMDB_Title> GetAllTitles(bool force = false) => force + ? _allTitles = RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Collection, TmdbCollectionID) + : _allTitles ??= RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Collection, TmdbCollectionID); + + /// <summary> + /// Get the preferred overview using the preferred episode title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback overview if no overview was + /// found in any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all movie collection overviews if they're + /// already cached from a previous call to + /// <seealso cref="GetAllOverviews"/>. + /// </param> + /// <returns>The preferred movie collection overview, or null if no preferred overview + /// was found.</returns> + public TMDB_Overview? GetPreferredOverview(bool useFallback = true, bool force = false) + { + var overviews = GetAllOverviews(force); + + foreach (var preferredLanguage in Languages.PreferredDescriptionNamingLanguages) + { + var overview = overviews.GetByLanguage(preferredLanguage.Language); + if (overview != null) + return overview; + } + + return useFallback ? new(ForeignEntityType.Collection, TmdbCollectionID, EnglishOverview, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all overviews for the movie collection, so we won't have to hit + /// the database twice to get all overviews _and_ the preferred overview. + /// </summary> + private IReadOnlyList<TMDB_Overview>? _allOverviews = null; + + /// <summary> + /// Get all overviews for the movie collection. + /// </summary> + /// <param name="force">Forcefully re-fetch all movie collection overviews + /// if they're already cached from a previous call.</param> + /// <returns>All overviews for the movie collection.</returns> + public IReadOnlyList<TMDB_Overview> GetAllOverviews(bool force = false) => force + ? _allOverviews = RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Collection, TmdbCollectionID) + : _allOverviews ??= RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Collection, TmdbCollectionID); + + /// <summary> + /// Get all images for the movie collection, or all images for the given + /// <paramref name="entityType"/> provided for the movie collection. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the movie collection. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbCollectionIDAndType(TmdbCollectionID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbCollectionID(TmdbCollectionID); + + /// <summary> + /// Get all local TMDB movies associated with the movie collection. + /// </summary> + /// <returns>The TMDB movies.</returns> + public IReadOnlyList<TMDB_Movie> GetTmdbMovies() => + RepoFactory.TMDB_Movie.GetByTmdbCollectionID(TmdbCollectionID); + + #endregion + + #region IEntityMetadata + + ForeignEntityType IEntityMetadata.Type => ForeignEntityType.Collection; + + DataSourceEnum IEntityMetadata.DataSource => DataSourceEnum.TMDB; + + string? IEntityMetadata.OriginalTitle => null; + + TitleLanguage? IEntityMetadata.OriginalLanguage => null; + + string? IEntityMetadata.OriginalLanguageCode => null; + + DateOnly? IEntityMetadata.ReleasedAt => null; + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Optional/TMDB_Collection_Movie.cs b/Shoko.Server/Models/TMDB/Optional/TMDB_Collection_Movie.cs new file mode 100644 index 000000000..7885cf257 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Optional/TMDB_Collection_Movie.cs @@ -0,0 +1,43 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Collection_Movie +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_Collection_MovieID { get; set; } + + /// <summary> + /// TMDB Collection ID. + /// </summary> + public int TmdbCollectionID { get; set; } + + /// <summary> + /// TMDB Movie ID. + /// </summary> + public int TmdbMovieID { get; set; } + + /// <summary> + /// Ordering of movies within the collection. + /// </summary> + public int Ordering { get; set; } + + #endregion + + #region Constructors + + public TMDB_Collection_Movie() { } + + public TMDB_Collection_Movie(int collectionId, int movieId, int ordering) + { + TmdbCollectionID = collectionId; + TmdbMovieID = movieId; + Ordering = ordering; + } + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Optional/TMDB_Network.cs b/Shoko.Server/Models/TMDB/Optional/TMDB_Network.cs new file mode 100644 index 000000000..b29e25e09 --- /dev/null +++ b/Shoko.Server/Models/TMDB/Optional/TMDB_Network.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Network +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_NetworkID { get; set; } + + /// <summary> + /// TMDB Network ID. + /// </summary> + public int TmdbNetworkID { get; set; } + + /// <summary> + /// Main name of the network on TMDB. + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// The country the network originates from. + /// </summary> + public string CountryOfOrigin { get; set; } = string.Empty; + + #endregion + + #region Constructors + + #endregion + + #region Methods + + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbNetworkIDAndType(TmdbNetworkID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbNetworkID(TmdbNetworkID); + + public IReadOnlyList<TMDB_Show_Network> GetTmdbNetworkCrossReferences() => + RepoFactory.TMDB_Show_Network.GetByTmdbNetworkID(TmdbNetworkID); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/Optional/TMDB_Show_Network.cs b/Shoko.Server/Models/TMDB/Optional/TMDB_Show_Network.cs new file mode 100644 index 000000000..d4fa0b4cb --- /dev/null +++ b/Shoko.Server/Models/TMDB/Optional/TMDB_Show_Network.cs @@ -0,0 +1,41 @@ +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Show_Network +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_Show_NetworkID { get; set; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Network ID. + /// </summary> + public int TmdbNetworkID { get; set; } + + /// <summary> + /// Ordering. + /// </summary> + public int Ordering { get; set; } + + #endregion + + #region Methods + + public TMDB_Network? GetTmdbNetwork() => + RepoFactory.TMDB_Network.GetByTmdbNetworkID(TmdbNetworkID); + + public TMDB_Show? GetTmdbShow() => + RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Base.cs b/Shoko.Server/Models/TMDB/TMDB_Base.cs new file mode 100644 index 000000000..c58fe22e3 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Base.cs @@ -0,0 +1,33 @@ + +using System; +using System.Collections.Generic; + +namespace Shoko.Server.Models.TMDB; + +public abstract class TMDB_Base<TId> +{ + /// <summary> + /// Entity Id. + /// </summary> + public abstract TId Id { get; } + + public bool UpdateProperty<T>(T target, T value, Action<T> setter) + { + if (!EqualityComparer<T>.Default.Equals(target, value)) + { + setter(value); + return true; + } + return false; + } + + public bool UpdateProperty<T>(T target, T value, Action<T> setter, Func<T, T, bool> areEquals) + { + if (!areEquals(target, value)) + { + setter(value); + return true; + } + return false; + } +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Cast.cs b/Shoko.Server/Models/TMDB/TMDB_Cast.cs new file mode 100644 index 000000000..ac685b836 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Cast.cs @@ -0,0 +1,43 @@ +using System; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Cast member for an episode. +/// </summary> +public class TMDB_Cast +{ + #region Properties + + /// <summary> + /// TMDB Person ID for the cast member. + /// </summary> + public int TmdbPersonID { get; set; } + + /// <summary> + /// TMDB Credit ID for the acting job. + /// </summary> + public string TmdbCreditID { get; set; } = string.Empty; + + /// <summary> + /// Character name. + /// </summary> + public string CharacterName { get; set; } = string.Empty; + + /// <summary> + /// Ordering. + /// </summary> + public int Ordering { get; set; } + + #endregion + + #region Methods + + public TMDB_Person GetTmdbPerson() => + RepoFactory.TMDB_Person.GetByTmdbPersonID(TmdbPersonID) ?? + throw new Exception($"Unable to find TMDB Person with the given id. (Person={TmdbPersonID})"); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Company.cs b/Shoko.Server/Models/TMDB/TMDB_Company.cs new file mode 100644 index 000000000..179f7f0e2 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Company.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Repositories; +using TMDbLib.Objects.General; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Company +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_CompanyID { get; set; } + + /// <summary> + /// TMDB Company ID. + /// </summary> + public int TmdbCompanyID { get; set; } + + /// <summary> + /// Main name of the company on TMDB. + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// The country the company originates from. + /// </summary> + public string CountryOfOrigin { get; set; } = string.Empty; + + #endregion + + #region Constructors + + public TMDB_Company() { } + + public TMDB_Company(int companyId) + { + TmdbCompanyID = companyId; + } + + #endregion + + #region Methods + + public bool Populate(ProductionCompany company) + { + var updated = false; + if (!string.IsNullOrEmpty(company.Name) && !string.Equals(company.Name, Name)) + { + Name = company.Name; + updated = true; + } + if (!string.IsNullOrEmpty(company.OriginCountry) && !string.Equals(company.OriginCountry, CountryOfOrigin)) + { + CountryOfOrigin = company.OriginCountry; + updated = true; + } + return updated; + } + + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbCompanyIDAndType(TmdbCompanyID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbCompanyID(TmdbCompanyID); + + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + public IReadOnlyList<TMDB_Company_Entity> GetTmdbCompanyCrossReferences() => + RepoFactory.TMDB_Company_Entity.GetByTmdbCompanyID(TmdbCompanyID); + + public IReadOnlyList<IEntityMetadata> GetTmdbEntities() => + GetTmdbCompanyCrossReferences() + .Select(xref => xref.GetTmdbEntity()) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<IEntityMetadata> GetTmdbShows() => + GetTmdbCompanyCrossReferences() + .Select(xref => xref.GetTmdbShow()) + .WhereNotNull() + .ToList(); + + public IReadOnlyList<IEntityMetadata> GetTmdbMovies() => + GetTmdbCompanyCrossReferences() + .Select(xref => xref.GetTmdbMovie()) + .WhereNotNull() + .ToList(); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Company_Entity.cs b/Shoko.Server/Models/TMDB/TMDB_Company_Entity.cs new file mode 100644 index 000000000..1df90e844 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Company_Entity.cs @@ -0,0 +1,84 @@ + +#nullable enable +using System; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Repositories; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Company_Entity +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_Company_EntityID { get; set; } + + /// <summary> + /// TMDB Company ID. + /// </summary> + public int TmdbCompanyID { get; set; } + + /// <summary> + /// TMDB Entity Type. + /// </summary> + public ForeignEntityType TmdbEntityType { get; set; } + + /// <summary> + /// TMDB Entity ID. + /// </summary> + public int TmdbEntityID { get; set; } + + /// <summary> + /// Used for ordering the companies for the entity. + /// </summary> + public int Ordering { get; set; } + + /// <summary> + /// Used for ordering the entities for the company. + /// </summary> + public DateOnly? ReleasedAt { get; set; } + + #endregion + + #region Constructors + + public TMDB_Company_Entity() { } + + public TMDB_Company_Entity(int companyId, ForeignEntityType entityType, int entityId, int index, DateOnly? releasedAt) + { + TmdbCompanyID = companyId; + TmdbEntityType = entityType; + TmdbEntityID = entityId; + Ordering = index; + ReleasedAt = releasedAt; + } + + #endregion + + #region Methods + + public TMDB_Company? GetTmdbCompany() => + RepoFactory.TMDB_Company.GetByTmdbCompanyID(TmdbCompanyID); + + public IEntityMetadata? GetTmdbEntity() => + TmdbEntityType switch + { + ForeignEntityType.Show => RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbEntityID), + ForeignEntityType.Movie => RepoFactory.TMDB_Movie.GetByTmdbMovieID(TmdbEntityID), + _ => null, + }; + + public TMDB_Show? GetTmdbShow() => TmdbEntityType == ForeignEntityType.Show + ? RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbEntityID) + : null; + + public TMDB_Movie? GetTmdbMovie() => TmdbEntityType == ForeignEntityType.Movie + ? RepoFactory.TMDB_Movie.GetByTmdbMovieID(TmdbEntityID) + : null; + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Crew.cs b/Shoko.Server/Models/TMDB/TMDB_Crew.cs new file mode 100644 index 000000000..4593a3827 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Crew.cs @@ -0,0 +1,43 @@ +using System; +using Shoko.Server.Repositories; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Crew member for an episode. +/// </summary> +public class TMDB_Crew +{ + #region Properties + + /// <summary> + /// TMDB Person ID for the crew member. + /// </summary> + public int TmdbPersonID { get; set; } + + /// <summary> + /// TMDB Credit ID for the production job. + /// </summary> + public string TmdbCreditID { get; set; } = string.Empty; + + /// <summary> + /// The job title. + /// </summary> + public string Job { get; set; } = string.Empty; + + /// <summary> + /// The crew department. + /// </summary> + public string Department { get; set; } = string.Empty; + + #endregion + + #region Methods + + public TMDB_Person GetTmdbPerson() => + RepoFactory.TMDB_Person.GetByTmdbPersonID(TmdbPersonID) ?? + throw new Exception($"Unable to find TMDB Person with the given id. (Person={TmdbPersonID})"); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Episode.cs b/Shoko.Server/Models/TMDB/TMDB_Episode.cs new file mode 100644 index 000000000..d493b5a44 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Episode.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Server; +using Shoko.Server.Utilities; +using TMDbLib.Objects.General; +using TMDbLib.Objects.Search; +using TMDbLib.Objects.TvShows; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// The Movie DataBase (TMDB) Episode Database Model. +/// </summary> +public class TMDB_Episode : TMDB_Base<int>, IEntityMetadata, IEpisode +{ + #region Properties + + /// <summary> + /// IEntityMetadata.Id. + /// </summary> + public override int Id => TmdbEpisodeID; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_EpisodeID { get; set; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Season ID. + /// </summary> + public int TmdbSeasonID { get; set; } + + /// <summary> + /// TMDB Episode ID. + /// </summary> + public int TmdbEpisodeID { get; set; } + + /// <summary> + /// Linked TvDB episode ID. + /// </summary> + /// <remarks> + /// Will be <code>null</code> if not linked. Will be <code>0</code> if no + /// TvDB link is found in TMDB. Otherwise it will be the TvDB episode ID. + /// </remarks> + public int? TvdbEpisodeID { get; set; } + + /// <summary> + /// The english title of the episode, used as a fallback for when no title + /// is available in the preferred language. + /// </summary> + public string EnglishTitle { get; set; } = string.Empty; + + /// <summary> + /// The english overview, used as a fallback for when no overview is + /// available in the preferred language. + /// </summary> + public string EnglishOverview { get; set; } = string.Empty; + + /// <summary> + /// Season number for default ordering. + /// </summary> + public int SeasonNumber { get; set; } + + /// <summary> + /// Episode number for default ordering. + /// </summary> + public int EpisodeNumber { get; set; } + + /// <summary> + /// Episode run-time in minutes. + /// </summary> + public int? RuntimeMinutes + { + get => Runtime.HasValue ? (int)Math.Floor(Runtime.Value.TotalMinutes) : null; + set => Runtime = value.HasValue ? TimeSpan.FromMinutes(value.Value) : null; + } + + /// <summary> + /// Episode run-time. + /// </summary> + public TimeSpan? Runtime { get; set; } + + /// <summary> + /// Average user rating across all <see cref="UserVotes"/>. + /// </summary> + public double UserRating { get; set; } + + /// <summary> + /// Number of users that cast a vote for a rating of this show. + /// </summary> + /// <value></value> + public int UserVotes { get; set; } + + /// <summary> + /// When the episode aired, or when it will air in the future if it's known. + /// </summary> + public DateOnly? AiredAt { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + + #region Constructors + + /// <summary> + /// Constructor for NHibernate to work correctly while hydrating the rows + /// from the database. + /// </summary> + public TMDB_Episode() { } + + /// <summary> + /// Constructor to create a new episode in the provider. + /// </summary> + /// <param name="episodeId">The TMDB episode id.</param> + public TMDB_Episode(int episodeId) + { + TmdbEpisodeID = episodeId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + + #region Methods + + /// <summary> + /// Populate the fields from the raw data. + /// </summary> + /// <param name="show">The raw TMDB Tv Show object.</param> + /// <param name="season">The raw TMDB Tv Season object.</param> + /// <param name="episode">The raw TMDB Tv Episode object.</param> + /// <param name="translations">The translation container for the Tv Episode object (fetched separately).</param> + /// <returns>True if any of the fields have been updated.</returns> + public bool Populate(TvShow show, TvSeason season, TvSeasonEpisode episode, TranslationsContainer? translations) + { + var translation = translations?.Translations.FirstOrDefault(translation => translation.Iso_639_1 == "en"); + + var updates = new[] + { + UpdateProperty(TmdbSeasonID, season.Id!.Value, v => TmdbSeasonID = v), + UpdateProperty(TmdbShowID, show.Id, v => TmdbShowID = v), + // If the translations aren't provided and we have an English title, then don't update it. + UpdateProperty(EnglishTitle, translations is null && !string.IsNullOrEmpty(EnglishTitle) ? EnglishTitle : !string.IsNullOrEmpty(translation?.Data.Name) ? translation.Data.Name : episode.Name, v => EnglishTitle = v), + UpdateProperty(EnglishOverview, !string.IsNullOrEmpty(translation?.Data.Overview) ? translation.Data.Overview : episode.Overview, v => EnglishOverview = v), + UpdateProperty(SeasonNumber, episode.SeasonNumber, v => SeasonNumber = v), + UpdateProperty(EpisodeNumber, episode.EpisodeNumber, v => EpisodeNumber = v), + UpdateProperty(Runtime, episode.Runtime.HasValue ? TimeSpan.FromMinutes(episode.Runtime.Value) : null, v => Runtime = v), + UpdateProperty(UserRating, episode.VoteAverage, v => UserRating = v), + UpdateProperty(UserVotes, episode.VoteCount, v => UserVotes = v), + UpdateProperty(AiredAt, episode.AirDate.HasValue ? DateOnly.FromDateTime(episode.AirDate.Value) : null, v => AiredAt = v), + }; + + return updates.Any(updated => updated); + } + + /// <summary> + /// Get the preferred title using the preferred episode title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback title if no title was found in + /// any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all episode titles if they're + /// already cached from a previous call to <seealso cref="GetAllTitles"/>. + /// </param> + /// <returns>The preferred episode title, or null if no preferred title was + /// found.</returns> + public TMDB_Title? GetPreferredTitle(bool useFallback = true, bool force = false) + { + var titles = GetAllTitles(force); + + foreach (var preferredLanguage in Languages.PreferredEpisodeNamingLanguages) + { + if (preferredLanguage.Language == TitleLanguage.Main) + return new(ForeignEntityType.Episode, TmdbEpisodeID, EnglishTitle, "en", "US"); + + var title = titles.GetByLanguage(preferredLanguage.Language); + if (title != null) + return title; + } + + return useFallback ? new(ForeignEntityType.Episode, TmdbEpisodeID, EnglishTitle, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all titles for the episode, so we won't have to hit + /// the database twice to get all titles _and_ the preferred title. + /// </summary> + private IReadOnlyList<TMDB_Title>? _allTitles = null; + + /// <summary> + /// Get all titles for the episode. + /// </summary> + /// <param name="force">Forcefully re-fetch all episode titles if they're + /// already cached from a previous call.</param> + /// <returns>All titles for the episode.</returns> + public IReadOnlyList<TMDB_Title> GetAllTitles(bool force = false) => force + ? _allTitles = RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Episode, TmdbEpisodeID) + : _allTitles ??= RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Episode, TmdbEpisodeID); + + /// <summary> + /// Get all episode titles in the preferred episode title languages. + /// </summary> + /// <param name="force">Forcefully re-fetch all episode titles if they're + /// already cached from a previous call.</param> + /// <returns>All episode titles in the preferred episode title languages.</returns> + public IReadOnlyList<TMDB_Title> GetAllPreferredTitles(bool force = false) + { + var allTitles = GetAllTitles(force); + var preferredLanguages = Languages.PreferredEpisodeNamingLanguages; + return allTitles + .WhereInLanguages(preferredLanguages.Select(language => language.Language).Append(TitleLanguage.English).ToHashSet()) + .ToList(); + } + + /// <summary> + /// Get the preferred overview using the preferred episode title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback overview if no overview was + /// found in any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all episode overviews if they're + /// already cached from a previous call to + /// <seealso cref="GetAllOverviews"/>. + /// </param> + /// <returns>The preferred episode overview, or null if no preferred + /// overview was found.</returns> + public TMDB_Overview? GetPreferredOverview(bool useFallback = true, bool force = false) + { + var overviews = GetAllOverviews(force); + + foreach (var preferredLanguage in Languages.PreferredDescriptionNamingLanguages) + { + var overview = overviews.GetByLanguage(preferredLanguage.Language); + if (overview != null) + return overview; + } + + return useFallback ? new(ForeignEntityType.Episode, TmdbEpisodeID, EnglishOverview, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all overviews for the episode, so we won't have to + /// hit the database twice to get all overviews _and_ the preferred + /// overview. + /// </summary> + private IReadOnlyList<TMDB_Overview>? _allOverviews = null; + + /// <summary> + /// Get all overviews for the episode. + /// </summary> + /// <param name="force">Forcefully re-fetch all episode overviews if they're + /// already cached from a previous call.</param> + /// <returns>All overviews for the episode.</returns> + public IReadOnlyList<TMDB_Overview> GetAllOverviews(bool force = false) => force + ? _allOverviews = RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Episode, TmdbEpisodeID) + : _allOverviews ??= RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Episode, TmdbEpisodeID); + + /// <summary> + /// Get all images for the episode, or all images for the given + /// <paramref name="entityType"/> provided for the episode. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the episode. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbEpisodeIDAndType(TmdbEpisodeID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbEpisodeID(TmdbEpisodeID); + + /// <summary> + /// Get all images for the episode, or all images for the given + /// <paramref name="entityType"/> provided for the episode. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <param name="preferredImages">The preferred images.</param> + /// <returns>A read-only list of images that are linked to the episode. + /// </returns> + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + IImageMetadata? IWithImages.GetPreferredImageForType(ImageEntityType entityType) + => null; + + IReadOnlyList<IImageMetadata> IWithImages.GetImages(ImageEntityType? entityType) + => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbEpisodeIDAndType(TmdbEpisodeID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbEpisodeID(TmdbEpisodeID); + + /// <summary> + /// Get all cast members that have worked on this episode. + /// </summary> + /// <returns>All cast members that have worked on this episode.</returns> + public IReadOnlyList<TMDB_Episode_Cast> Cast => + RepoFactory.TMDB_Episode_Cast.GetByTmdbEpisodeID(TmdbEpisodeID); + + /// <summary> + /// Get all crew members that have worked on this episode. + /// </summary> + /// <returns>All crew members that have worked on this episode.</returns> + public IReadOnlyList<TMDB_Episode_Crew> Crew => + RepoFactory.TMDB_Episode_Crew.GetByTmdbEpisodeID(TmdbEpisodeID); + + /// <summary> + /// Get the TMDB season associated with the episode, or null if the season + /// have been purged from the local database for whatever reason. + /// </summary> + /// <returns>The TMDB season, or null.</returns> + public TMDB_Season? TmdbSeason => + RepoFactory.TMDB_Season.GetByTmdbSeasonID(TmdbSeasonID); + + /// <summary> + /// Get the TMDB show associated with the episode, or null if the show have + /// been purged from the local database for whatever reason. + /// </summary> + /// <returns>The TMDB show, or null.</returns> + public TMDB_Show? TmdbShow => + RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all alternate ordering entries for the episode available from the + /// local database. You need to have alternate orderings enabled in the + /// settings file for these to be populated. + /// </summary> + /// <returns>All alternate ordering entries for the episode.</returns> + public IReadOnlyList<TMDB_AlternateOrdering_Episode> TmdbAlternateOrderingEpisodes => + RepoFactory.TMDB_AlternateOrdering_Episode.GetByTmdbEpisodeID(TmdbEpisodeID); + + /// <summary> + /// Get the alternate ordering entry for the episode with the given + /// <paramref name="id"/>, or null if no such entry exists. + /// </summary> + /// <param name="id">The episode group collection ID of the alternate ordering + /// entry to retrieve.</param> + /// <returns>The alternate ordering entry associated with the given ID, or + /// null if no such entry exists.</returns> + public TMDB_AlternateOrdering_Episode? GetTmdbAlternateOrderingEpisodeById(string? id) => + string.IsNullOrEmpty(id) + ? null + : RepoFactory.TMDB_AlternateOrdering_Episode.GetByEpisodeGroupCollectionAndEpisodeIDs(id, TmdbEpisodeID); + + /// <summary> + /// Get all AniDB/TMDB cross-references for the episode. + /// </summary> + /// <returns>A read-only list of AniDB/TMDB cross-references for the episode.</returns> + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> CrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByTmdbEpisodeID(TmdbEpisodeID); + + /// <summary> + /// Get all file cross-references associated with the episode. + /// </summary> + /// <returns>A read-only list of file cross-references associated with the + /// episode.</returns> + public IReadOnlyList<SVR_CrossRef_File_Episode> FileCrossReferences => + CrossReferences + .DistinctBy(xref => xref.AnidbEpisodeID) + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID)) + .WhereNotNull() + .ToList(); + + #endregion + + #region IEntityMetadata + + ForeignEntityType IEntityMetadata.Type => ForeignEntityType.Episode; + + DataSourceEnum IEntityMetadata.DataSource => DataSourceEnum.TMDB; + + string? IEntityMetadata.OriginalTitle => null; + + TitleLanguage? IEntityMetadata.OriginalLanguage => null; + + string? IEntityMetadata.OriginalLanguageCode => null; + + DateOnly? IEntityMetadata.ReleasedAt => AiredAt; + + #endregion + + #region IMetadata + + DataSourceEnum IMetadata.Source => DataSourceEnum.TMDB; + + int IMetadata<int>.ID => Id; + + #endregion + + #region IWithTitles + + string IWithTitles.DefaultTitle => EnglishTitle; + + string IWithTitles.PreferredTitle => GetPreferredTitle()!.Value; + + IReadOnlyList<AnimeTitle> IWithTitles.Titles => GetAllTitles() + .Select(title => new AnimeTitle() + { + Language = title.Language, + LanguageCode = title.LanguageCode, + Source = DataSourceEnum.TMDB, + Title = title.Value, + Type = TitleType.Official, + }) + .ToList(); + + #endregion + + #region IWithDescriptions + + string IWithDescriptions.DefaultDescription => EnglishOverview; + + string IWithDescriptions.PreferredDescription => GetPreferredOverview()!.Value; + + IReadOnlyList<TextDescription> IWithDescriptions.Descriptions => GetAllOverviews() + .Select(overview => new TextDescription() + { + CountryCode = overview.CountryCode, + Language = overview.Language, + LanguageCode = overview.LanguageCode, + Source = DataSourceEnum.TMDB, + Value = overview.Value, + }) + .ToList(); + + #endregion + + #region IEpisode + + int IEpisode.SeriesID => TmdbShowID; + + IReadOnlyList<int> IEpisode.ShokoEpisodeIDs => CrossReferences + .Select(xref => xref.AnimeEpisode?.AnimeEpisodeID) + .WhereNotNull() + .ToList(); + + EpisodeType IEpisode.Type => SeasonNumber == 0 ? EpisodeType.Special : EpisodeType.Episode; + + int IEpisode.EpisodeNumber => EpisodeNumber; + + int? IEpisode.SeasonNumber => SeasonNumber; + + TimeSpan IEpisode.Runtime => Runtime ?? TimeSpan.Zero; + + DateTime? IEpisode.AirDate => AiredAt?.ToDateTime(); + + ISeries? IEpisode.Series => TmdbShow; + + IReadOnlyList<IShokoEpisode> IEpisode.ShokoEpisodes => CrossReferences + .Select(xref => xref.AnimeEpisode) + .WhereNotNull() + .ToList(); + + IReadOnlyList<IVideoCrossReference> IEpisode.CrossReferences => CrossReferences + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID)) + .ToList(); + + IReadOnlyList<IVideo> IEpisode.VideoList => CrossReferences + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID)) + .Select(xref => xref.VideoLocal) + .WhereNotNull() + .ToList(); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Episode_Cast.cs b/Shoko.Server/Models/TMDB/TMDB_Episode_Cast.cs new file mode 100644 index 000000000..05412c065 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Episode_Cast.cs @@ -0,0 +1,42 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Cast member for an episode. +/// </summary> +public class TMDB_Episode_Cast : TMDB_Cast +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_Episode_CastID { get; set; } + + /// <summary> + /// TMDB Show ID for the show this role belongs to. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Show ID for the season this role belongs to. + /// </summary> + public int TmdbSeasonID { get; set; } + + /// <summary> + /// TMDB Episode ID for the episode this role belongs to. + /// </summary> + public int TmdbEpisodeID { get; set; } + + /// <summary> + /// Indicates the role is not a recurring role within the season. + /// </summary> + public bool IsGuestRole { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Episode_Crew.cs b/Shoko.Server/Models/TMDB/TMDB_Episode_Crew.cs new file mode 100644 index 000000000..122d39717 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Episode_Crew.cs @@ -0,0 +1,37 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Crew member for an episode. +/// </summary> +public class TMDB_Episode_Crew : TMDB_Crew +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_Episode_CrewID { get; set; } + + /// <summary> + /// TMDB Show ID for the show this job belongs to. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Show ID for the season this job belongs to. + /// </summary> + public int TmdbSeasonID { get; set; } + + /// <summary> + /// TMDB Episode ID for the episode this job belongs to. + /// </summary> + public int TmdbEpisodeID { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Image.cs b/Shoko.Server/Models/TMDB/TMDB_Image.cs new file mode 100644 index 000000000..61887de23 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Image.cs @@ -0,0 +1,322 @@ +using System.IO; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Server; +using Shoko.Server.Utilities; +using TMDbLib.Objects.General; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Image : Image_Base, IImageMetadata +{ + #region Properties + + /// <inheritdoc/> + public override int ID => TMDB_ImageID; + + /// <inheritdoc/> + public override bool IsLocked => false; + + /// <inheritdoc/> + public override int Width { get; set; } = 0; + + /// <inheritdoc/> + public override int Height { get; set; } = 0; + + /// <summary> + /// Local id for image. + /// </summary> + public int TMDB_ImageID { get; set; } + + /// <summary> + /// Related TMDB Movie entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbMovieID { get; set; } + + /// <summary> + /// Related TMDB Episode entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbEpisodeID { get; set; } + + /// <summary> + /// Related TMDB Season entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbSeasonID { get; set; } + + /// <summary> + /// Related TMDB Show entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbShowID { get; set; } + + /// <summary> + /// Related TMDB Collection entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbCollectionID { get; set; } + + /// <summary> + /// Related TMDB Network entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbNetworkID { get; set; } + + /// <summary> + /// Related TMDB Company entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbCompanyID { get; set; } + + /// <summary> + /// Related TMDB Person entity id, if applicable. + /// </summary> + /// <remarks> + /// An image can be linked to multiple entries at once. + /// </remarks> + public int? TmdbPersonID { get; set; } + + /// <summary> + /// Foreign type. Determines if the data is for movies or tv shows, and if + /// the tmdb id is for a show or movie. + /// </summary> + public ForeignEntityType ForeignType { get; set; } + + /// <inheritdoc/> + public string RemoteFileName { get; set; } = string.Empty; + + /// <inheritdoc/> + public override string? RemoteURL + => string.IsNullOrEmpty(RemoteFileName) || string.IsNullOrEmpty(TmdbMetadataService.ImageServerUrl) ? null : $"{TmdbMetadataService.ImageServerUrl}original{RemoteFileName}"; + + /// <summary> + /// Relative path to the image stored locally. + /// </summary> + public string? RelativePath + => string.IsNullOrEmpty(RemoteFileName) ? null : Path.Join("TMDB", ImageType.ToString(), RemoteFileName); + + /// <inheritdoc/> + public override string? LocalPath + => ImageUtils.ResolvePath(RelativePath); + + /// <summary> + /// Average user rating across all user votes. + /// </summary> + /// <remarks> + /// May be used for ordering when acquiring and/or discarding images. + /// </remarks> + public double UserRating { get; set; } = 0.0; + + /// <summary> + /// User votes. + /// </summary> + /// <remarks> + /// May be used for ordering when acquiring and/or discarding images. + /// </remarks> + public int UserVotes { get; set; } = 0; + + #endregion + + #region Constructors + + public TMDB_Image() : base(DataSourceEnum.TMDB, ImageEntityType.None, 0) { } + + public TMDB_Image(string filePath, ImageEntityType type) : base(DataSourceEnum.TMDB, type, 0) + { + RemoteFileName = filePath?.Trim() ?? string.Empty; + if (RemoteFileName.EndsWith(".svg")) + RemoteFileName = RemoteFileName[..^4] + ".png"; + IsEnabled = true; + } + + #endregion + + #region Methods + + public bool Populate(ImageData data, ForeignEntityType foreignType, int foreignId) + { + var updated = Populate(data); + updated |= Populate(foreignType, foreignId); + return updated; + } + + public bool Populate(ForeignEntityType foreignType, int foreignId) + { + var updated = false; + switch (foreignType) + { + case ForeignEntityType.Movie: + if (TmdbMovieID != foreignId) + { + TmdbMovieID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + case ForeignEntityType.Episode: + if (TmdbEpisodeID != foreignId) + { + TmdbEpisodeID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + case ForeignEntityType.Season: + if (TmdbSeasonID != foreignId) + { + TmdbSeasonID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + case ForeignEntityType.Show: + if (TmdbShowID != foreignId) + { + TmdbShowID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + case ForeignEntityType.Collection: + if (TmdbCollectionID != foreignId) + { + TmdbCollectionID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + case ForeignEntityType.Network: + if (TmdbNetworkID != foreignId) + { + TmdbNetworkID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + case ForeignEntityType.Company: + if (TmdbCompanyID != foreignId) + { + TmdbCompanyID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + case ForeignEntityType.Person: + if (TmdbPersonID != foreignId) + { + TmdbPersonID = foreignId; + updated = true; + } + if (!ForeignType.HasFlag(foreignType)) + { + ForeignType |= foreignType; + updated = true; + } + break; + } + return updated; + } + + private bool Populate(ImageData data) + { + var updated = false; + if (Width != data.Width) + { + Width = data.Width; + updated = true; + } + if (Height != data.Height) + { + Height = data.Height; + updated = true; + } + var languageCode = string.IsNullOrEmpty(data.Iso_639_1) ? null : data.Iso_639_1; + if (LanguageCode != languageCode) + { + LanguageCode = languageCode; + updated = true; + } + if (UserRating != data.VoteAverage) + { + UserRating = data.VoteAverage; + updated = true; + } + if (UserVotes != data.VoteCount) + { + UserVotes = data.VoteCount; + updated = true; + } + return updated; + } + + public int? GetForeignID(ForeignEntityType foreignType) + => foreignType switch + { + ForeignEntityType.Movie => TmdbMovieID, + ForeignEntityType.Episode => TmdbEpisodeID, + ForeignEntityType.Season => TmdbSeasonID, + ForeignEntityType.Show => TmdbShowID, + ForeignEntityType.Collection => TmdbCollectionID, + ForeignEntityType.Network => TmdbNetworkID, + ForeignEntityType.Company => TmdbCompanyID, + ForeignEntityType.Person => TmdbPersonID, + _ => null, + }; + + public IImageMetadata GetImageMetadata(bool preferred = false) + => new Image_Base(DataSourceEnum.TMDB, ImageType, ID, LocalPath, RemoteURL) + { + IsEnabled = IsEnabled, + IsPreferred = preferred, + _width = Width, + _height = Height, + }; + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Movie.cs b/Shoko.Server/Models/TMDB/TMDB_Movie.cs new file mode 100644 index 000000000..a3bc7f766 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Movie.cs @@ -0,0 +1,526 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Server; +using Shoko.Server.Utilities; +using TMDbLib.Objects.Movies; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// The Movie DataBase (TMDB) Movie Database Model. +/// </summary> +public class TMDB_Movie : TMDB_Base<int>, IEntityMetadata, IMovie +{ + #region Properties + + /// <summary> + /// IEntityMetadata.Id. + /// </summary> + public override int Id => TmdbMovieID; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_MovieID { get; set; } + + /// <summary> + /// TMDB Movie ID. + /// </summary> + public int TmdbMovieID { get; set; } + + /// <summary> + /// TMDB Collection ID, if the movie is part of a collection. + /// </summary> + public int? TmdbCollectionID { get; set; } + + /// <summary> + /// Linked Imdb movie ID. + /// </summary> + /// <remarks> + /// Will be <code>null</code> if not linked. Will be <code>0</code> if no + /// Imdb link is found in TMDB. Otherwise, it will be the Imdb movie ID. + /// </remarks> + public string? ImdbMovieID { get; set; } + + /// <summary> + /// The default poster path. Used to determine the default poster for the + /// movie. + /// </summary> + public string PosterPath { get; set; } = string.Empty; + + /// <summary> + /// The default backdrop path. Used to determine the default backdrop for + /// the movie. + /// </summary> + public string BackdropPath { get; set; } = string.Empty; + + /// <summary> + /// The english title of the movie, used as a fallback for when no title + /// is available in the preferred language. + /// </summary> + public string EnglishTitle { get; set; } = string.Empty; + + /// <summary> + /// The english overview, used as a fallback for when no overview is + /// available in the preferred language. + /// </summary> + public string EnglishOverview { get; set; } = string.Empty; + + /// <summary> + /// Original title in the original language. + /// </summary> + public string OriginalTitle { get; set; } = string.Empty; + + /// <summary> + /// The original language this movie was shot in, just as a title language + /// enum instead. + /// </summary> + public TitleLanguage OriginalLanguage + { + get => string.IsNullOrEmpty(OriginalLanguageCode) ? TitleLanguage.None : OriginalLanguageCode.GetTitleLanguage(); + } + + /// <summary> + /// The original language this movie was shot in. + /// </summary> + public string OriginalLanguageCode { get; set; } = string.Empty; + + /// <summary> + /// Indicates the movie is restricted to an age group above the legal age, + /// because it's a pornography. + /// </summary> + public bool IsRestricted { get; set; } + + /// <summary> + /// Indicates the entry is not truly a movie, including but not limited to + /// the types: + /// + /// - official compilations, + /// - best of, + /// - filmed sport events, + /// - music concerts, + /// - plays or stand-up show, + /// - fitness video, + /// - health video, + /// - live movie theater events (art, music), + /// - and how-to DVDs, + /// + /// among others. + /// </summary> + public bool IsVideo { get; set; } + + /// <summary> + /// Genres. + /// </summary> + public List<string> Genres { get; set; } = []; + + /// <summary> + /// Content ratings for different countries for this movie. + /// </summary> + public List<TMDB_ContentRating> ContentRatings { get; set; } = []; + + /// <summary> + /// Movie run-time in minutes. + /// </summary> + public int? RuntimeMinutes + { + get => Runtime.HasValue ? (int)Math.Floor(Runtime.Value.TotalMinutes) : null; + set => Runtime = value.HasValue ? TimeSpan.FromMinutes(value.Value) : null; + } + + /// <summary> + /// Movie run-time. + /// </summary> + public TimeSpan? Runtime { get; set; } + + /// <summary> + /// Average user rating across all <see cref="UserVotes"/>. + /// </summary> + public double UserRating { get; set; } + + /// <summary> + /// Number of users that cast a vote for a rating of this movie. + /// </summary> + /// <value></value> + public int UserVotes { get; set; } + + /// <summary> + /// When the movie aired, or when it will air in the future if it's known. + /// </summary> + public DateOnly? ReleasedAt { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + + #region Constructors + + /// <summary> + /// Constructor for NHibernate to work correctly while hydrating the rows + /// from the database. + /// </summary> + public TMDB_Movie() { } + + /// <summary> + /// Constructor to create a new movie in the provider. + /// </summary> + /// <param name="movieId">The TMDB Movie id.</param> + public TMDB_Movie(int movieId) + { + TmdbMovieID = movieId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + + #region Methods + + /// <summary> + /// Populate the fields from the raw data. + /// </summary> + /// <param name="movie">The raw TMDB Movie object.</param> + /// <param name="crLanguages">Content rating languages.</param> + /// <returns>True if any of the fields have been updated.</returns> + public bool Populate(Movie movie, HashSet<TitleLanguage>? crLanguages) + { + var translation = movie.Translations.Translations.FirstOrDefault(translation => translation.Iso_639_1 == "en"); + var updatedList = new[] + { + UpdateProperty(PosterPath, movie.PosterPath, v => PosterPath = v), + UpdateProperty(BackdropPath, movie.BackdropPath, v => BackdropPath = v), + UpdateProperty(TmdbCollectionID, movie.BelongsToCollection?.Id, v => TmdbCollectionID = v), + UpdateProperty(EnglishTitle, !string.IsNullOrEmpty(translation?.Data.Name) ? translation.Data.Name : movie.Title, v => EnglishTitle = v), + UpdateProperty(EnglishOverview, !string.IsNullOrEmpty(translation?.Data.Overview) ? translation.Data.Overview : movie.Overview, v => EnglishOverview = v), + UpdateProperty(OriginalTitle, movie.OriginalTitle, v => OriginalTitle = v), + UpdateProperty(OriginalLanguageCode, movie.OriginalLanguage, v => OriginalLanguageCode = v), + UpdateProperty(IsRestricted, movie.Adult, v => IsRestricted = v), + UpdateProperty(IsVideo, movie.Video, v => IsVideo = v), + UpdateProperty(Genres, movie.GetGenres(), v => Genres = v, (a, b) => string.Equals(string.Join("|", a), string.Join("|", b))), + UpdateProperty( + ContentRatings, + movie.ReleaseDates.Results + .Where(releaseDate => releaseDate.ReleaseDates.Any(r => !string.IsNullOrEmpty(r.Certification))) + .Select(releaseDate => new TMDB_ContentRating(releaseDate.Iso_3166_1, releaseDate.ReleaseDates.Last(r => !string.IsNullOrEmpty(r.Certification)).Certification)) + .WhereInLanguages(crLanguages?.Append(TitleLanguage.EnglishAmerican).ToHashSet()) + .OrderBy(c => c.CountryCode) + .ToList(), + v => ContentRatings = v, + (a, b) => string.Equals(string.Join(",", a.Select(a1 => a1.ToString())), string.Join(",", b.Select(b1 => b1.ToString()))) + ), + UpdateProperty(Runtime, movie.Runtime.HasValue ? TimeSpan.FromMinutes(movie.Runtime.Value) : null, v => Runtime = v), + UpdateProperty(UserRating, movie.VoteAverage, v => UserRating = v), + UpdateProperty(UserVotes, movie.VoteCount, v => UserVotes = v), + UpdateProperty(ReleasedAt, movie.ReleaseDate.HasValue ? DateOnly.FromDateTime(movie.ReleaseDate.Value) : null, v => ReleasedAt = v), + }; + + return updatedList.Any(updated => updated); + } + + /// <summary> + /// Get the preferred title using the preferred series title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback title if no title was found in + /// any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all movie titles if they're + /// already cached from a previous call to <seealso cref="GetAllTitles"/>. + /// </param> + /// <returns>The preferred movie title, or null if no preferred title was + /// found.</returns> + public TMDB_Title? GetPreferredTitle(bool useFallback = true, bool force = false) + { + var titles = GetAllTitles(force); + + foreach (var preferredLanguage in Languages.PreferredNamingLanguages) + { + if (preferredLanguage.Language == TitleLanguage.Main) + return new(ForeignEntityType.Movie, TmdbMovieID, EnglishTitle, "en", "US"); + + var title = titles.GetByLanguage(preferredLanguage.Language); + if (title != null) + return title; + } + + return useFallback ? new(ForeignEntityType.Movie, TmdbMovieID, EnglishTitle, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all titles for the movie, so we won't have to hit + /// the database twice to get all titles _and_ the preferred title. + /// </summary> + private IReadOnlyList<TMDB_Title>? _allTitles = null; + + /// <summary> + /// Get all titles for the movie. + /// </summary> + /// <param name="force">Forcefully re-fetch all movie titles if they're + /// already cached from a previous call. </param> + /// <returns>All titles for the movie.</returns> + public IReadOnlyList<TMDB_Title> GetAllTitles(bool force = false) => force + ? _allTitles = RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Movie, TmdbMovieID) + : _allTitles ??= RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Movie, TmdbMovieID); + + /// <summary> + /// Get the preferred overview using the preferred episode title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback overview if no overview was + /// found in any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all movie overviews if they're + /// already cached from a previous call to + /// <seealso cref="GetAllOverviews"/>. + /// </param> + /// <returns>The preferred movie overview, or null if no preferred overview + /// was found.</returns> + public TMDB_Overview? GetPreferredOverview(bool useFallback = true, bool force = false) + { + var overviews = GetAllOverviews(force); + + foreach (var preferredLanguage in Languages.PreferredDescriptionNamingLanguages) + { + var overview = overviews.GetByLanguage(preferredLanguage.Language); + if (overview != null) + return overview; + } + + return useFallback ? new(ForeignEntityType.Movie, TmdbMovieID, EnglishOverview, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all overviews for the movie, so we won't have to hit + /// the database twice to get all overviews _and_ the preferred overview. + /// </summary> + private IReadOnlyList<TMDB_Overview>? _allOverviews = null; + + /// <summary> + /// Get all overviews for the movie. + /// </summary> + /// <param name="force">Forcefully re-fetch all movie overviews if they're + /// already cached from a previous call.</param> + /// <returns>All overviews for the movie.</returns> + public IReadOnlyList<TMDB_Overview> GetAllOverviews(bool force = false) => force + ? _allOverviews = RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Movie, TmdbMovieID) + : _allOverviews ??= RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Movie, TmdbMovieID); + + public TMDB_Image? DefaultPoster => RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(PosterPath, ImageEntityType.Poster); + + public TMDB_Image? DefaultBackdrop => RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(BackdropPath, ImageEntityType.Backdrop); + + /// <summary> + /// Get all images for the movie, or all images for the given + /// <paramref name="entityType"/> provided for the movie. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the movie. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbMovieIDAndType(TmdbMovieID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbMovieID(TmdbMovieID); + + /// <summary> + /// Get all images for the movie, or all images for the given + /// <paramref name="entityType"/> provided for the movie. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <param name="preferredImages">The preferred images.</param> + /// <returns>A read-only list of images that are linked to the movie. + /// </returns> + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + /// <summary> + /// Get all TMDB company cross-references linked to the movie. + /// </summary> + /// <returns>All TMDB company cross-references linked to the movie. + /// </returns> + public IReadOnlyList<TMDB_Company_Entity> TmdbCompanyCrossReferences => + RepoFactory.TMDB_Company_Entity.GetByTmdbEntityTypeAndID(ForeignEntityType.Movie, TmdbMovieID); + + /// <summary> + /// Get all TMDB companies linked to the movie. + /// </summary> + /// <returns>All TMDB companies linked to the movie.</returns> + public IReadOnlyList<TMDB_Company> GetTmdbCompanies() => + TmdbCompanyCrossReferences + .Select(xref => xref.GetTmdbCompany()) + .WhereNotNull() + .ToList(); + + /// <summary> + /// Get all cast members that have worked on this movie. + /// </summary> + /// <returns>All cast members that have worked on this movie.</returns> + public IReadOnlyList<TMDB_Movie_Cast> Cast => + RepoFactory.TMDB_Movie_Cast.GetByTmdbMovieID(TmdbMovieID); + + /// <summary> + /// Get all crew members that have worked on this movie. + /// </summary> + /// <returns>All crew members that have worked on this movie.</returns> + public IReadOnlyList<TMDB_Movie_Crew> Crew => + RepoFactory.TMDB_Movie_Crew.GetByTmdbMovieID(TmdbMovieID); + + /// <summary> + /// Get the TMDB movie collection linked to the movie from the local + /// database, if any. You need to have movie collections enabled in the + /// settings file for this to be populated. + /// </summary> + /// <returns>The TMDB movie collection if found, or null.</returns> + public TMDB_Collection? TmdbCollection => TmdbCollectionID.HasValue + ? RepoFactory.TMDB_Collection.GetByTmdbCollectionID(TmdbCollectionID.Value) + : null; + + /// <summary> + /// Get AniDB/TMDB cross-references for the movie. + /// </summary> + /// <returns>A read-only list of AniDB/TMDB cross-references for the movie.</returns> + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> CrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByTmdbMovieID(TmdbMovieID); + + /// <summary> + /// Get all file cross-references associated with the movie. + /// </summary> + /// <returns>A read-only list of file cross-references associated with the + /// movie.</returns> + public IReadOnlyList<SVR_CrossRef_File_Episode> FileCrossReferences => + CrossReferences + .DistinctBy(xref => xref.AnidbEpisodeID) + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID)) + .WhereNotNull() + .ToList(); + + #endregion + + #region IEntityMetadata + + ForeignEntityType IEntityMetadata.Type => ForeignEntityType.Movie; + + DataSourceEnum IEntityMetadata.DataSource => DataSourceEnum.TMDB; + + TitleLanguage? IEntityMetadata.OriginalLanguage => OriginalLanguage; + + #endregion + + #region IMetadata + + DataSourceEnum IMetadata.Source => DataSourceEnum.TMDB; + + int IMetadata<int>.ID => Id; + + #endregion + + #region IWithTitles + + string IWithTitles.DefaultTitle => EnglishTitle; + + string IWithTitles.PreferredTitle => GetPreferredTitle()!.Value; + + IReadOnlyList<AnimeTitle> IWithTitles.Titles => GetAllTitles() + .Select(title => new AnimeTitle() + { + Language = title.Language, + LanguageCode = title.LanguageCode, + Source = DataSourceEnum.TMDB, + Title = title.Value, + Type = TitleType.Official, + }) + .ToList(); + + #endregion + + #region IWithDescriptions + + string IWithDescriptions.DefaultDescription => EnglishOverview; + + string IWithDescriptions.PreferredDescription => GetPreferredOverview()!.Value; + + IReadOnlyList<TextDescription> IWithDescriptions.Descriptions => GetAllOverviews() + .Select(overview => new TextDescription() + { + CountryCode = overview.CountryCode, + Language = overview.Language, + LanguageCode = overview.LanguageCode, + Source = DataSourceEnum.TMDB, + Value = overview.Value, + }) + .ToList(); + + #endregion + + #region IWithImages + + IImageMetadata? IWithImages.GetPreferredImageForType(ImageEntityType entityType) => null; + + IReadOnlyList<IImageMetadata> IWithImages.GetImages(ImageEntityType? entityType) => GetImages(entityType); + + #endregion + + #region IMovie + + IReadOnlyList<int> IMovie.ShokoEpisodeIDs => CrossReferences + .Select(xref => xref.AnimeEpisode?.AnimeEpisodeID) + .WhereNotNull() + .ToList(); + + IReadOnlyList<int> IMovie.ShokoSeriesIDs => CrossReferences + .Select(xref => xref.AnimeSeries?.AnimeSeriesID) + .WhereNotNull() + .ToList(); + + DateTime? IMovie.ReleaseDate => ReleasedAt?.ToDateTime(); + + double IMovie.Rating => UserRating; + + IImageMetadata? IMovie.DefaultPoster => DefaultPoster; + + IReadOnlyList<IShokoEpisode> IMovie.ShokoEpisodes => CrossReferences + .Select(xref => xref.AnimeEpisode) + .WhereNotNull() + .ToList(); + + IReadOnlyList<IShokoSeries> IMovie.ShokoSeries => CrossReferences + .Select(xref => xref.AnimeSeries) + .WhereNotNull() + .ToList(); + + IReadOnlyList<IRelatedMetadata<ISeries>> IMovie.RelatedSeries => []; + + IReadOnlyList<IRelatedMetadata<IMovie>> IMovie.RelatedMovies => []; + + IReadOnlyList<IVideoCrossReference> IMovie.CrossReferences => CrossReferences + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID)) + .ToList(); + + IReadOnlyList<IVideo> IMovie.VideoList => CrossReferences + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID)) + .Select(xref => xref.VideoLocal) + .WhereNotNull() + .ToList(); + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Movie_Cast.cs b/Shoko.Server/Models/TMDB/TMDB_Movie_Cast.cs new file mode 100644 index 000000000..a55a9883f --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Movie_Cast.cs @@ -0,0 +1,24 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Movie_Cast : TMDB_Cast +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_Movie_CastID { get; set; } + + /// <summary> + /// TMDB Movie ID for the movie this role belongs to. + /// </summary> + public int TmdbMovieID { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Movie_Crew.cs b/Shoko.Server/Models/TMDB/TMDB_Movie_Crew.cs new file mode 100644 index 000000000..922d0b635 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Movie_Crew.cs @@ -0,0 +1,27 @@ + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// Crew member for a movie. +/// </summary> +public class TMDB_Movie_Crew : TMDB_Crew +{ + #region Properties + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_Movie_CrewID { get; set; } + + /// <summary> + /// TMDB Movie ID for the movie this job belongs to. + /// </summary> + public int TmdbMovieID { get; set; } + + #endregion + + #region Methods + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Person.cs b/Shoko.Server/Models/TMDB/TMDB_Person.cs new file mode 100644 index 000000000..58b20641f --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Person.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Server; +using Shoko.Server.Utilities; +using TMDbLib.Objects.People; + +using PersonGender = Shoko.Server.Providers.TMDB.PersonGender; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// The Movie DataBase (TMDB) Person Database Model. +/// </summary> +public class TMDB_Person : TMDB_Base<int>, IEntityMetadata +{ + #region Properties + + /// <summary> + /// IEntityMetadata.Id. + /// </summary> + public override int Id => TmdbPersonID; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_PersonID { get; set; } + + /// <summary> + /// TMDB Person ID for the cast member. + /// </summary> + public int TmdbPersonID { get; set; } + + /// <summary> + /// The official(?) English form of the person's name. + /// </summary> + public string EnglishName { get; set; } = string.Empty; + + /// <summary> + /// The english biography, used as a fallback for when no biography is + /// available in the preferred language. + /// </summary> + public string EnglishBiography { get; set; } = string.Empty; + + /// <summary> + /// All known aliases for the person. + /// </summary> + public List<string> Aliases { get; set; } = []; + + /// <summary> + /// The person's gender, if known. + /// </summary> + public PersonGender Gender { get; set; } + + /// <summary> + /// Indicates that all the works this person have produced or been part of + /// has been restricted to an age group above the legal age, so pornographic + /// works. + /// </summary> + public bool IsRestricted { get; set; } + + /// <summary> + /// The date of birth, if known. + /// </summary> + public DateOnly? BirthDay { get; set; } + + /// <summary> + /// The date of death, if the person is dead and we know the date. + /// </summary> + public DateOnly? DeathDay { get; set; } + + /// <summary> + /// Their place of birth, if known. + /// </summary> + public string? PlaceOfBirth { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + + #region Constructors + + /// <summary> + /// Constructor for NHibernate to work correctly while hydrating the rows + /// from the database. + /// </summary> + public TMDB_Person() { } + + /// <summary> + /// Constructor to create a new person in the provider. + /// </summary> + /// <param name="personId">The TMDB Person id.</param> + public TMDB_Person(int personId) + { + TmdbPersonID = personId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + + #region Methods + + /// <summary> + /// Populate the fields from the raw data. + /// </summary> + /// <param name="person">The raw TMDB Person object.</param> + /// <returns>True if any of the fields have been updated.</returns> + public bool Populate(Person person) + { + var translation = person.Translations?.Translations.FirstOrDefault(translation => translation.Iso_639_1 == "en"); + + var updates = new[] + { + UpdateProperty(EnglishName, person.Name, v => EnglishName = v), + UpdateProperty(EnglishBiography, !string.IsNullOrEmpty(translation?.Data.Overview) ? translation.Data.Overview : person.Biography, v => EnglishBiography = v), + UpdateProperty(Aliases, person.AlsoKnownAs, v => Aliases = v, (a, b) => string.Equals(string.Join("|", a),string.Join("|", b))), + UpdateProperty(IsRestricted, person.Adult, v => IsRestricted = v), + UpdateProperty(BirthDay, person.Birthday.HasValue ? DateOnly.FromDateTime(person.Birthday.Value) : null, v => BirthDay = v), + UpdateProperty(DeathDay, person.Deathday.HasValue ? DateOnly.FromDateTime(person.Deathday.Value) : null, v => DeathDay = v), + UpdateProperty(PlaceOfBirth, string.IsNullOrEmpty(person.PlaceOfBirth) ? null : person.PlaceOfBirth, v => PlaceOfBirth = v), + }; + + return updates.Any(updated => updated); + } + + /// <summary> + /// Get the preferred biography using the preferred episode title + /// preference from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback biography if no biography was + /// found in any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all person biographies if + /// they're already cached from a previous call to + /// <seealso cref="GetAllBiographies"/>. + /// </param> + /// <returns>The preferred person biography, or null if no preferred biography + /// was found.</returns> + public TMDB_Overview? GetPreferredBiography(bool useFallback = false, bool force = false) + { + var biographies = GetAllBiographies(force); + + foreach (var preferredLanguage in Languages.PreferredDescriptionNamingLanguages) + { + var biography = biographies.GetByLanguage(preferredLanguage.Language); + if (biography != null) + return biography; + } + + return useFallback ? new(ForeignEntityType.Person, TmdbPersonID, EnglishBiography, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all biographies for the person, so we won't have to hit + /// the database twice to get all biographies _and_ the preferred biography. + /// </summary> + private IReadOnlyList<TMDB_Overview>? _allBiographies = null; + + /// <summary> + /// Get all biographies for the person. + /// </summary> + /// <param name="force">Forcefully re-fetch all person biographies if they're + /// already cached from a previous call.</param> + /// <returns>All biographies for the person.</returns> + public IReadOnlyList<TMDB_Overview> GetAllBiographies(bool force = false) => force + ? _allBiographies = RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Person, TmdbPersonID) + : _allBiographies ??= RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Person, TmdbPersonID); + + /// <summary> + /// Get all images for the person, or all images for the given + /// <paramref name="entityType"/> provided for the person. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the person. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbPersonIDAndType(TmdbPersonID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbPersonID(TmdbPersonID); + + #endregion + + #region IEntityMetadata + + ForeignEntityType IEntityMetadata.Type => ForeignEntityType.Person; + + DataSourceEnum IEntityMetadata.DataSource => DataSourceEnum.TMDB; + + string IEntityMetadata.EnglishTitle => EnglishName; + + string IEntityMetadata.EnglishOverview => EnglishBiography; + + string? IEntityMetadata.OriginalTitle => null; + + TitleLanguage? IEntityMetadata.OriginalLanguage => null; + + string? IEntityMetadata.OriginalLanguageCode => null; + + // Technically not untrue. Though this is more of a joke mapping than anything. + DateOnly? IEntityMetadata.ReleasedAt => BirthDay; + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Season.cs b/Shoko.Server/Models/TMDB/TMDB_Season.cs new file mode 100644 index 000000000..fda72fd57 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Season.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Server; +using Shoko.Server.Utilities; +using TMDbLib.Objects.TvShows; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// The Movie DataBase (TMDB) Season Database Model. +/// </summary> +public class TMDB_Season : TMDB_Base<int>, IEntityMetadata +{ + #region Properties + + /// <summary> + /// IEntityMetadata.Id + /// </summary> + public override int Id => TmdbSeasonID; + + /// <summary> + /// Local ID. + /// </summary> + public int TMDB_SeasonID { get; set; } + + /// <summary> + /// TMDB Show ID. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// TMDB Season ID. + /// </summary> + public int TmdbSeasonID { get; set; } + + /// <summary> + /// The default poster path. Used to determine the default poster for the show. + /// </summary> + public string PosterPath { get; set; } = string.Empty; + + /// <summary> + /// The english title of the season, used as a fallback for when no title + /// is available in the preferred language. + /// </summary> + public string EnglishTitle { get; set; } = string.Empty; + + /// <summary> + /// The english overview, used as a fallback for when no overview is + /// available in the preferred language. + /// </summary> + public string EnglishOverview { get; set; } = string.Empty; + + /// <summary> + /// Number of episodes within the season. + /// </summary> + public int EpisodeCount { get; set; } + + /// <summary> + /// Season number for default ordering. + /// </summary> + public int SeasonNumber { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #endregion + + #region Constructors + + /// <summary> + /// Constructor for NHibernate to work correctly while hydrating the rows + /// from the database. + /// </summary> + public TMDB_Season() { } + + /// <summary> + /// Constructor to create a new season in the provider. + /// </summary> + /// <param name="seasonId">The TMDB Season id.</param> + public TMDB_Season(int seasonId) + { + TmdbSeasonID = seasonId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + + #region Methods + + /// <summary> + /// Populate the fields from the raw data. + /// </summary> + /// <param name="show">The raw TMDB Tv Show object.</param> + /// <param name="season">The raw TMDB Tv Season object.</param> + /// <returns>True if any of the fields have been updated.</returns> + public bool Populate(TvShow show, TvSeason season) + { + var translation = season.Translations.Translations.FirstOrDefault(translation => translation.Iso_639_1 == "en"); + + var updates = new[] + { + UpdateProperty(PosterPath, season.PosterPath, v => PosterPath = v), + UpdateProperty(TmdbSeasonID, season.Id!.Value, v => TmdbSeasonID = v), + UpdateProperty(TmdbShowID, show.Id, v => TmdbShowID = v), + UpdateProperty(EnglishTitle, !string.IsNullOrEmpty(translation?.Data.Name) ? translation.Data.Name : season.Name, v => EnglishTitle = v), + UpdateProperty(EnglishOverview, !string.IsNullOrEmpty(translation?.Data.Overview) ? translation.Data.Overview : season.Overview, v => EnglishOverview = v), + UpdateProperty(EpisodeCount, season.Episodes.Count, v => EpisodeCount = v), + UpdateProperty(SeasonNumber, season.SeasonNumber, v => SeasonNumber = v), + }; + + return updates.Any(updated => updated); + } + + /// <summary> + /// Get the preferred title using the preferred series title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback title if no title was found in + /// any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all season titles if they're + /// already cached from a previous call to <seealso cref="GetAllTitles"/>. + /// </param> + /// <returns>The preferred season title, or null if no preferred title was + /// found.</returns> + public TMDB_Title? GetPreferredTitle(bool useFallback = true, bool force = false) + { + var titles = GetAllTitles(force); + + foreach (var preferredLanguage in Languages.PreferredNamingLanguages) + { + if (preferredLanguage.Language == TitleLanguage.Main) + return new(ForeignEntityType.Season, TmdbSeasonID, EnglishTitle, "en", "US"); + + var title = titles.GetByLanguage(preferredLanguage.Language); + if (title != null) + return title; + } + + return useFallback ? new(ForeignEntityType.Season, TmdbSeasonID, EnglishTitle, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all titles for the season, so we won't have to hit + /// the database twice to get all titles _and_ the preferred title. + /// </summary> + private IReadOnlyList<TMDB_Title>? _allTitles = null; + + /// <summary> + /// Get all titles for the season. + /// </summary> + /// <param name="force">Forcefully re-fetch all season titles if they're + /// already cached from a previous call. </param> + /// <returns>All titles for the season.</returns> + public IReadOnlyList<TMDB_Title> GetAllTitles(bool force = false) => force + ? _allTitles = RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Season, TmdbSeasonID) + : _allTitles ??= RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Season, TmdbSeasonID); + + public TMDB_Overview? GetPreferredOverview(bool useFallback = true, bool force = false) + { + var overviews = GetAllOverviews(force); + + foreach (var preferredLanguage in Languages.PreferredDescriptionNamingLanguages) + { + var overview = overviews.GetByLanguage(preferredLanguage.Language); + if (overview != null) + return overview; + } + + return useFallback ? new(ForeignEntityType.Season, TmdbSeasonID, EnglishOverview, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all overviews for the season, so we won't have to + /// hit the database twice to get all overviews _and_ the preferred + /// overview. + /// </summary> + private IReadOnlyList<TMDB_Overview>? _allOverviews = null; + + /// <summary> + /// Get all overviews for the season. + /// </summary> + /// <param name="force">Forcefully re-fetch all season overviews if they're + /// already cached from a previous call.</param> + /// <returns>All overviews for the season.</returns> + public IReadOnlyList<TMDB_Overview> GetAllOverviews(bool force = false) => force + ? _allOverviews = RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Season, TmdbSeasonID) + : _allOverviews ??= RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Season, TmdbSeasonID); + + /// <summary> + /// Get all images for the season, or all images for the given + /// <paramref name="entityType"/> provided for the season. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the season. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbSeasonIDAndType(TmdbSeasonID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbSeasonID(TmdbSeasonID); + + /// <summary> + /// Get all cast members that have worked on this season. + /// </summary> + /// <returns>All cast members that have worked on this season.</returns> + public IReadOnlyList<TMDB_Season_Cast> Cast => + RepoFactory.TMDB_Episode_Cast.GetByTmdbSeasonID(TmdbSeasonID) + .GroupBy(cast => new { cast.TmdbPersonID, cast.CharacterName, cast.IsGuestRole }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + return new TMDB_Season_Cast() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + TmdbSeasonID = firstEpisode.TmdbSeasonID, + IsGuestRole = firstEpisode.IsGuestRole, + CharacterName = firstEpisode.CharacterName, + Ordering = firstEpisode.Ordering, + EpisodeCount = episodes.Count, + }; + }) + .OrderBy(crew => crew.Ordering) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + /// <summary> + /// Get all crew members that have worked on this season. + /// </summary> + /// <returns>All crew members that have worked on this season.</returns> + public IReadOnlyList<TMDB_Season_Crew> Crew => + RepoFactory.TMDB_Episode_Crew.GetByTmdbSeasonID(TmdbSeasonID) + .GroupBy(cast => new { cast.TmdbPersonID, cast.Department, cast.Job }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + return new TMDB_Season_Crew() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + TmdbSeasonID = firstEpisode.TmdbSeasonID, + Department = firstEpisode.Department, + Job = firstEpisode.Job, + EpisodeCount = episodes.Count, + }; + }) + .OrderBy(crew => crew.Department) + .OrderBy(crew => crew.Job) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + /// <summary> + /// Get the TMDB show associated with the season, or null if the show have + /// been purged from the local database for whatever reason. + /// </summary> + /// <returns>The TMDB show, or null.</returns> + public TMDB_Show? TmdbShow => + RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all local TMDB episodes associated with the season, or an empty list + /// if the season have been purged from the local database for whatever + /// reason. + /// </summary> + /// <returns>The TMDB episodes.</returns> + public IReadOnlyList<TMDB_Episode> TmdbEpisodes => + RepoFactory.TMDB_Episode.GetByTmdbSeasonID(TmdbSeasonID); + + #endregion + + #region IEntityMetadata + + ForeignEntityType IEntityMetadata.Type => ForeignEntityType.Season; + + DataSourceEnum IEntityMetadata.DataSource => DataSourceEnum.TMDB; + + string? IEntityMetadata.OriginalTitle => null; + + TitleLanguage? IEntityMetadata.OriginalLanguage => null; + + string? IEntityMetadata.OriginalLanguageCode => null; + + DateOnly? IEntityMetadata.ReleasedAt => null; + + #endregion +} diff --git a/Shoko.Server/Models/TMDB/TMDB_Show.cs b/Shoko.Server/Models/TMDB/TMDB_Show.cs new file mode 100644 index 000000000..983423667 --- /dev/null +++ b/Shoko.Server/Models/TMDB/TMDB_Show.cs @@ -0,0 +1,608 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Server; +using Shoko.Server.Utilities; +using TMDbLib.Objects.TvShows; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +/// <summary> +/// The Movie DataBase (TMDB) Show Database Model. +/// </summary> +public class TMDB_Show : TMDB_Base<int>, IEntityMetadata, ISeries +{ + #region Properties + + /// <summary> + /// IEntityMetadata.Id + /// </summary> + public override int Id => TmdbShowID; + + /// <summary> + /// Local id. + /// </summary> + public int TMDB_ShowID { get; } + + /// <summary> + /// TMDB Show Id. + /// </summary> + public int TmdbShowID { get; set; } + + /// <summary> + /// Linked TvDB Show ID. + /// </summary> + /// <remarks> + /// Will be <code>null</code> if not linked. Will be <code>0</code> if no + /// TvDB link is found in TMDB. Otherwise it will be the TvDB Show ID. + /// </remarks> + public int? TvdbShowID { get; set; } + + /// <summary> + /// The default poster path. Used to determine the default poster for the + /// show. + /// </summary> + public string PosterPath { get; set; } = string.Empty; + + /// <summary> + /// The default backdrop path. Used to determine the default backdrop for + /// the show. + /// </summary> + public string BackdropPath { get; set; } = string.Empty; + + /// <summary> + /// The english title of the show, used as a fallback for when no title is + /// available in the preferred language. + /// </summary> + public string EnglishTitle { get; set; } = string.Empty; + + /// <summary> + /// The english overview, used as a fallback for when no overview is + /// available in the preferred language. + /// </summary> + public string EnglishOverview { get; set; } = string.Empty; + + /// <summary> + /// Original title in the original language. + /// </summary> + public string OriginalTitle { get; set; } = string.Empty; + + /// <summary> + /// The original language this show was shot in, just as a title language + /// enum instead. + /// </summary> + public TitleLanguage OriginalLanguage + { + get => string.IsNullOrEmpty(OriginalLanguageCode) ? TitleLanguage.None : OriginalLanguageCode.GetTitleLanguage(); + } + + /// <summary> + /// The original language this show was shot in. + /// </summary> + public string OriginalLanguageCode { get; set; } = string.Empty; + + /// <summary> + /// Indicates the show is restricted to an age group above the legal age, + /// because it's a pornography. + /// </summary> + public bool IsRestricted { get; set; } + + /// <summary> + /// Genres. + /// </summary> + public List<string> Genres { get; set; } = []; + + /// <summary> + /// Content ratings for different countries for this show. + /// </summary> + public List<TMDB_ContentRating> ContentRatings { get; set; } = []; + + /// <summary> + /// Number of episodes using the default ordering. + /// </summary> + public int EpisodeCount { get; set; } + + /// <summary> + /// Number of seasons using the default ordering. + /// </summary> + public int SeasonCount { get; set; } + + /// <summary> + /// Number of alternate ordering schemas available for this show. + /// </summary> + public int AlternateOrderingCount { get; set; } + + /// <summary> + /// Average user rating across all <see cref="UserVotes"/>. + /// </summary> + public double UserRating { get; set; } + + /// <summary> + /// Number of users that cast a vote for a rating of this show. + /// </summary> + /// <value></value> + public int UserVotes { get; set; } + + /// <summary> + /// First aired episode date. + /// </summary> + public DateOnly? FirstAiredAt { get; set; } + + /// <summary> + /// Last aired episode date for the show, or null if the show is still + /// running. + /// </summary> + public DateOnly? LastAiredAt { get; set; } + + /// <summary> + /// When the metadata was first downloaded. + /// </summary> + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the metadata was last synchronized with the remote. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + #region Settings + + /// <summary> + /// The ID of the preferred alternate ordering to use when not specified in the API. + /// </summary> + public string? PreferredAlternateOrderingID { get; set; } + + #endregion + + #endregion + + #region Constructors + + /// <summary> + /// Constructor for NHibernate to work correctly while hydrating the rows + /// from the database. + /// </summary> + public TMDB_Show() { } + + /// <summary> + /// Constructor to create a new show in the provider. + /// </summary> + /// <param name="showId">The TMDB show id.</param> + public TMDB_Show(int showId) + { + TmdbShowID = showId; + CreatedAt = DateTime.Now; + LastUpdatedAt = CreatedAt; + } + + #endregion + + #region Methods + + /// <summary> + /// Populate the fields from the raw data. + /// </summary> + /// <param name="show">The raw TMDB Tv Show object.</param> + /// <param name="crLanguages">Content rating languages.</param> + /// <returns>True if any of the fields have been updated.</returns> + public bool Populate(TvShow show, HashSet<TitleLanguage>? crLanguages) + { + // Don't trust 'show.Name' for the English title since it will fall-back + // to the original language if there is no title in English. + var translation = show.Translations.Translations.FirstOrDefault(translation => translation.Iso_639_1 == "en"); + var updates = new[] + { + UpdateProperty(PosterPath, show.PosterPath, v => PosterPath = v), + UpdateProperty(BackdropPath, show.BackdropPath, v => BackdropPath = v), + UpdateProperty(OriginalTitle, show.OriginalName, v => OriginalTitle = v), + UpdateProperty(OriginalLanguageCode, show.OriginalLanguage, v => OriginalLanguageCode = v), + UpdateProperty(EnglishTitle, !string.IsNullOrEmpty(translation?.Data.Name) ? translation.Data.Name : show.Name, v => EnglishTitle = v), + UpdateProperty(EnglishOverview, !string.IsNullOrEmpty(translation?.Data.Overview) ? translation.Data.Overview : show.Overview, v => EnglishOverview = v), + UpdateProperty(IsRestricted, show.Adult, v => IsRestricted = v), + UpdateProperty(Genres, show.GetGenres(), v => Genres = v, (a, b) => string.Equals(string.Join("|", a), string.Join("|", b))), + UpdateProperty( + ContentRatings, + show.ContentRatings.Results + .Select(rating => new TMDB_ContentRating(rating.Iso_3166_1, rating.Rating)) + .WhereInLanguages(crLanguages?.Append(TitleLanguage.EnglishAmerican).ToHashSet()) + .OrderBy(c => c.CountryCode) + .ToList(), + v => ContentRatings = v, + (a, b) => string.Equals(string.Join(",", a.Select(a1 => a1.ToString())), string.Join(",", b.Select(b1 => b1.ToString()))) + ), + UpdateProperty(EpisodeCount, show.NumberOfEpisodes, v => EpisodeCount = v), + UpdateProperty(SeasonCount, show.NumberOfSeasons, v => SeasonCount = v), + UpdateProperty(AlternateOrderingCount, show.EpisodeGroups?.Results.Count ?? AlternateOrderingCount, v => AlternateOrderingCount = v), + UpdateProperty(UserRating, show.VoteAverage, v => UserRating = v), + UpdateProperty(UserVotes, show.VoteCount, v => UserVotes = v), + UpdateProperty(FirstAiredAt, show.FirstAirDate.HasValue ? DateOnly.FromDateTime(show.FirstAirDate.Value) : null, v => FirstAiredAt = v), + UpdateProperty(LastAiredAt, !string.IsNullOrEmpty(show.Status) && show.Status.Equals("Ended", StringComparison.InvariantCultureIgnoreCase) && show.LastAirDate.HasValue ? DateOnly.FromDateTime(show.LastAirDate.Value) : null, v => LastAiredAt = v), + }; + + return updates.Any(updated => updated); + } + + /// <summary> + /// Get the preferred title using the preferred series title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback title if no title was found in + /// any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all show titles if they're + /// already cached from a previous call to <seealso cref="GetAllTitles"/>. + /// </param> + /// <returns>The preferred show title, or null if no preferred title was + /// found.</returns> + public TMDB_Title? GetPreferredTitle(bool useFallback = true, bool force = false) + { + var titles = GetAllTitles(force); + + foreach (var preferredLanguage in Languages.PreferredNamingLanguages) + { + if (preferredLanguage.Language == TitleLanguage.Main) + return new(ForeignEntityType.Show, TmdbShowID, EnglishTitle, "en", "US"); + + var title = titles.GetByLanguage(preferredLanguage.Language); + if (title != null) + return title; + } + + return useFallback ? new(ForeignEntityType.Show, TmdbShowID, EnglishTitle, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all titles for the show, so we won't have to hit the + /// database twice to get all titles _and_ the preferred title. + /// </summary> + private IReadOnlyList<TMDB_Title>? _allTitles = null; + + /// <summary> + /// Get all titles for the show. + /// </summary> + /// <param name="force">Forcefully re-fetch all show titles if they're + /// already cached from a previous call.</param> + /// <returns>All titles for the show.</returns> + public IReadOnlyList<TMDB_Title> GetAllTitles(bool force = false) => force + ? _allTitles = RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Show, TmdbShowID) + : _allTitles ??= RepoFactory.TMDB_Title.GetByParentTypeAndID(ForeignEntityType.Show, TmdbShowID); + + /// <summary> + /// Get the preferred overview using the preferred episode title preference + /// from the application settings. + /// </summary> + /// <param name="useFallback">Use a fallback overview if no overview was + /// found in any of the preferred languages.</param> + /// <param name="force">Forcefully re-fetch all episode overviews if they're + /// already cached from a previous call to + /// <seealso cref="GetAllOverviews"/>. + /// </param> + /// <returns>The preferred episode overview, or null if no preferred + /// overview was found.</returns> + public TMDB_Overview? GetPreferredOverview(bool useFallback = true, bool force = false) + { + var overviews = GetAllOverviews(force); + + foreach (var preferredLanguage in Languages.PreferredDescriptionNamingLanguages) + { + var overview = overviews.GetByLanguage(preferredLanguage.Language); + if (overview != null) + return overview; + } + + return useFallback ? new(ForeignEntityType.Show, TmdbShowID, EnglishOverview, "en", "US") : null; + } + + /// <summary> + /// Cached reference to all overviews for the show, so we won't have to + /// hit the database twice to get all overviews _and_ the preferred + /// overview. + /// </summary> + private IReadOnlyList<TMDB_Overview>? _allOverviews = null; + + /// <summary> + /// Get all overviews for the show. + /// </summary> + /// <param name="force">Forcefully re-fetch all show overviews if they're + /// already cached from a previous call. </param> + /// <returns>All overviews for the show.</returns> + public IReadOnlyList<TMDB_Overview> GetAllOverviews(bool force = false) => force + ? _allOverviews = RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Show, TmdbShowID) + : _allOverviews ??= RepoFactory.TMDB_Overview.GetByParentTypeAndID(ForeignEntityType.Show, TmdbShowID); + + public TMDB_Image? DefaultPoster => RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(PosterPath, ImageEntityType.Poster); + + public TMDB_Image? DefaultBackdrop => RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(BackdropPath, ImageEntityType.Backdrop); + + /// <summary> + /// Get all images for the show, or all images for the given + /// <paramref name="entityType"/> provided for the show. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <returns>A read-only list of images that are linked to the show. + /// </returns> + public IReadOnlyList<TMDB_Image> GetImages(ImageEntityType? entityType = null) => entityType.HasValue + ? RepoFactory.TMDB_Image.GetByTmdbShowIDAndType(TmdbShowID, entityType.Value) + : RepoFactory.TMDB_Image.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all images for the show, or all images for the given + /// <paramref name="entityType"/> provided for the show. + /// </summary> + /// <param name="entityType">If set, will restrict the returned list to only + /// containing the images of the given entity type.</param> + /// <param name="preferredImages">The preferred images.</param> + /// <returns>A read-only list of images that are linked to the show. + /// </returns> + public IReadOnlyList<IImageMetadata> GetImages(ImageEntityType? entityType, IReadOnlyDictionary<ImageEntityType, IImageMetadata> preferredImages) => + GetImages(entityType) + .GroupBy(i => i.ImageType) + .SelectMany(gB => preferredImages.TryGetValue(gB.Key, out var pI) ? gB.Select(i => i.Equals(pI) ? pI : i) : gB) + .ToList(); + + /// <summary> + /// Get all TMDB company cross-references linked to the show. + /// </summary> + /// <returns>All TMDB company cross-references linked to the show.</returns> + public IReadOnlyList<TMDB_Company_Entity> TmdbCompanyCrossReferences => + RepoFactory.TMDB_Company_Entity.GetByTmdbEntityTypeAndID(ForeignEntityType.Show, TmdbShowID); + + /// <summary> + /// Get all TMDB companies linked to the show. + /// </summary> + /// <returns>All TMDB companies linked to the show.</returns> + public IReadOnlyList<TMDB_Company> TmdbCompanies => + TmdbCompanyCrossReferences + .Select(xref => xref.GetTmdbCompany()) + .WhereNotNull() + .ToList(); + + /// <summary> + /// Get all TMDB network cross-references linked to the show. + /// </summary> + /// <returns>All TMDB network cross-references linked to the show.</returns> + public IReadOnlyList<TMDB_Show_Network> TmdbNetworkCrossReferences => + RepoFactory.TMDB_Show_Network.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all TMDB networks linked to the show. + /// </summary> + /// <returns>All TMDB networks linked to the show.</returns> + public IReadOnlyList<TMDB_Network> TmdbNetworks => + TmdbNetworkCrossReferences + .Select(xref => xref.GetTmdbNetwork()) + .WhereNotNull() + .ToList(); + + /// <summary> + /// Get all cast members that have worked on this show. + /// </summary> + /// <returns>All cast members that have worked on this show.</returns> + public IReadOnlyList<TMDB_Show_Cast> Cast => + RepoFactory.TMDB_Episode_Cast.GetByTmdbShowID(TmdbShowID) + .GroupBy(cast => new { cast.TmdbPersonID, cast.CharacterName, cast.IsGuestRole }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + var seasonCount = episodes.GroupBy(a => a.TmdbSeasonID).Count(); + return new TMDB_Show_Cast() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + CharacterName = firstEpisode.CharacterName, + Ordering = firstEpisode.Ordering, + EpisodeCount = episodes.Count, + SeasonCount = seasonCount, + }; + }) + .OrderBy(crew => crew.Ordering) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + /// <summary> + /// Get all crew members that have worked on this show. + /// </summary> + /// <returns>All crew members that have worked on this show.</returns> + public IReadOnlyList<TMDB_Show_Crew> Crew => + RepoFactory.TMDB_Episode_Crew.GetByTmdbShowID(TmdbShowID) + .GroupBy(cast => new { cast.TmdbPersonID, cast.Department, cast.Job }) + .Select(group => + { + var episodes = group.ToList(); + var firstEpisode = episodes.First(); + var seasonCount = episodes.GroupBy(a => a.TmdbSeasonID).Count(); + return new TMDB_Show_Crew() + { + TmdbPersonID = firstEpisode.TmdbPersonID, + TmdbShowID = firstEpisode.TmdbShowID, + Department = firstEpisode.Department, + Job = firstEpisode.Job, + EpisodeCount = episodes.Count, + SeasonCount = seasonCount, + }; + }) + .OrderBy(crew => crew.Department) + .OrderBy(crew => crew.Job) + .OrderBy(crew => crew.TmdbPersonID) + .ToList(); + + /// <summary> + /// Get the preferred alternate ordering scheme associated with the show in + /// the local database. You need alternate ordering to be enabled in the + /// settings file for this to be populated. + /// </summary> + /// <returns>The preferred alternate ordering scheme associated with the + /// show in the local database. <see langword="null"/> if the show does not + /// have an alternate ordering scheme associated with it.</returns> + public TMDB_AlternateOrdering? PreferredAlternateOrdering => + string.IsNullOrEmpty(PreferredAlternateOrderingID) + ? null + : RepoFactory.TMDB_AlternateOrdering.GetByEpisodeGroupCollectionAndShowIDs(PreferredAlternateOrderingID, TmdbShowID); + + /// <summary> + /// Get all TMDB alternate ordering schemes associated with the show in the + /// local database. You need alternate ordering to be enabled in the + /// settings file for these to be populated. + /// </summary> + /// <returns>The list of TMDB alternate ordering schemes.</returns> + public IReadOnlyList<TMDB_AlternateOrdering> TmdbAlternateOrdering => + RepoFactory.TMDB_AlternateOrdering.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all TMDB seasons associated with the show in the local database. Or + /// an empty list if the show data have not been downloaded yet or have been + /// purged from the local database for whatever reason. + /// </summary> + /// <returns>The TMDB seasons.</returns> + public IReadOnlyList<TMDB_Season> TmdbSeasons => + RepoFactory.TMDB_Season.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get all TMDB episodes associated with the show in the local database. Or + /// an empty list if the show data have not been downloaded yet or have been + /// purged from the local database for whatever reason. + /// </summary> + /// <returns>The TMDB episodes.</returns> + public IReadOnlyList<TMDB_Episode> TmdbEpisodes => + RepoFactory.TMDB_Episode.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get AniDB/TMDB cross-references for the show. + /// </summary> + /// <returns>The cross-references.</returns> + public IReadOnlyList<CrossRef_AniDB_TMDB_Show> CrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(TmdbShowID); + + /// <summary> + /// Get AniDB/TMDB episode cross-references for the show. + /// </summary> + /// <returns>The episode cross-references.</returns> + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> EpisodeCrossReferences => + RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByTmdbShowID(TmdbShowID); + + #endregion + + #region IEntityMetadata + + ForeignEntityType IEntityMetadata.Type => ForeignEntityType.Show; + + DataSourceEnum IEntityMetadata.DataSource => DataSourceEnum.TMDB; + + TitleLanguage? IEntityMetadata.OriginalLanguage => OriginalLanguage; + + DateOnly? IEntityMetadata.ReleasedAt => FirstAiredAt; + + #endregion + + #region IMetadata + + DataSourceEnum IMetadata.Source => DataSourceEnum.TMDB; + + int IMetadata<int>.ID => Id; + + #endregion + + #region IWithTitles + + string IWithTitles.DefaultTitle => EnglishTitle; + + string IWithTitles.PreferredTitle => GetPreferredTitle()!.Value; + + IReadOnlyList<AnimeTitle> IWithTitles.Titles => GetAllTitles() + .Select(title => new AnimeTitle() + { + Language = title.Language, + LanguageCode = title.LanguageCode, + Source = DataSourceEnum.TMDB, + Title = title.Value, + Type = TitleType.Official, + }) + .ToList(); + + #endregion + + #region IWithDescriptions + + string IWithDescriptions.DefaultDescription => EnglishOverview; + + string IWithDescriptions.PreferredDescription => GetPreferredOverview()!.Value; + + IReadOnlyList<TextDescription> IWithDescriptions.Descriptions => GetAllOverviews() + .Select(overview => new TextDescription() + { + CountryCode = overview.CountryCode, + Language = overview.Language, + LanguageCode = overview.LanguageCode, + Source = DataSourceEnum.TMDB, + Value = overview.Value, + }) + .ToList(); + + #endregion + + #region IWithImages + + IImageMetadata? IWithImages.GetPreferredImageForType(ImageEntityType entityType) => null; + + IReadOnlyList<IImageMetadata> IWithImages.GetImages(ImageEntityType? entityType) => GetImages(entityType); + + #endregion + + #region ISeries + + IReadOnlyList<int> ISeries.ShokoSeriesIDs => CrossReferences.Select(xref => xref.AnimeSeries?.AnimeSeriesID).WhereNotNull().Distinct().ToList(); + + AnimeType ISeries.Type => AnimeType.TVSeries; + + DateTime? ISeries.AirDate => FirstAiredAt?.ToDateTime(); + + DateTime? ISeries.EndDate => LastAiredAt?.ToDateTime(); + + double ISeries.Rating => UserRating; + + bool ISeries.Restricted => IsRestricted; + + IImageMetadata? ISeries.DefaultPoster => DefaultPoster; + + IReadOnlyList<IShokoSeries> ISeries.ShokoSeries => CrossReferences + .Select(xref => xref.AnimeSeries) + .WhereNotNull() + .ToList(); + + IReadOnlyList<IRelatedMetadata<ISeries>> ISeries.RelatedSeries => []; + + IReadOnlyList<IRelatedMetadata<IMovie>> ISeries.RelatedMovies => []; + + IReadOnlyList<IVideoCrossReference> ISeries.CrossReferences => CrossReferences + .DistinctBy(xref => xref.AnidbAnimeID) + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByAnimeID(xref.AnidbAnimeID)) + .ToList(); + + IReadOnlyList<IEpisode> ISeries.Episodes => TmdbEpisodes; + + EpisodeCounts ISeries.EpisodeCounts => + new() + { + Episodes = EpisodeCount, + }; + + IReadOnlyList<IVideo> ISeries.Videos => CrossReferences + .DistinctBy(xref => xref.AnidbAnimeID) + .SelectMany(xref => RepoFactory.CrossRef_File_Episode.GetByAnimeID(xref.AnidbAnimeID)) + .Select(xref => xref.VideoLocal) + .WhereNotNull() + .ToList(); + + #endregion +} + diff --git a/Shoko.Server/Models/TMDB/Text/TMDB_Overview.cs b/Shoko.Server/Models/TMDB/Text/TMDB_Overview.cs new file mode 100644 index 000000000..29a88a2cd --- /dev/null +++ b/Shoko.Server/Models/TMDB/Text/TMDB_Overview.cs @@ -0,0 +1,43 @@ +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Overview +{ + public int TMDB_OverviewID { get; set; } + + public int ParentID { get; set; } + + public ForeignEntityType ParentType { get; set; } + + public TitleLanguage Language + { + get => string.IsNullOrEmpty(LanguageCode) ? TitleLanguage.None : LanguageCode.GetTitleLanguage(); + } + + /// <summary> + /// ISO 639-1 alpha-2 language code. + /// </summary> + public string LanguageCode { get; set; } = string.Empty; + + /// <summary> + /// ISO 3166-1 alpha-2 country code. + /// </summary> + public string CountryCode { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; + + public TMDB_Overview() { } + + public TMDB_Overview(ForeignEntityType parentType, int parentId, string value, string languageCode, string countryCode) + { + ParentType = parentType; + ParentID = parentId; + Value = value; + LanguageCode = languageCode; + CountryCode = countryCode; + } +} diff --git a/Shoko.Server/Models/TMDB/Text/TMDB_Title.cs b/Shoko.Server/Models/TMDB/Text/TMDB_Title.cs new file mode 100644 index 000000000..55a5ebb0f --- /dev/null +++ b/Shoko.Server/Models/TMDB/Text/TMDB_Title.cs @@ -0,0 +1,66 @@ +using System; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Models.TMDB; + +public class TMDB_Title : IEquatable<TMDB_Title> +{ + public int TMDB_TitleID { get; set; } + + public int ParentID { get; set; } + + public ForeignEntityType ParentType { get; set; } + + public TitleLanguage Language + { + get => string.IsNullOrEmpty(LanguageCode) ? TitleLanguage.None : LanguageCode.GetTitleLanguage(); + } + + /// <summary> + /// ISO 639-1 alpha-2 language code. + /// </summary> + public string LanguageCode { get; set; } = string.Empty; + + /// <summary> + /// ISO 3166-1 alpha-2 country code. + /// </summary> + public string CountryCode { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; + + public TMDB_Title() { } + + public TMDB_Title(ForeignEntityType parentType, int parentId, string value, string languageCode, string countryCode) + { + ParentType = parentType; + ParentID = parentId; + Value = value; + LanguageCode = languageCode; + CountryCode = countryCode; + } + + public override int GetHashCode() + { + var hash = 17; + + hash = hash * 31 + ParentID.GetHashCode(); + hash = hash * 31 + ParentType.GetHashCode(); + hash = hash * 31 + (LanguageCode?.GetHashCode() ?? 0); + hash = hash * 31 + (CountryCode?.GetHashCode() ?? 0); + hash = hash * 31 + Value.GetHashCode(); + + return hash; + } + + public override bool Equals(object? other) => + other is not null && other is TMDB_Title title && Equals(title); + + public bool Equals(TMDB_Title? other) => + other != null && + Value == other.Value && + LanguageCode == other.LanguageCode && + CountryCode == other.CountryCode; +} diff --git a/Shoko.Server/Models/Trakt/Trakt_Show.cs b/Shoko.Server/Models/Trakt/Trakt_Show.cs new file mode 100644 index 000000000..27f494e05 --- /dev/null +++ b/Shoko.Server/Models/Trakt/Trakt_Show.cs @@ -0,0 +1,13 @@ + +namespace Shoko.Server.Models.Trakt; + +public class Trakt_Show +{ + public int Trakt_ShowID { get; set; } + public string TraktID { get; set; } + public int? TmdbShowID { get; set; } + public string Title { get; set; } + public string Year { get; set; } + public string URL { get; set; } + public string Overview { get; set; } +} diff --git a/Shoko.Server/Plex/TVShow/SVR_Episode.cs b/Shoko.Server/Plex/TVShow/SVR_Episode.cs index 0069b79c1..ac2b1282f 100644 --- a/Shoko.Server/Plex/TVShow/SVR_Episode.cs +++ b/Shoko.Server/Plex/TVShow/SVR_Episode.cs @@ -1,4 +1,6 @@ -using System.IO; +using System; +using System.IO; +using System.Linq; using Shoko.Models.Plex.TVShow; using Shoko.Server.Models; using Shoko.Server.Repositories; @@ -14,8 +16,32 @@ public SVR_Episode(PlexHelper helper) Helper = helper; } - public SVR_AnimeEpisode AnimeEpisode => - RepoFactory.AnimeEpisode.GetByFilename(Path.GetFileName(Media[0].Part[0].File)); + public SVR_AnimeEpisode AnimeEpisode + { + get + { + var separator = Helper.ServerCache.Platform.ToLower() switch + { + "linux" => '/', + "windows" => '\\', + "osx" => '/', + "macos" => '/', + "darwin" => '/', + "android" => '/', + + _ => Path.DirectorySeparatorChar, + }; + + var filenameWithParent = Path.Join(Media[0].Part[0].File.Split(separator)[^2..]); + + var file = RepoFactory.VideoLocalPlace + .GetAll() + .FirstOrDefault(location => location.FullServerPath?.EndsWith(filenameWithParent, StringComparison.OrdinalIgnoreCase) ?? false); + + return file is null ? null : RepoFactory.AnimeEpisode.GetByHash(file.VideoLocal?.Hash).FirstOrDefault(); + + } + } public void Unscrobble() { diff --git a/Shoko.Server/Plugin/Loader.cs b/Shoko.Server/Plugin/Loader.cs index 7245910d0..c8cc3cf04 100644 --- a/Shoko.Server/Plugin/Loader.cs +++ b/Shoko.Server/Plugin/Loader.cs @@ -17,7 +17,7 @@ namespace Shoko.Server.Plugin; public static class Loader { private static readonly IList<Type> _pluginTypes = new List<Type>(); - private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger s_logger = LogManager.GetCurrentClassLogger(); private static IDictionary<Type, IPlugin> Plugins { get; } = new Dictionary<Type, IPlugin>(); internal static IServiceCollection AddPlugins(this IServiceCollection serviceCollection) @@ -25,8 +25,7 @@ internal static IServiceCollection AddPlugins(this IServiceCollection serviceCol // add plugin api related things to service collection var assemblies = new List<Assembly>(); var assembly = Assembly.GetExecutingAssembly(); - var uri = new UriBuilder(assembly.GetName().CodeBase); - var dirname = Path.GetDirectoryName(Uri.UnescapeDataString(uri.Path)); + var dirname = Path.GetDirectoryName(assembly.Location); assemblies.Add(Assembly.GetCallingAssembly()); //add this to dynamically load as well. // Load plugins from the user config dir too. @@ -44,24 +43,24 @@ internal static IServiceCollection AddPlugins(this IServiceCollection serviceCol var name = Path.GetFileNameWithoutExtension(dll); if (settings.Plugins.EnabledPlugins.ContainsKey(name) && !settings.Plugins.EnabledPlugins[name]) { - _logger.Info($"Found {name}, but it is disabled in the Server Settings. Skipping it."); + s_logger.Info($"Found {name}, but it is disabled in the Server Settings. Skipping it."); continue; } - _logger.Info($"Trying to load {dll}"); + s_logger.Info($"Trying to load {dll}"); assemblies.Add(Assembly.LoadFrom(dll)); // TryAdd, because if it made it this far, then it's missing or true. settings.Plugins.EnabledPlugins.TryAdd(name, true); if (!settings.Plugins.Priority.Contains(name)) settings.Plugins.Priority.Add(name); Utils.SettingsProvider.SaveSettings(); + s_logger.Info($"Loaded Assemblies from {dll}"); } - catch (Exception e) when (e is BadImageFormatException or FileLoadException) + catch (Exception ex) { - _logger.Error(e); + s_logger.Warn(ex, "Failed to load plugin {Name}", Path.GetFileNameWithoutExtension(dll)); } } - RenameFileHelper.FindRenamers(assemblies); LoadPlugins(assemblies, serviceCollection); return serviceCollection; @@ -102,6 +101,7 @@ public static SwaggerGenOptions AddPlugins(this SwaggerGenOptions options) private static void LoadPlugins(IEnumerable<Assembly> assemblies, IServiceCollection serviceCollection) { + s_logger.Trace("Scanning for IPlugin implementations"); var implementations = assemblies.SelectMany(a => { try @@ -110,7 +110,7 @@ private static void LoadPlugins(IEnumerable<Assembly> assemblies, IServiceCollec } catch (Exception e) { - _logger.Debug(e); + s_logger.Debug(e); return new Type[0]; } }).Where(a => a.GetInterfaces().Contains(typeof(IPlugin))); @@ -121,23 +121,25 @@ private static void LoadPlugins(IEnumerable<Assembly> assemblies, IServiceCollec BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); if (mtd != null) { + s_logger.Trace("Configuring Services for {0}", implementation.Name); mtd.Invoke(null, new object[] { serviceCollection }); } _pluginTypes.Add(implementation); + s_logger.Trace("Loaded IPlugin implementation: {0}", implementation.Name); } } internal static void InitPlugins(IServiceProvider provider) { - _logger.Info("Loading {0} plugins", _pluginTypes.Count); + s_logger.Info("Loading {0} plugins", _pluginTypes.Count); foreach (var pluginType in _pluginTypes) { var plugin = (IPlugin)ActivatorUtilities.CreateInstance(provider, pluginType); Plugins.Add(pluginType, plugin); LoadSettings(pluginType, plugin); - _logger.Info($"Loaded: {plugin.Name}"); + s_logger.Info($"Loaded: {plugin.Name}"); plugin.Load(); } @@ -163,15 +165,13 @@ private static void LoadSettings(Type type, IPlugin plugin) var obj = !File.Exists(settingsPath) ? Activator.CreateInstance(t) : SettingsProvider.Deserialize(t, File.ReadAllText(settingsPath)); - // Plugins.Settings will be empty, since it's ignored by the serializer var settings = (IPluginSettings)obj; - serverSettings.Plugins.Settings.Add(settings); plugin.OnSettingsLoaded(settings); } catch (Exception e) { - _logger.Error(e, $"Unable to initialize Settings for {name}"); + s_logger.Error(e, $"Unable to initialize Settings for {name}"); } } @@ -189,7 +189,7 @@ public static void SaveSettings(IPluginSettings settings) } catch (Exception e) { - _logger.Error(e, $"Unable to Save Settings for {name}"); + s_logger.Error(e, $"Unable to Save Settings for {name}"); } } } diff --git a/Shoko.Server/Providers/AniDB/AniDBEnums.cs b/Shoko.Server/Providers/AniDB/AniDBEnums.cs index 3c8e1306d..637f2d9d4 100644 --- a/Shoko.Server/Providers/AniDB/AniDBEnums.cs +++ b/Shoko.Server/Providers/AniDB/AniDBEnums.cs @@ -265,6 +265,14 @@ public enum AnimeType Other = 5 } +public enum CreatorType +{ + Unknown = 0, + Person = 1, + Company = 2, + Collaboration = 3, +} + /// <summary> /// Explains how the main entry relates to the related entry. /// </summary> diff --git a/Shoko.Server/Providers/AniDB/AniDBImageHandler.cs b/Shoko.Server/Providers/AniDB/AniDBImageHandler.cs deleted file mode 100644 index c240c9d6b..000000000 --- a/Shoko.Server/Providers/AniDB/AniDBImageHandler.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Polly; -using Shoko.Commons.Utils; -using Shoko.Models.Enums; -using Shoko.Server.Extensions; -using Shoko.Server.ImageDownload; -using Shoko.Server.Providers.AniDB.Interfaces; -using Shoko.Server.Repositories; -using WebException = System.Net.WebException; - -namespace Shoko.Server.Providers.AniDB; - -public class AniDBImageHandler -{ - private const string FailedToDownloadNoID = "Image failed to download: Can\'t find valid {ImageType} with ID: {ImageID}"; - private readonly IUDPConnectionHandler _handler; - private readonly ImageHttpClientFactory _clientFactory; - private readonly ILogger<AniDBImageHandler> _logger; - - public AniDBImageHandler(ILogger<AniDBImageHandler> logger, IUDPConnectionHandler handler, ImageHttpClientFactory clientFactory) - { - _logger = logger; - _handler = handler; - _clientFactory = clientFactory; - } - - public (string downloadUrl, string filePath) GetPaths(ImageEntityType imageType, int imageID) - { - var prettyImageType = imageType.ToString().Replace("_", " "); - string downloadUrl = null; - string filePath = null; - switch (imageType) - { - case ImageEntityType.AniDB_Cover: - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(imageID); - if (anime == null) - { - _logger.LogWarning(FailedToDownloadNoID, prettyImageType, imageID); - return default; - } - - downloadUrl = string.Format(_handler.ImageServerUrl, anime.Picname); - filePath = anime.PosterPath; - break; - - case ImageEntityType.AniDB_Character: - var chr = RepoFactory.AniDB_Character.GetByCharID(imageID); - if (chr == null) - { - _logger.LogWarning(FailedToDownloadNoID, prettyImageType, imageID); - return default; - } - - downloadUrl = string.Format(_handler.ImageServerUrl, chr.PicName); - filePath = chr.GetPosterPath(); - break; - - case ImageEntityType.AniDB_Creator: - var va = RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(imageID); - if (va == null) - { - _logger.LogWarning(FailedToDownloadNoID, prettyImageType, imageID); - return default; - } - - downloadUrl = string.Format(_handler.ImageServerUrl, va.PicName); - filePath = va.GetPosterPath(); - break; - } - - return (downloadUrl, filePath); - } - - public async Task<ImageDownloadResult> DownloadImage(string downloadUrl, string filePath, bool force, int maxRetries = 5) - { - // Abort if the download URL or final destination is not available. - if (string.IsNullOrEmpty(downloadUrl) || string.IsNullOrEmpty(filePath)) - return ImageDownloadResult.Failure; - - var imageValid = IsImageCached(filePath); - if (imageValid && !force) - return ImageDownloadResult.Cached; - - return await DownloadImageDirectly(downloadUrl, filePath, maxRetries); - } - - public bool IsImageCached(ImageEntityType imageType, int imageID) - { - var (_, filePath) = GetPaths(imageType, imageID); - return IsImageCached(filePath); - } - - public static bool IsImageCached(string filePath) => !string.IsNullOrEmpty(filePath) && File.Exists(filePath) && Misc.IsImageValid(filePath); - - public async Task<ImageDownloadResult> DownloadImageDirectly(string downloadUrl, string filePath, int maxRetries = 5) - { - // Abort if the download URL or final destination is not available. - if (string.IsNullOrEmpty(downloadUrl) || string.IsNullOrEmpty(filePath)) - return ImageDownloadResult.Failure; - - var tempPath = Path.Combine(ImageUtils.GetImagesTempFolder(), Path.GetFileName(filePath)); - var retryPolicy = Policy - .Handle<HttpRequestException>() - .Or<WebException>() - .RetryAsync(maxRetries, (exception, _) => - { - if (exception is HttpRequestException { StatusCode: HttpStatusCode.Forbidden or HttpStatusCode.NotFound } httpEx) - { - throw new InvalidOperationException("Image download failed with invalid resource.", httpEx); - } - }); - - return await retryPolicy.ExecuteAsync(async () => - { - // Download the image using custom HttpClient factory. - var client = _clientFactory.CreateClient("AniDBClient"); - var bytes = await client.GetByteArrayAsync(downloadUrl); - - // Validate the downloaded image. - if (bytes.Length < 4) - throw new WebException("The image download stream returned less than 4 bytes (a valid image has 2-4 bytes in the header)"); - - if (Misc.GetImageFormat(bytes) == null) - throw new WebException("The image download stream returned an invalid image"); - - // Write the image data to the temp file. - if (File.Exists(tempPath)) - File.Delete(tempPath); - - await using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write)) - fs.Write(bytes, 0, bytes.Length); - - // Ensure directory structure exists. - var dirPath = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dirPath) && !Directory.Exists(dirPath)) - Directory.CreateDirectory(dirPath); - - // Delete existing file if re-downloading. - if (File.Exists(filePath)) - File.Delete(filePath); - - // Move the temp file to its final destination. - File.Move(tempPath, filePath); - - return ImageDownloadResult.Success; - }); - } -} diff --git a/Shoko.Server/Providers/AniDB/AniDBRateLimiter.cs b/Shoko.Server/Providers/AniDB/AniDBRateLimiter.cs index f07efc12e..b13f2ad7c 100644 --- a/Shoko.Server/Providers/AniDB/AniDBRateLimiter.cs +++ b/Shoko.Server/Providers/AniDB/AniDBRateLimiter.cs @@ -1,38 +1,136 @@ using System; using System.Diagnostics; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; +using Shoko.Server.Settings; +using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider; + +#nullable enable namespace Shoko.Server.Providers.AniDB; public abstract class AniDBRateLimiter { private readonly ILogger _logger; - private readonly object _lock = new(); + + private readonly SemaphoreSlim _lock = new(1, 1); + + private readonly object _settingsLock = new(); + private readonly Stopwatch _requestWatch = new(); + private readonly Stopwatch _activeTimeWatch = new(); + private readonly ISettingsProvider _settingsProvider; + + private readonly IShokoEventHandler _eventHandler; + + private readonly Func<IServerSettings, AnidbRateLimitSettings> _settingsSelector; + + private int? _shortDelay = null; + + // From AniDB's wiki about UDP rate limiting: // Short Term: // A Client MUST NOT send more than 0.5 packets per second(that's one packet every two seconds, not two packets a second!) // The server will start to enforce the limit after the first 5 packets have been received. - protected abstract int ShortDelay { get; init; } + private int ShortDelay + { + get + { + EnsureUsable(); + return _shortDelay!.Value; + } + } + + private int? _longDelay = null; + + // From AniDB's wiki about UDP rate limiting: // Long Term: // A Client MUST NOT send more than one packet every four seconds over an extended amount of time. // An extended amount of time is not defined. Use common sense. - protected abstract int LongDelay { get; init; } + private int LongDelay + { + get + { + EnsureUsable(); + + return _longDelay!.Value; + } + } + + private long? _shortPeriod = null; + + // Switch to longer delay after a short period + private long ShortPeriod + { + get + { + EnsureUsable(); + + return _shortPeriod!.Value; + } + } + + private long? _resetPeriod = null; - // Switch to longer delay after 1 hour - protected abstract long shortPeriod { get; init; } + // Switch to shorter delay after inactivity + private long ResetPeriod + { + get + { + EnsureUsable(); - // Switch to shorter delay after 30 minutes of inactivity - protected abstract long resetPeriod { get; init; } + return _resetPeriod!.Value; + } + } - protected AniDBRateLimiter(ILogger logger) + /// <summary> + /// Ensures that all the rate limiting values are usable. + /// </summary> + /// <param name="force">Force the values to be reapplied from settings, even if they are already in a usable state.</param> + private void EnsureUsable(bool force = false) + { + if (!force && _shortDelay.HasValue) + return; + + lock (_settingsLock) + { + if (!force && _shortDelay.HasValue) + return; + + var settings = _settingsSelector(_settingsProvider.GetSettings()); + var baseRate = settings.BaseRateInSeconds * 1000; + _shortDelay = baseRate; + _longDelay = baseRate * settings.SlowRateMultiplier; + _shortPeriod = baseRate * settings.SlowRatePeriodMultiplier; + _resetPeriod = baseRate * settings.ResetPeriodMultiplier; + } + } + + protected AniDBRateLimiter(ILogger logger, ISettingsProvider settingsProvider, IShokoEventHandler eventHandler, Func<IServerSettings, AnidbRateLimitSettings> settingsSelector) { _logger = logger; _requestWatch.Start(); _activeTimeWatch.Start(); + _settingsProvider = settingsProvider; + _settingsSelector = settingsSelector; + _eventHandler = eventHandler; + _eventHandler.SettingsSaved += OnSettingsSaved; + } + + ~AniDBRateLimiter() + { + _eventHandler.SettingsSaved -= OnSettingsSaved; + } + + private void OnSettingsSaved(object? sender, SettingsSavedEventArgs eventArgs) + { + // Reset the cached values when the settings are updated. + EnsureUsable(true); } private void ResetRate() @@ -42,38 +140,35 @@ private void ResetRate() _logger.LogTrace("Rate is reset. Active time was {Time} ms", elapsedTime); } - public T EnsureRate<T>(Func<T> action) + public async Task<T> EnsureRateAsync<T>(Func<Task<T>> action, bool forceShortDelay = false) { + await _lock.WaitAsync(); try { - var entered = false; - Monitor.Enter(_lock, ref entered); - if (!entered) throw new SynchronizationLockException(); - var delay = _requestWatch.ElapsedMilliseconds; - if (delay > resetPeriod) ResetRate(); - var currentDelay = _activeTimeWatch.ElapsedMilliseconds > shortPeriod ? LongDelay : ShortDelay; + if (delay > ResetPeriod) ResetRate(); + var currentDelay = !forceShortDelay && _activeTimeWatch.ElapsedMilliseconds > ShortPeriod ? LongDelay : ShortDelay; if (delay > currentDelay) { _logger.LogTrace("Time since last request is {Delay} ms, not throttling", delay); _logger.LogTrace("Sending AniDB command"); - return action(); + return await action(); } // add 50ms for good measure var waitTime = currentDelay - (int)delay + 50; _logger.LogTrace("Time since last request is {Delay} ms, throttling for {Time}", delay, waitTime); - Thread.Sleep(waitTime); + await Task.Delay(waitTime); _logger.LogTrace("Sending AniDB command"); - return action(); + return await action(); } finally { _requestWatch.Restart(); - Monitor.Exit(_lock); + _lock.Release(); } } } diff --git a/Shoko.Server/Providers/AniDB/AniDBStartup.cs b/Shoko.Server/Providers/AniDB/AniDBStartup.cs index e227f363e..d323f9587 100644 --- a/Shoko.Server/Providers/AniDB/AniDBStartup.cs +++ b/Shoko.Server/Providers/AniDB/AniDBStartup.cs @@ -15,7 +15,6 @@ public static IServiceCollection AddAniDB(this IServiceCollection services) { services.AddSingleton<HttpAnimeParser>(); services.AddSingleton<ImageHttpClientFactory>(); - services.AddSingleton<AniDBImageHandler>(); services.AddSingleton<AniDBTitleHelper>(); services.AddSingleton<AnimeCreator>(); services.AddSingleton<HttpXmlUtils>(); diff --git a/Shoko.Server/Providers/AniDB/HTTP/AniDBHttpConnectionHandler.cs b/Shoko.Server/Providers/AniDB/HTTP/AniDBHttpConnectionHandler.cs index 8c8036063..3c22cf2a6 100644 --- a/Shoko.Server/Providers/AniDB/HTTP/AniDBHttpConnectionHandler.cs +++ b/Shoko.Server/Providers/AniDB/HTTP/AniDBHttpConnectionHandler.cs @@ -48,11 +48,12 @@ public async Task<HttpResponse<string>> GetHttpDirectly(string url) { throw new AniDBBannedException { - BanType = UpdateType.HTTPBan, BanExpires = BanTime?.AddHours(BanTimerResetLength) + BanType = UpdateType.HTTPBan, + BanExpires = BanTime?.AddHours(BanTimerResetLength), }; } - var response = await RateLimiter.EnsureRate(async () => + var response = await RateLimiter.EnsureRateAsync(async () => { using var response = await _httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); @@ -63,7 +64,8 @@ public async Task<HttpResponse<string>> GetHttpDirectly(string url) { throw new AniDBBannedException { - BanType = UpdateType.HTTPBan, BanExpires = BanTime?.AddHours(BanTimerResetLength) + BanType = UpdateType.HTTPBan, + BanExpires = BanTime?.AddHours(BanTimerResetLength), }; } diff --git a/Shoko.Server/Providers/AniDB/HTTP/AnimeCreator.cs b/Shoko.Server/Providers/AniDB/HTTP/AnimeCreator.cs index 8d151842f..620cfcac7 100644 --- a/Shoko.Server/Providers/AniDB/HTTP/AnimeCreator.cs +++ b/Shoko.Server/Providers/AniDB/HTTP/AnimeCreator.cs @@ -13,14 +13,17 @@ using Shoko.Models.Server; using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Extensions; -using Shoko.Server.ImageDownload; using Shoko.Server.Models; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Models.CrossReference; using Shoko.Server.Providers.AniDB.HTTP.GetAnime; using Shoko.Server.Repositories; using Shoko.Server.Repositories.Cached; using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.AniDB; using Shoko.Server.Scheduling.Jobs.Shoko; using Shoko.Server.Settings; +using Shoko.Server.Utilities; namespace Shoko.Server.Providers.AniDB.HTTP; @@ -40,17 +43,17 @@ public AnimeCreator(ILogger<AnimeCreator> logger, ISettingsProvider settings, IS #pragma warning disable CS0618 - public async Task<(bool animeUpdated, ISet<int> episodesAddedOrUpdated)> CreateAnime(ResponseGetAnime response, SVR_AniDB_Anime anime, int relDepth) + public async Task<(bool animeUpdated, bool titlesUpdated, bool descriptionUpdated, Dictionary<SVR_AniDB_Episode, UpdateReason> episodeChanges)> CreateAnime(ResponseGetAnime response, SVR_AniDB_Anime anime, int relDepth) { _logger.LogTrace("Updating anime {AnimeID}", response?.Anime?.AnimeID); - if ((response?.Anime?.AnimeID ?? 0) == 0) return (false, new HashSet<int>()); + if ((response?.Anime?.AnimeID ?? 0) == 0) return (false, false, false, []); var lockObj = _updatingIDs.GetOrAdd(response.Anime.AnimeID, new object()); Monitor.Enter(lockObj); try { // check if we updated in a lock var existingAnime = RepoFactory.AniDB_Anime.GetByAnimeID(response.Anime.AnimeID); - if (existingAnime != null && DateTime.Now - existingAnime.DateTimeUpdated < TimeSpan.FromSeconds(2)) return (false, new HashSet<int>()); + if (existingAnime != null && DateTime.Now - existingAnime.DateTimeUpdated < TimeSpan.FromSeconds(2)) return (false, false, false, []); var settings = _settingsProvider.GetSettings(); _logger.LogTrace("------------------------------------------------"); @@ -66,13 +69,13 @@ public AnimeCreator(ILogger<AnimeCreator> logger, ISettingsProvider settings, IS _logger.LogError("AniDB_Anime was unable to populate as it received invalid info. " + "This is not an error on our end. It is AniDB's issue, " + "as they did not return either an ID or a title for the anime"); - return (false, new HashSet<int>()); + return (false, false, false, []); } var taskTimer = Stopwatch.StartNew(); var totalTimer = Stopwatch.StartNew(); - var updated = PopulateAnime(response.Anime, anime); - RepoFactory.AniDB_Anime.Save(anime, false); + var (updated, descriptionUpdated) = PopulateAnime(response.Anime, anime); + RepoFactory.AniDB_Anime.Save(anime); taskTimer.Stop(); _logger.LogTrace("PopulateAnime in: {Time}", taskTimer.Elapsed); @@ -87,7 +90,8 @@ public AnimeCreator(ILogger<AnimeCreator> logger, ISettingsProvider settings, IS _logger.LogTrace("CreateEpisodes in: {Time}", taskTimer.Elapsed); taskTimer.Restart(); - updated = CreateTitles(response.Titles, anime) || updated; + var titlesUpdated = CreateTitles(response.Titles, anime); + updated = updated || titlesUpdated; taskTimer.Stop(); _logger.LogTrace("CreateTitles in: {Time}", taskTimer.Elapsed); taskTimer.Restart(); @@ -135,7 +139,7 @@ public AnimeCreator(ILogger<AnimeCreator> logger, ISettingsProvider settings, IS _logger.LogTrace("TOTAL TIME in : {Time}", totalTimer.Elapsed); _logger.LogTrace("------------------------------------------------"); - return (updated, updated ? response.Episodes.Select(ep => ep.EpisodeID).ToHashSet() : updatedEpisodes); + return (updated, titlesUpdated, descriptionUpdated, updatedEpisodes); } catch (Exception ex) { @@ -150,9 +154,10 @@ public AnimeCreator(ILogger<AnimeCreator> logger, ISettingsProvider settings, IS } #pragma warning restore CS0618 - private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime) + private static (bool animeUpdated, bool descriptionUpdated) PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime) { var isUpdated = false; + var descriptionUpdated = false; var isNew = anime.AnimeID == 0 || anime.AniDB_AnimeID == 0; var description = animeInfo.Description ?? string.Empty; var episodeCountSpecial = animeInfo.EpisodeCount - animeInfo.EpisodeCountNormal; @@ -202,6 +207,7 @@ private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime { anime.Description = description; isUpdated = true; + descriptionUpdated = true; } if (anime.EndDate != animeInfo.EndDate) @@ -252,9 +258,9 @@ private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime isUpdated = true; } - if (anime.Restricted != animeInfo.Restricted) + if (anime.IsRestricted != animeInfo.IsRestricted) { - anime.Restricted = animeInfo.Restricted; + anime.IsRestricted = animeInfo.IsRestricted; isUpdated = true; } @@ -302,13 +308,13 @@ private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime #pragma warning restore CS0618 } - return isUpdated; + return (isUpdated, descriptionUpdated); } - private async Task<(bool, ISet<int>)> CreateEpisodes(List<ResponseEpisode> rawEpisodeList, SVR_AniDB_Anime anime) + private async Task<(bool, Dictionary<SVR_AniDB_Episode, UpdateReason>)> CreateEpisodes(List<ResponseEpisode> rawEpisodeList, SVR_AniDB_Anime anime) { if (rawEpisodeList == null) - return (false, new HashSet<int>()); + return (false, []); var episodeCountSpecial = 0; var episodeCountNormal = 0; @@ -490,8 +496,7 @@ private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime var anidbFilesToRemove = new List<SVR_AniDB_File>(); var xrefsToRemove = new List<SVR_CrossRef_File_Episode>(); var videosToRefetch = new List<SVR_VideoLocal>(); - var tvdbXRefsToRemove = new List<CrossRef_AniDB_TvDB_Episode>(); - var tvdbXRefOverridesToRemove = new List<CrossRef_AniDB_TvDB_Episode_Override>(); + var tmdbXRefsToRemove = new List<CrossRef_AniDB_TMDB_Episode>(); if (correctSeries != null) shokoSeriesDict.Add(correctSeries.AnimeSeriesID, correctSeries); foreach (var episode in epsToSave) @@ -530,13 +535,11 @@ private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime .Select(xref => RepoFactory.AniDB_File.GetByHashAndFileSize(xref.Hash, xref.FileSize)) .Where(anidbFile => anidbFile != null) .ToList(); - var tvdbXRefs = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBEpisodeID(episode.EpisodeID); - var tvdbXRefOverrides = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBEpisodeID(episode.EpisodeID); + var tmdbXRefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(episode.EpisodeID); xrefsToRemove.AddRange(xrefs); videosToRefetch.AddRange(videos); anidbFilesToRemove.AddRange(anidbFiles); - tvdbXRefsToRemove.AddRange(tvdbXRefs); - tvdbXRefOverridesToRemove.AddRange(tvdbXRefOverrides); + tmdbXRefsToRemove.AddRange(tmdbXRefs); } shokoSeriesDict.Clear(); @@ -558,13 +561,11 @@ private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime .Select(xref => RepoFactory.AniDB_File.GetByHashAndFileSize(xref.Hash, xref.FileSize)) .Where(anidbFile => anidbFile != null) .ToList(); - var tvdbXRefs = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBEpisodeID(episode.EpisodeID); - var tvdbXRefOverrides = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBEpisodeID(episode.EpisodeID); + var tmdbXRefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(episode.EpisodeID); xrefsToRemove.AddRange(xrefs); videosToRefetch.AddRange(videos); anidbFilesToRemove.AddRange(anidbFiles); - tvdbXRefsToRemove.AddRange(tvdbXRefs); - tvdbXRefOverridesToRemove.AddRange(tvdbXRefOverrides); + tmdbXRefsToRemove.AddRange(tmdbXRefs); } RepoFactory.AniDB_File.Delete(anidbFilesToRemove); @@ -575,8 +576,7 @@ private static bool PopulateAnime(ResponseAnime animeInfo, SVR_AniDB_Anime anime RepoFactory.AnimeEpisode.Save(shokoEpisodesToSave); RepoFactory.AnimeEpisode.Delete(shokoEpisodesToRemove); RepoFactory.CrossRef_File_Episode.Delete(xrefsToRemove); - RepoFactory.CrossRef_AniDB_TvDB_Episode.Delete(tvdbXRefsToRemove); - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.Delete(tvdbXRefOverridesToRemove); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(tmdbXRefsToRemove); // Schedule a refetch of any video files affected by the removal of the // episodes. They were likely moved to another episode entry so let's @@ -597,15 +597,13 @@ await scheduler.StartJobNow<ProcessFileJob>(c => anime.EpisodeCountSpecial = episodeCountSpecial; anime.EpisodeCount = episodeCount; - // Emit anidb episode updated events. - foreach (var (episode, reason) in episodeEventsToEmit) - ShokoEventHandler.Instance.OnEpisodeUpdated(anime, episode, reason); + // Add removed episodes to the dictionary. foreach (var episode in epsToRemove) - ShokoEventHandler.Instance.OnEpisodeUpdated(anime, episode, UpdateReason.Removed); + episodeEventsToEmit.Add(episode, UpdateReason.Removed); return ( episodeEventsToEmit.ContainsValue(UpdateReason.Added) || epsToRemove.Count > 0, - episodeEventsToEmit.Keys.Select(a => a.EpisodeID).ToHashSet() + episodeEventsToEmit ); } @@ -796,11 +794,11 @@ private void CreateCharacters(List<ResponseCharacter> chars, SVR_AniDB_Anime ani } // delete existing relationships to seiyuu's - var charSeiyuusToDelete = chars.SelectMany(rawchar => RepoFactory.AniDB_Character_Seiyuu.GetByCharID(rawchar.CharacterID)).ToList(); + var charSeiyuusToDelete = chars.SelectMany(rawchar => RepoFactory.AniDB_Character_Creator.GetByCharacterID(rawchar.CharacterID)).ToList(); try { - RepoFactory.AniDB_Character_Seiyuu.Delete(charSeiyuusToDelete); + RepoFactory.AniDB_Character_Creator.Delete(charSeiyuusToDelete); } catch (Exception ex) { @@ -810,8 +808,9 @@ private void CreateCharacters(List<ResponseCharacter> chars, SVR_AniDB_Anime ani var chrsToSave = new List<AniDB_Character>(); var xrefsToSave = new List<AniDB_Anime_Character>(); - var seiyuuToSave = new Dictionary<int, AniDB_Seiyuu>(); - var seiyuuXrefToSave = new List<AniDB_Character_Seiyuu>(); + var creatorsToSchedule = new HashSet<int>(); + var creatorsToSave = new Dictionary<int, AniDB_Creator>(); + var creatorXrefToSave = new List<AniDB_Character_Creator>(); var charBasePath = ImageUtils.GetBaseAniDBCharacterImagesPath() + Path.DirectorySeparatorChar; var creatorBasePath = ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar; @@ -861,7 +860,7 @@ private void CreateCharacters(List<ResponseCharacter> chars, SVR_AniDB_Anime ani Name = chr.CharName, AlternateName = rawchar.CharacterKanjiName, Description = chr.CharDescription, - ImagePath = chr.GetPosterPath()?.Replace(charBasePath, "") + ImagePath = chr.GetFullImagePath()?.Replace(charBasePath, "") }; // we need an ID for xref RepoFactory.AnimeCharacter.Save(character); @@ -885,6 +884,7 @@ private void CreateCharacters(List<ResponseCharacter> chars, SVR_AniDB_Anime ani } } + var settings = _settingsProvider.GetSettings(); foreach (var seiyuuGrouping in seiyuuLookup) { try @@ -892,29 +892,41 @@ private void CreateCharacters(List<ResponseCharacter> chars, SVR_AniDB_Anime ani var rawSeiyuu = seiyuuGrouping.FirstOrDefault(); // save the link between character and seiyuu // this should always be null - seiyuuXrefToSave.Add(new AniDB_Character_Seiyuu { CharID = chr.CharID, SeiyuuID = rawSeiyuu.SeiyuuID }); + creatorXrefToSave.Add(new() { CharacterID = chr.CharID, CreatorID = rawSeiyuu.SeiyuuID }); // save the seiyuu - var seiyuu = RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(rawSeiyuu.SeiyuuID) ?? new AniDB_Seiyuu(); + var creator = RepoFactory.AniDB_Creator.GetByCreatorID(rawSeiyuu.SeiyuuID) ?? new() + { + CreatorID = rawSeiyuu.SeiyuuID, + Name = rawSeiyuu.SeiyuuName, + Type = CreatorType.Unknown, + ImagePath = rawSeiyuu.PicName, + }; + if (string.IsNullOrEmpty(creator.Name) && !string.IsNullOrEmpty(rawSeiyuu.SeiyuuName)) + creator.Name = rawSeiyuu.SeiyuuName; - seiyuu.PicName = rawSeiyuu.PicName; - seiyuu.SeiyuuID = rawSeiyuu.SeiyuuID; - seiyuu.SeiyuuName = rawSeiyuu.SeiyuuName; - seiyuuToSave[seiyuu.SeiyuuID] = seiyuu; + creatorsToSave[creator.CreatorID] = creator; + if (settings.AniDb.DownloadCreators && creator.Type is CreatorType.Unknown) + creatorsToSchedule.Add(creator.CreatorID); - var staff = RepoFactory.AnimeStaff.GetByAniDBID(seiyuu.SeiyuuID); + var staff = RepoFactory.AnimeStaff.GetByAniDBID(creator.CreatorID); if (staff == null) { staff = new AnimeStaff { // Unfortunately, most of the info is not provided - AniDBID = seiyuu.SeiyuuID, + AniDBID = creator.CreatorID, Name = rawSeiyuu.SeiyuuName, - ImagePath = seiyuu.GetPosterPath()?.Replace(creatorBasePath, "") + ImagePath = creator.GetFullImagePath()?.Replace(creatorBasePath, "") }; // we need an ID for xref RepoFactory.AnimeStaff.Save(staff); } + else if (string.IsNullOrEmpty(staff.Name) && !string.IsNullOrEmpty(rawSeiyuu.SeiyuuName)) + { + staff.Name = rawSeiyuu.SeiyuuName; + RepoFactory.AnimeStaff.Save(staff); + } var xrefAnimeStaff = RepoFactory.CrossRef_Anime_Staff.GetByParts(anime.AnimeID, character.CharacterID, @@ -957,13 +969,15 @@ private void CreateCharacters(List<ResponseCharacter> chars, SVR_AniDB_Anime ani { RepoFactory.AniDB_Character.Save(chrsToSave); RepoFactory.AniDB_Anime_Character.Save(xrefsToSave); - RepoFactory.AniDB_Seiyuu.Save(seiyuuToSave.Values.ToList()); - RepoFactory.AniDB_Character_Seiyuu.Save(seiyuuXrefToSave); + RepoFactory.AniDB_Creator.Save(creatorsToSave.Values.ToList()); + RepoFactory.AniDB_Character_Creator.Save(creatorXrefToSave); } catch (Exception ex) { _logger.LogError(ex, "Unable to Save Characters and Seiyuus for {MainTitle}", anime.MainTitle); } + + ScheduleCreators(creatorsToSchedule, anime.MainTitle); } private void CreateStaff(List<ResponseStaff> staffList, SVR_AniDB_Anime anime) @@ -982,6 +996,8 @@ private void CreateStaff(List<ResponseStaff> staffList, SVR_AniDB_Anime anime) _logger.LogError(ex, "Unable to Remove Staff for {MainTitle}", anime.MainTitle); } + var creatorsToSchedule = new HashSet<int>(); + var creatorsToSave = new List<AniDB_Creator>(); var animeStaffToSave = new List<AniDB_Anime_Staff>(); var xRefToSave = new List<CrossRef_Anime_Staff>(); @@ -994,12 +1010,12 @@ private void CreateStaff(List<ResponseStaff> staffList, SVR_AniDB_Anime anime) } } + var settings = _settingsProvider.GetSettings(); foreach (var grouping in staffLookup) { try { var rawStaff = grouping.FirstOrDefault(); - // save the link between character and seiyuu animeStaffToSave.Add(new AniDB_Anime_Staff { AnimeID = rawStaff.AnimeID, @@ -1007,17 +1023,41 @@ private void CreateStaff(List<ResponseStaff> staffList, SVR_AniDB_Anime anime) CreatorType = rawStaff.CreatorType }); + var creator = RepoFactory.AniDB_Creator.GetByCreatorID(rawStaff.CreatorID); + if (creator is null) + { + creator = new() + { + CreatorID = rawStaff.CreatorID, + Name = rawStaff.CreatorName, + Type = CreatorType.Unknown, + }; + creatorsToSave.Add(creator); + } + else if (string.IsNullOrEmpty(creator.Name) && !string.IsNullOrEmpty(rawStaff.CreatorName)) + { + creator.Name = rawStaff.CreatorName; + creatorsToSave.Add(creator); + } + if (settings.AniDb.DownloadCreators && creator.Type is CreatorType.Unknown) + creatorsToSchedule.Add(rawStaff.CreatorID); + var staff = RepoFactory.AnimeStaff.GetByAniDBID(rawStaff.CreatorID); if (staff == null) { staff = new AnimeStaff { - // Unfortunately, most of the info is not provided - AniDBID = rawStaff.CreatorID, Name = rawStaff.CreatorName + AniDBID = rawStaff.CreatorID, + Name = rawStaff.CreatorName, }; // we need an ID for xref RepoFactory.AnimeStaff.Save(staff); } + else if (string.IsNullOrEmpty(staff.Name) && !string.IsNullOrEmpty(rawStaff.CreatorName)) + { + staff.Name = rawStaff.CreatorName; + RepoFactory.AnimeStaff.Save(staff); + } var roleType = rawStaff.CreatorType switch { @@ -1032,8 +1072,7 @@ private void CreateStaff(List<ResponseStaff> staffList, SVR_AniDB_Anime anime) _ => StaffRoleType.Staff }; - var xrefAnimeStaff = - RepoFactory.CrossRef_Anime_Staff.GetByParts(anime.AnimeID, null, staff.StaffID, roleType); + var xrefAnimeStaff = RepoFactory.CrossRef_Anime_Staff.GetByParts(anime.AnimeID, null, staff.StaffID, roleType); if (xrefAnimeStaff != null) { continue; @@ -1071,6 +1110,25 @@ private void CreateStaff(List<ResponseStaff> staffList, SVR_AniDB_Anime anime) { _logger.LogError(ex, "Unable to Save Staff for {MainTitle}", anime.MainTitle); } + + ScheduleCreators(creatorsToSchedule, anime.MainTitle); + } + + private async void ScheduleCreators(IEnumerable<int> creatorIDs, string mainTitle) + { + try + { + var creatorList = creatorIDs.ToList(); + if (creatorList.Count == 0) return; + var scheduler = await _schedulerFactory.GetScheduler(); + _logger.LogInformation("Scheduling {Count} creators to be updated for {MainTitle}", creatorList.Count, mainTitle); + foreach (var creatorId in creatorList) + await scheduler.StartJob<GetAniDBCreatorJob>(c => c.CreatorID = creatorId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to Schedule Creators for {MainTitle}", mainTitle); + } } private static void CreateResources(List<ResponseResource> resources, SVR_AniDB_Anime anime) diff --git a/Shoko.Server/Providers/AniDB/HTTP/GetAnime/ResponseAnime.cs b/Shoko.Server/Providers/AniDB/HTTP/GetAnime/ResponseAnime.cs index 63e4cc107..e985ce830 100644 --- a/Shoko.Server/Providers/AniDB/HTTP/GetAnime/ResponseAnime.cs +++ b/Shoko.Server/Providers/AniDB/HTTP/GetAnime/ResponseAnime.cs @@ -21,7 +21,7 @@ public class ResponseAnime public int TempVoteCount { get; set; } public int AvgReviewRating { get; set; } public int ReviewCount { get; set; } - public int Restricted { get; set; } + public bool IsRestricted { get; set; } public int AnimePlanetID { get; set; } public int ANNID { get; set; } public int AllCinemaID { get; set; } diff --git a/Shoko.Server/Providers/AniDB/HTTP/HttpAnimeParser.cs b/Shoko.Server/Providers/AniDB/HTTP/HttpAnimeParser.cs index 350dfc7da..b7c046b36 100644 --- a/Shoko.Server/Providers/AniDB/HTTP/HttpAnimeParser.cs +++ b/Shoko.Server/Providers/AniDB/HTTP/HttpAnimeParser.cs @@ -103,11 +103,11 @@ private ResponseAnime ParseAnime(int animeID, XmlNode docAnime) var restricted = docAnime["anime"].Attributes["restricted"]?.Value; if (bool.TryParse(restricted, out var res)) { - anime.Restricted = res ? 1 : 0; + anime.IsRestricted = res; } else { - anime.Restricted = 0; + anime.IsRestricted = false; } anime.URL = TryGetProperty(docAnime, "anime", "url"); diff --git a/Shoko.Server/Providers/AniDB/HTTP/HttpRateLimiter.cs b/Shoko.Server/Providers/AniDB/HTTP/HttpRateLimiter.cs index 7c3c41398..4ffd8a3d9 100644 --- a/Shoko.Server/Providers/AniDB/HTTP/HttpRateLimiter.cs +++ b/Shoko.Server/Providers/AniDB/HTTP/HttpRateLimiter.cs @@ -1,15 +1,12 @@ using Microsoft.Extensions.Logging; +using Shoko.Plugin.Abstractions; + +using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider; namespace Shoko.Server.Providers.AniDB.HTTP; public class HttpRateLimiter : AniDBRateLimiter { - protected override int ShortDelay { get; init; } = 2000; - protected override int LongDelay { get; init; } = 4000; - protected override long shortPeriod { get; init; } = 1000000; - protected override long resetPeriod { get; init; } = 1800000; - - public HttpRateLimiter(ILogger<HttpRateLimiter> logger) : base(logger) - { - } + public HttpRateLimiter(ILogger<HttpRateLimiter> logger, ISettingsProvider settingsProvider, IShokoEventHandler eventHandler) + : base(logger, settingsProvider, eventHandler, s => s.AniDb.HTTPRateLimit) { } } diff --git a/Shoko.Server/Providers/AniDB/Interfaces/IUDPConnectionHandler.cs b/Shoko.Server/Providers/AniDB/Interfaces/IUDPConnectionHandler.cs index 80cca8163..bcb6eec91 100644 --- a/Shoko.Server/Providers/AniDB/Interfaces/IUDPConnectionHandler.cs +++ b/Shoko.Server/Providers/AniDB/Interfaces/IUDPConnectionHandler.cs @@ -14,6 +14,7 @@ public interface IUDPConnectionHandler : IConnectionHandler bool ValidAniDBCredentials(string user, string pass); Task<bool> Login(); void ForceLogout(); + void ClearSession(); Task CloseConnections(); Task ForceReconnection(); void StartBackoffTimer(int time, string message); @@ -21,7 +22,7 @@ public interface IUDPConnectionHandler : IConnectionHandler Task<bool> Init(string username, string password, string serverName, ushort serverPort, ushort clientPort); Task<bool> TestLogin(string username, string password); - Task<string> SendDirectly(string command, bool needsUnicode = true, bool resetPingTimer = true, bool resetLogoutTimer = true); + Task<string> SendDirectly(string command, bool needsUnicode = true, bool isPing = false, bool isLogout = false); Task<string> Send(string command, bool needsUnicode = true); } diff --git a/Shoko.Server/Providers/AniDB/Titles/AniDBTitleHelper.cs b/Shoko.Server/Providers/AniDB/Titles/AniDBTitleHelper.cs index 0e6a48b4b..65fdfb715 100644 --- a/Shoko.Server/Providers/AniDB/Titles/AniDBTitleHelper.cs +++ b/Shoko.Server/Providers/AniDB/Titles/AniDBTitleHelper.cs @@ -38,7 +38,7 @@ public AniDBTitleHelper(ISettingsProvider settingsProvider) try { _accessLock.EnterReadLock(); - return _cache?.Animes.ToList() ?? new List<ResponseAniDBTitles.Anime>(); + return _cache?.AnimeList.ToList() ?? []; } finally { @@ -58,11 +58,11 @@ public ResponseAniDBTitles.Anime SearchAnimeID(int animeID) try { if (_cache == null) CreateCache(); - + try { _accessLock.EnterReadLock(); - return _cache?.Animes + return _cache?.AnimeList .FirstOrDefault(a => a.AnimeID == animeID); } finally @@ -88,8 +88,8 @@ public ResponseAniDBTitles.Anime SearchAnimeID(int animeID) try { _accessLock.EnterReadLock(); - var languages = _settingsProvider.GetSettings().LanguagePreference; - return _cache?.Animes + var languages = _settingsProvider.GetSettings().Language.SeriesTitleLanguageOrder; + return _cache?.AnimeList .AsParallel() .Search( query, @@ -100,7 +100,7 @@ public ResponseAniDBTitles.Anime SearchAnimeID(int animeID) .ToList(), fuzzy ) - .Select(a => a.Result).ToList() ?? new List<ResponseAniDBTitles.Anime>(); + .Select(a => a.Result).ToList() ?? []; } finally { diff --git a/Shoko.Server/Providers/AniDB/Titles/ResponseAniDBTitles.cs b/Shoko.Server/Providers/AniDB/Titles/ResponseAniDBTitles.cs index 58c209a02..5ebeda25d 100644 --- a/Shoko.Server/Providers/AniDB/Titles/ResponseAniDBTitles.cs +++ b/Shoko.Server/Providers/AniDB/Titles/ResponseAniDBTitles.cs @@ -10,7 +10,7 @@ namespace Shoko.Server.Providers.AniDB.Titles; [XmlRoot("animetitles")] public class ResponseAniDBTitles { - [XmlElement("anime")] public List<Anime> Animes { get; set; } + [XmlElement("anime")] public List<Anime> AnimeList { get; set; } public class Anime { @@ -36,7 +36,7 @@ public string PreferredTitle if (title != null) return title.Title; // Then check for _any_ title at all, if there is no main or official title in the langugage. - if (Utils.SettingsProvider.GetSettings().LanguageUseSynonyms) + if (Utils.SettingsProvider.GetSettings().Language.UseSynonyms) { title = Titles.FirstOrDefault(t => t.Language == language); if (title != null) return title.Title; diff --git a/Shoko.Server/Providers/AniDB/UDP/AniDBUDPConnectionHandler.cs b/Shoko.Server/Providers/AniDB/UDP/AniDBUDPConnectionHandler.cs index 3fe4d8666..a18f0e00e 100644 --- a/Shoko.Server/Providers/AniDB/UDP/AniDBUDPConnectionHandler.cs +++ b/Shoko.Server/Providers/AniDB/UDP/AniDBUDPConnectionHandler.cs @@ -20,24 +20,30 @@ namespace Shoko.Server.Providers.AniDB.UDP; +#nullable enable public class AniDBUDPConnectionHandler : ConnectionHandler, IUDPConnectionHandler { - // 10 minutes - private const int LogoutPeriod = 10 * 60 * 1000; - // 45 seconds - private const int PingFrequency = 45 * 1000; + /**** + * From Anidb wiki: + * The virtual UDP connection times out if no data was received from the client for 35 minutes. + * A client should issue a UPTIME command once every 30 minutes to keep the connection alive should that be required. + * If the client does not use any of the notification/push features of the API it should NOT keep the connection alive, furthermore it should explicitly terminate the connection by issuing a LOGOUT command once it finished it's work. + * If it is very likely that another command will be issued shortly (within the next 20 minutes) a client SHOULD keep the current connection open, by not sending a LOGOUT command. + ****/ + // 5 minutes + private const int LogoutPeriod = 5 * 60 * 1000; private readonly IRequestFactory _requestFactory; private readonly IConnectivityService _connectivityService; - private IAniDBSocketHandler _socketHandler; + private IAniDBSocketHandler? _socketHandler; private static readonly Regex s_logMask = new("(?<=(\\bpass=|&pass=\\bs=|&s=))[^&]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public event EventHandler LoginFailed; + public event EventHandler? LoginFailed; public override double BanTimerResetLength => 1.5D; public override string Type => "UDP"; protected override UpdateType BanEnum => UpdateType.UDPBan; - public string SessionID { get; private set; } + public string? SessionID { get; private set; } public bool IsAlive { get; private set; } private string _cdnDomain = Constants.URLS.AniDB_Images_Domain; @@ -46,8 +52,8 @@ public class AniDBUDPConnectionHandler : ConnectionHandler, IUDPConnectionHandle private ISettingsProvider SettingsProvider { get; set; } - private Timer _pingTimer; - private Timer _logoutTimer; + private Timer? _pingTimer; + private Timer? _logoutTimer; private bool _isLoggedOn; private bool _isInvalidSession; @@ -61,7 +67,9 @@ public bool IsInvalidSession _isInvalidSession = value; UpdateState(new AniDBStateUpdate { - UpdateType = UpdateType.InvalidSession, UpdateTime = DateTime.Now, Value = value + UpdateType = UpdateType.InvalidSession, + UpdateTime = DateTime.Now, + Value = value }); } } @@ -74,10 +82,11 @@ public override bool IsBanned if (value) { _isLoggedOn = false; - IsInvalidSession = false; SessionID = null; } + IsInvalidSession = false; + base.IsBanned = value; } } @@ -110,7 +119,7 @@ public async Task<bool> Init() await InitInternal(); return true; } - + public async Task<bool> Init(string username, string password, string serverName, ushort serverPort, ushort clientPort) { var settings = SettingsProvider.GetSettings(); @@ -142,7 +151,7 @@ private async Task InitInternal() _isLoggedOn = false; IsNetworkAvailable = await _socketHandler.TryConnection(); - _pingTimer = new Timer { Interval = PingFrequency, Enabled = true, AutoReset = true }; + _pingTimer = new Timer { Interval = settings.AniDb.UDPPingFrequency * 1000, Enabled = true, AutoReset = true }; _pingTimer.Elapsed += PingTimerElapsed; _logoutTimer = new Timer { Interval = LogoutPeriod, Enabled = true, AutoReset = false }; _logoutTimer.Elapsed += LogoutTimerElapsed; @@ -150,12 +159,12 @@ private async Task InitInternal() IsAlive = true; } - private void PingTimerElapsed(object sender, ElapsedEventArgs e) + private void PingTimerElapsed(object? sender, ElapsedEventArgs e) { try { if (!_isLoggedOn) return; - if (_socketHandler.IsLocked || !_socketHandler.IsConnected) return; + if (_socketHandler == null || _socketHandler.IsLocked || !_socketHandler.IsConnected) return; if (IsBanned || BackoffSecs.HasValue) return; var ping = _requestFactory.Create<RequestPing>(); @@ -163,11 +172,11 @@ private void PingTimerElapsed(object sender, ElapsedEventArgs e) } catch (UnexpectedUDPResponseException) { - _pingTimer.Stop(); + _pingTimer?.Stop(); } catch (AniDBBannedException) { - _pingTimer.Stop(); + _pingTimer?.Stop(); } catch (Exception exception) { @@ -175,12 +184,12 @@ private void PingTimerElapsed(object sender, ElapsedEventArgs e) } } - private void LogoutTimerElapsed(object sender, ElapsedEventArgs e) + private void LogoutTimerElapsed(object? sender, ElapsedEventArgs e) { try { if (!_isLoggedOn) return; - if (_socketHandler.IsLocked || !_socketHandler.IsConnected) return; + if (_socketHandler == null || _socketHandler.IsLocked || !_socketHandler.IsConnected) return; if (IsBanned || BackoffSecs.HasValue) return; ForceLogout(); @@ -210,12 +219,14 @@ public async Task<string> Send(string command, bool needsUnicode = true) { throw new AniDBBannedException { - BanType = UpdateType.UDPBan, BanExpires = BanTime?.AddHours(BanTimerResetLength) + BanType = UpdateType.UDPBan, + BanExpires = BanTime?.AddHours(BanTimerResetLength) }; } // TODO Low Priority: We need to handle Login Attempt Decay, so that we can try again if it's not just a bad user/pass // It wasn't handled before, and it's not caused serious problems + // login doesn't use this method, so this check won't interfere with it // if we got here, and it's invalid session, then it already failed to re-log if (IsInvalidSession) { @@ -225,51 +236,55 @@ public async Task<string> Send(string command, bool needsUnicode = true) // Check Login State if (!await Login()) { - throw new NotLoggedInException(); + throw new LoginFailedException(); } // Actually Call AniDB return await SendDirectly(command, needsUnicode); } - public Task<string> SendDirectly(string command, bool needsUnicode = true, bool resetPingTimer = true, bool resetLogoutTimer = true) + public Task<string> SendDirectly(string command, bool needsUnicode = true, bool isPing = false, bool isLogout = false) { try { // we want to reset the logout timer anyway - if (resetPingTimer) _pingTimer?.Stop(); - if (resetLogoutTimer) _logoutTimer?.Stop(); + if (!isLogout && !isPing) + { + _pingTimer?.Stop(); + _logoutTimer?.Stop(); + } - return SendInternal(command, needsUnicode); + return SendInternal(command, needsUnicode, isPing); } finally { - if (resetPingTimer) _pingTimer?.Start(); - if (resetLogoutTimer) _logoutTimer?.Start(); + if (!isLogout && !isPing) + { + _pingTimer?.Start(); + _logoutTimer?.Start(); + } } } - private async Task<string> SendInternal(string command, bool needsUnicode = true) + private async Task<string> SendInternal(string command, bool needsUnicode = true, bool isPing = false) { + ObjectDisposedException.ThrowIf(_socketHandler is not { IsConnected: true }, "The connection was closed by shoko"); + // 1. Call AniDB // 2. Decode the response, converting Unicode and decompressing, as needed // 3. Check for an Error Response // 4. Return a pretty response object, with a parsed return code and trimmed string var encoding = needsUnicode ? new UnicodeEncoding(true, false) : Encoding.ASCII; - - if (_socketHandler is not { IsConnected: true }) throw new ObjectDisposedException("The connection was closed by shoko"); - var sendByteAdd = encoding.GetBytes(command); - var timeoutPolicy = Policy - .Handle<SocketException>(e => e is { SocketErrorCode: SocketError.TimedOut}) + .Handle<SocketException>(e => e is { SocketErrorCode: SocketError.TimedOut }) .Or<OperationCanceledException>() .RetryAsync(async (_, _) => { Logger.LogWarning("AniDB request timed out. Checking Network and trying again"); await _connectivityService.CheckAvailability(); }); - var result = await timeoutPolicy.ExecuteAndCaptureAsync(async () => await RateLimiter.EnsureRate(async () => + var result = await timeoutPolicy.ExecuteAndCaptureAsync(async () => await RateLimiter.EnsureRateAsync(forceShortDelay: isPing, action: async () => { if (_connectivityService.NetworkAvailability < NetworkAvailability.PartialInternet) { @@ -278,7 +293,6 @@ private async Task<string> SendInternal(string command, bool needsUnicode = true } var start = DateTime.Now; - Logger.LogTrace("AniDB UDP Call: (Using {Unicode}) {Command}", needsUnicode ? "Unicode" : "ASCII", MaskLog(command)); var byReceivedAdd = await _socketHandler.Send(sendByteAdd); @@ -288,7 +302,8 @@ private async Task<string> SendInternal(string command, bool needsUnicode = true IsBanned = true; throw new AniDBBannedException { - BanType = UpdateType.UDPBan, BanExpires = BanTime?.AddHours(BanTimerResetLength) + BanType = UpdateType.UDPBan, + BanExpires = BanTime?.AddHours(BanTimerResetLength) }; } @@ -305,6 +320,7 @@ private async Task<string> SendInternal(string command, bool needsUnicode = true if (result.FinalException != null) { Logger.LogError(result.FinalException, "Failed to send AniDB message"); + throw result.FinalException; } return result.Result; @@ -372,6 +388,14 @@ public void ForceLogout() SessionID = null; } + public void ClearSession() + { + StopPinging(); + IsInvalidSession = false; + _isLoggedOn = false; + SessionID = null; + } + public async Task CloseConnections() { IsNetworkAvailable = false; @@ -399,6 +423,7 @@ public async Task<bool> Login() try { + if (IsBanned) return false; Logger.LogTrace("Failed to login to AniDB. Issuing a Logout command and retrying"); ForceLogout(); return await Login(settings.AniDb.Username, settings.AniDb.Password); @@ -472,7 +497,7 @@ private async Task<UDPResponse<ResponseLogin>> LoginWithFallbacks(string usernam ); return login.Send(); } - catch (UnexpectedUDPResponseException) + catch (Exception e) when (e is UnexpectedUDPResponseException or NotLoggedInException) { Logger.LogTrace( "Received an UnexpectedUDPResponseException on Login. This usually happens because of an unexpected shutdown. Relogging using Unicode"); diff --git a/Shoko.Server/Providers/AniDB/UDP/Connection/RequestLogout.cs b/Shoko.Server/Providers/AniDB/UDP/Connection/RequestLogout.cs index 8cccf9c94..3fb4a8027 100644 --- a/Shoko.Server/Providers/AniDB/UDP/Connection/RequestLogout.cs +++ b/Shoko.Server/Providers/AniDB/UDP/Connection/RequestLogout.cs @@ -21,7 +21,7 @@ public override UDPResponse<Void> Send() } PreExecute(Handler.SessionID); - var rawResponse = Handler.SendDirectly(Command, resetPingTimer: false, resetLogoutTimer: false).Result; + var rawResponse = Handler.SendDirectly(Command, isLogout: true).Result; var response = ParseResponse(rawResponse); var parsedResponse = ParseResponse(response); return parsedResponse; diff --git a/Shoko.Server/Providers/AniDB/UDP/Connection/RequestPing.cs b/Shoko.Server/Providers/AniDB/UDP/Connection/RequestPing.cs index 3ff54ecf3..11d59cd5b 100644 --- a/Shoko.Server/Providers/AniDB/UDP/Connection/RequestPing.cs +++ b/Shoko.Server/Providers/AniDB/UDP/Connection/RequestPing.cs @@ -28,7 +28,7 @@ protected override void PreExecute(string sessionID) public override UDPResponse<Void> Send() { - var rawResponse = Handler.SendDirectly(BaseCommand, resetPingTimer: false, resetLogoutTimer: false).Result; + var rawResponse = Handler.SendDirectly(BaseCommand, isPing: true).Result; var response = ParseResponse(rawResponse, true); var parsedResponse = ParseResponse(response); return parsedResponse; diff --git a/Shoko.Server/Providers/AniDB/UDP/Exceptions/LoginFailedException.cs b/Shoko.Server/Providers/AniDB/UDP/Exceptions/LoginFailedException.cs new file mode 100644 index 000000000..ed51ba192 --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/Exceptions/LoginFailedException.cs @@ -0,0 +1,9 @@ +using System; +using Shoko.Server.Services.ErrorHandling; + +namespace Shoko.Server.Providers.AniDB.UDP.Exceptions; + +[SentryIgnore] +public class LoginFailedException : Exception +{ +} diff --git a/Shoko.Server/Providers/AniDB/UDP/Generic/UDPRequest.cs b/Shoko.Server/Providers/AniDB/UDP/Generic/UDPRequest.cs index 1ffa13407..f57b7189a 100644 --- a/Shoko.Server/Providers/AniDB/UDP/Generic/UDPRequest.cs +++ b/Shoko.Server/Providers/AniDB/UDP/Generic/UDPRequest.cs @@ -115,10 +115,8 @@ protected virtual UDPResponse<string> ParseResponse(string response, bool return switch (status) { - // 506 INVALID SESSION // 505 ILLEGAL INPUT OR ACCESS DENIED // reset login status to start again - case UDPReturnCode.INVALID_SESSION: case UDPReturnCode.ILLEGAL_INPUT_OR_ACCESS_DENIED: Handler.IsInvalidSession = true; throw new NotLoggedInException(); @@ -136,8 +134,17 @@ protected virtual UDPResponse<string> ParseResponse(string response, bool return Handler.StartBackoffTimer(300, errorMessage); break; } + // 506 INVALID SESSION + // 598 UNKNOWN COMMAND + case UDPReturnCode.INVALID_SESSION: case UDPReturnCode.UNKNOWN_COMMAND: - throw new UnexpectedUDPResponseException(response: response, code: status, request: Command); + if (status == UDPReturnCode.UNKNOWN_COMMAND) + { + Logger.LogWarning("AniDB returned \"UNKNOWN COMMAND\" which likely means your session has expired." + + "Please check your router's settings for how long it keeps track of active connections and adjust UDPPingFrequency in the settings accordingly"); + } + Handler.ClearSession(); + throw new NotLoggedInException(); } if (truncated) diff --git a/Shoko.Server/Providers/AniDB/UDP/Info/RequestGetCreator.cs b/Shoko.Server/Providers/AniDB/UDP/Info/RequestGetCreator.cs new file mode 100644 index 000000000..2cfa2dbb9 --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/Info/RequestGetCreator.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.Exceptions; +using Shoko.Server.Providers.AniDB.UDP.Generic; + +#nullable enable +namespace Shoko.Server.Providers.AniDB.UDP.Info; + +/// <summary> +/// Get File Info. Getting the file info will only return any data if the hashes match +/// If there is MyList info, it will also return that +/// </summary> +public class RequestGetCreator : UDPRequest<ResponseGetCreator?> +{ + // These are dependent on context + protected override string BaseCommand => $"CREATOR creatorid={CreatorID}"; + + public int CreatorID { get; set; } + + protected override UDPResponse<ResponseGetCreator?> ParseResponse(UDPResponse<string> response) + { + var code = response.Code; + var receivedData = response.Response; + if (code is UDPReturnCode.NO_SUCH_CREATOR) + return new UDPResponse<ResponseGetCreator?> { Code = code }; + if (code is not UDPReturnCode.CREATOR) + throw new UnexpectedUDPResponseException(code, receivedData, Command); + + // Note: creator name will always be in 'ja', while transcription will be in 'x-jat'. if the creator don't have any names in those languages then they will be sent blank. + // {int creator id}|{str creator name kanji}|{str creator name transcription}|{int type}|{str pic_name}|{str url_english}|{str url_japanese}|{str wiki_url_english}|{str wiki_url_japanese}|{int last update date} + var parts = receivedData.Split('|').Select(a => a.Trim()).ToArray(); + if (parts.Length < 10) + { + throw new UnexpectedUDPResponseException("There were the wrong number of data columns", code, + receivedData, Command); + } + + if (!int.TryParse(parts[0], out var creatorID)) + throw new UnexpectedUDPResponseException("Creator ID was not an int", code, receivedData, Command); + + if (!int.TryParse(parts[3], out var creatorType)) + throw new UnexpectedUDPResponseException("Creator type was not an int", code, receivedData, Command); + + if (!int.TryParse(parts[9], out var lastUpdated)) + throw new UnexpectedUDPResponseException("Last updated date was not an int", code, receivedData, Command); + + var name = parts[1].Replace("`", "'"); + var transcribedName = parts[2].Replace("`", "'"); + var picName = string.IsNullOrEmpty(parts[4]) ? null : parts[4]; + var urlEnglish = string.IsNullOrEmpty(parts[5]) ? null : parts[5]; + var urlJapanese = string.IsNullOrEmpty(parts[6]) ? null : parts[6]; + var wikiUrlEnglish = string.IsNullOrEmpty(parts[7]) ? null : parts[7]; + var wikiUrlJapanese = string.IsNullOrEmpty(parts[8]) ? null : parts[8]; + var lastUpdatedAt = DateTime.UnixEpoch.AddSeconds(lastUpdated).ToLocalTime(); + return new UDPResponse<ResponseGetCreator?> + { + Code = code, + Response = new ResponseGetCreator + { + ID = creatorID, + Name = transcribedName, + OriginalName = name, + Type = (CreatorType)creatorType, + ImagePath = picName, + EnglishHomepageUrl = urlEnglish, + JapaneseHomepageUrl = urlJapanese, + EnglishWikiUrl = wikiUrlEnglish, + JapaneseWikiUrl = wikiUrlJapanese, + LastUpdateAt = lastUpdatedAt, + }, + }; + } + + public RequestGetCreator(ILoggerFactory loggerFactory, IUDPConnectionHandler handler) : base(loggerFactory, handler) + { + } +} diff --git a/Shoko.Server/Providers/AniDB/UDP/Info/RequestGetEpisode.cs b/Shoko.Server/Providers/AniDB/UDP/Info/RequestGetEpisode.cs index 35edbb4d6..f8c545e64 100644 --- a/Shoko.Server/Providers/AniDB/UDP/Info/RequestGetEpisode.cs +++ b/Shoko.Server/Providers/AniDB/UDP/Info/RequestGetEpisode.cs @@ -13,7 +13,7 @@ namespace Shoko.Server.Providers.AniDB.UDP.Info; public class RequestGetEpisode : UDPRequest<ResponseGetEpisode> { // These are dependent on context - protected override string BaseCommand => $"Episode eid={EpisodeID}"; + protected override string BaseCommand => $"EPISODE eid={EpisodeID}"; public int EpisodeID { get; set; } diff --git a/Shoko.Server/Providers/AniDB/UDP/Info/ResponseGetCreator.cs b/Shoko.Server/Providers/AniDB/UDP/Info/ResponseGetCreator.cs new file mode 100644 index 000000000..01d2eb039 --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/Info/ResponseGetCreator.cs @@ -0,0 +1,64 @@ +using System; + +#nullable enable +namespace Shoko.Server.Providers.AniDB.UDP.Info; + +/// <summary> +/// Response to the GetCreator UDP command. +/// </summary> +public class ResponseGetCreator +{ + /// <summary> + /// The ID of the creator. + /// </summary> + public int ID { get; set; } + + /// <summary> + /// The name of the creator, transcribed to use the latin alphabet. Will + /// always be the 'x-jat' language for the creator, if set. Otherwise, it + /// will be an empty string. + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// The original name of the creator. Will always be the 'ja' language for + /// the creator, if set. Otherwise, it will be an empty string. + /// </summary> + public string OriginalName { get; set; } = string.Empty; + + /// <summary> + /// The type of creator. + /// </summary> + public CreatorType Type { get; set; } + + /// <summary> + /// The location of the image associated with the creator. + /// </summary> + public string? ImagePath { get; set; } + + /// <summary> + /// The URL of the creator's English homepage. + /// </summary> + public string? EnglishHomepageUrl { get; set; } + + /// <summary> + /// The URL of the creator's Japanese homepage. + /// </summary> + public string? JapaneseHomepageUrl { get; set; } + + /// <summary> + /// The URL of the creator's English Wikipedia page. + /// </summary> + public string? EnglishWikiUrl { get; set; } + + /// <summary> + /// The URL of the creator's Japanese Wikipedia page. + /// </summary> + public string? JapaneseWikiUrl { get; set; } + + /// <summary> + /// The date that the creator was last updated on AniDB. + /// </summary> + public DateTime LastUpdateAt { get; set; } +} + diff --git a/Shoko.Server/Providers/AniDB/UDP/UDPRateLimiter.cs b/Shoko.Server/Providers/AniDB/UDP/UDPRateLimiter.cs index e4993d760..a0df46c22 100644 --- a/Shoko.Server/Providers/AniDB/UDP/UDPRateLimiter.cs +++ b/Shoko.Server/Providers/AniDB/UDP/UDPRateLimiter.cs @@ -1,15 +1,12 @@ using Microsoft.Extensions.Logging; +using Shoko.Plugin.Abstractions; + +using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider; namespace Shoko.Server.Providers.AniDB.UDP; public class UDPRateLimiter : AniDBRateLimiter { - protected override int ShortDelay { get; init; } = 2000; - protected override int LongDelay { get; init; } = 4000; - protected override long shortPeriod { get; init; } = 3600000; - protected override long resetPeriod { get; init; } = 1800000; - - public UDPRateLimiter(ILogger<UDPRateLimiter> logger) : base(logger) - { - } + public UDPRateLimiter(ILogger<UDPRateLimiter> logger, ISettingsProvider settingsProvider, IShokoEventHandler eventHandler) + : base(logger, settingsProvider, eventHandler, s => s.AniDb.UDPRateLimit) { } } diff --git a/Shoko.Server/Providers/AniDB/UDP/User/RequestAcknowledgeNotify.cs b/Shoko.Server/Providers/AniDB/UDP/User/RequestAcknowledgeNotify.cs new file mode 100644 index 000000000..d60ef4818 --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/User/RequestAcknowledgeNotify.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.Exceptions; +using Shoko.Server.Providers.AniDB.UDP.Generic; +using Shoko.Server.Server; + +namespace Shoko.Server.Providers.AniDB.UDP.User; + +/// <summary> +/// Acknowledge a notification or message +/// </summary> +public class RequestAcknowledgeNotify : UDPRequest<Void> +{ + public AniDBNotifyType Type { get; set; } + public int ID { get; set; } + + protected override string BaseCommand => $"NOTIFYACK type={(Type == AniDBNotifyType.Message ? "M" : "N")}&id={ID}"; + + protected override UDPResponse<Void> ParseResponse(UDPResponse<string> response) + { + var code = response.Code; + switch (code) + { + case UDPReturnCode.NOTIFYACK_SUCCESSFUL_MESSAGE: + case UDPReturnCode.NOTIFYACK_SUCCESSFUL_NOTIFIATION: + case UDPReturnCode.NO_SUCH_MESSAGE: + case UDPReturnCode.NO_SUCH_NOTIFY: + return new UDPResponse<Void> { Code = code }; + default: + throw new UnexpectedUDPResponseException(code, response.Response, Command); + } + } + + public RequestAcknowledgeNotify(ILoggerFactory loggerFactory, IUDPConnectionHandler handler) : base(loggerFactory, handler) + { + } +} diff --git a/Shoko.Server/Providers/AniDB/UDP/User/RequestGetMessageContent.cs b/Shoko.Server/Providers/AniDB/UDP/User/RequestGetMessageContent.cs new file mode 100644 index 000000000..c0cbcf010 --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/User/RequestGetMessageContent.cs @@ -0,0 +1,109 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.Exceptions; +using Shoko.Server.Providers.AniDB.UDP.Generic; +using Shoko.Server.Server; + +namespace Shoko.Server.Providers.AniDB.UDP.User; + +/// <summary> +/// Get data about a specific message +/// </summary> +public class RequestGetMessageContent : UDPRequest<ResponseMessageContent> +{ + private static readonly Regex BreakRegex = new(@"\<br ?\/?\>", RegexOptions.Compiled); + + /// <summary> + /// Message ID + /// </summary> + public int ID { get; set; } + + protected override string BaseCommand => $"NOTIFYGET type=M&id={ID}"; + + protected override UDPResponse<ResponseMessageContent> ParseResponse(UDPResponse<string> response) + { + var code = response.Code; + var receivedData = response.Response; + + switch (code) + { + case UDPReturnCode.NOTIFYGET_MESSAGE: + { + // {int4 id}|{int4 from_user_id}|{str from_user_name}|{int4 date}|{int4 type}|{str title}|{str body} + /* + id is the message identifier + from_user_id and from_user_name are the id and name of the user who sent the message + date is the time the message was sent + type is the type of message (0=normal msg, 1=annonymous, 2=system msg, 3=mod msg) + title and body are the message title and body + */ + var parts = receivedData.Split('|').Select(a => a.Trim()).ToArray(); + if (parts.Length != 7) + { + throw new UnexpectedUDPResponseException("Incorrect Number of Parts Returned", code, receivedData, Command); + } + + if (!int.TryParse(parts[0], out var msgID)) + { + throw new UnexpectedUDPResponseException("Message ID was not an int", code, receivedData, Command); + } + + if (!int.TryParse(parts[1], out var senderID)) + { + throw new UnexpectedUDPResponseException("Sender ID was not an int", code, receivedData, Command); + } + var senderName = parts[2]; + + if (!int.TryParse(parts[3], out var sentTime)) + { + throw new UnexpectedUDPResponseException("Date was not an int", code, receivedData, Command); + } + var sentDateTime = DateTime.UnixEpoch.AddSeconds(sentTime).ToLocalTime(); + + if (!int.TryParse(parts[4], out var msgTypeI)) + { + throw new UnexpectedUDPResponseException("Type was not an int", code, receivedData, Command); + } + + if (msgTypeI < 0 || msgTypeI > 3) + { + throw new UnexpectedUDPResponseException("Type was not in 0-3 range", code, receivedData, Command); + } + var msgType = (AniDBMessageType)msgTypeI; + + var msgTitle = parts[5].Trim(); + var msgBody = BreakRegex.Replace(parts[6], "\n").Trim(); + + return new UDPResponse<ResponseMessageContent> + { + Code = code, + Response = new ResponseMessageContent + { + ID = msgID, + SenderID = senderID, + SenderName = senderName, + SentTime = sentDateTime, + Type = msgType, + Title = msgTitle, + Body = msgBody + } + }; + } + case UDPReturnCode.NO_SUCH_MESSAGE: + { + return new UDPResponse<ResponseMessageContent> { Code = code, Response = null }; + } + default: + { + throw new UnexpectedUDPResponseException(code, receivedData, Command); + } + } + } + + public RequestGetMessageContent(ILoggerFactory loggerFactory, IUDPConnectionHandler handler) : base(loggerFactory, handler) + { + } +} diff --git a/Shoko.Server/Providers/AniDB/UDP/User/RequestGetNotifyCount.cs b/Shoko.Server/Providers/AniDB/UDP/User/RequestGetNotifyCount.cs new file mode 100644 index 000000000..03a253abe --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/User/RequestGetNotifyCount.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.Generic; +using Shoko.Server.Providers.AniDB.UDP.Exceptions; + +namespace Shoko.Server.Providers.AniDB.UDP.User; + +/// <summary> +/// Get number of pending notifications and unread messages +/// </summary> +public class RequestGetNotifyCount : UDPRequest<ResponseNotificationCount> +{ + /// <summary> + /// Fetch online buddy count + /// </summary> + public bool Buddies { get; set; } + + protected override string BaseCommand => $"NOTIFY buddy={(Buddies ? '1' : '0')}"; + + protected override UDPResponse<ResponseNotificationCount> ParseResponse(UDPResponse<string> response) + { + var code = response.Code; + if (code != UDPReturnCode.NOTIFICATION_STATE) + { + throw new UnexpectedUDPResponseException(code, response.Response, Command); + } + + var receivedData = response.Response; + + // {int4 pending_file_notifications}|{int4 number_of_unread_messages} + // Or when Buddies is true + // {int4 pending_file_notifications}|{int4 number_of_unread_messages}|{int4 number_of_online_buddies} + var parts = receivedData.Split('|'); + if ((Buddies && parts.Length != 3) || (!Buddies && parts.Length != 2)) + { + throw new UnexpectedUDPResponseException("Incorrect Number of Parts Returned", code, receivedData, Command); + } + + if (!int.TryParse(parts[0], out var files)) + { + throw new UnexpectedUDPResponseException("Pending File Notifications was not an int", code, receivedData, Command); + } + + if (!int.TryParse(parts[1], out var messages)) + { + throw new UnexpectedUDPResponseException("Unread Messages was not an int", code, receivedData, Command); + } + + int? buddiesOnline = null; + if (parts.Length == 3) + { + if (!int.TryParse(parts[2], out var value)) + { + throw new UnexpectedUDPResponseException("Online Buddies was not an int", code, receivedData, Command); + } + buddiesOnline = value; + } + + return new UDPResponse<ResponseNotificationCount> + { + Code = code, + Response = new ResponseNotificationCount + { + Files = files, + Messages = messages, + BuddiesOnline = buddiesOnline + } + }; + } + + public RequestGetNotifyCount(ILoggerFactory loggerFactory, IUDPConnectionHandler handler) : base(loggerFactory, handler) + { + } +} diff --git a/Shoko.Server/Providers/AniDB/UDP/User/RequestGetNotifyList.cs b/Shoko.Server/Providers/AniDB/UDP/User/RequestGetNotifyList.cs new file mode 100644 index 000000000..17a24481b --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/User/RequestGetNotifyList.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.Generic; +using Shoko.Server.Providers.AniDB.UDP.Exceptions; +using Shoko.Server.Server; + +namespace Shoko.Server.Providers.AniDB.UDP.User; + +/// <summary> +/// Get IDs of pending notifications and unread messages +/// </summary> +public class RequestGetNotifyList : UDPRequest<IList<ResponseNotifyId>> +{ + + protected override string BaseCommand => "NOTIFYLIST"; + + protected override UDPResponse<IList<ResponseNotifyId>> ParseResponse(UDPResponse<string> response) + { + var code = response.Code; + if (code != UDPReturnCode.NOTIFYLIST) + { + throw new UnexpectedUDPResponseException(code, response.Response, Command); + } + + var receivedData = response.Response; + + if (receivedData.Length == 0) + { + return new UDPResponse<IList<ResponseNotifyId>> + { + Code = code, + Response = System.Array.Empty<ResponseNotifyId>() + }; + } + + string[] lines; + if (receivedData.Contains('\n')) + { + lines = receivedData.Split('\n'); + } + else + { + lines = new[] { receivedData }; + } + + var notifications = new List<ResponseNotifyId>(); + foreach (var line in lines) + { + // {str type}|{int4 id} + /* + type = M for message, N for notification + id = ID of the message or notification + */ + var parts = line.Split("|"); + + if (parts.Length != 2) + { + throw new UnexpectedUDPResponseException("Incorrect Number of Parts Returned", code, receivedData, Command); + } + + if (!int.TryParse(parts[1], out var id)) + { + throw new UnexpectedUDPResponseException("ID was not an int", code, receivedData, Command); + } + + var typeStr = parts[0]; + if (typeStr.Equals("M")) + { + notifications.Add( + new() + { + Type = AniDBNotifyType.Message, + ID = id + } + ); + } + else if (typeStr.Equals("N")) + { + notifications.Add( + new() + { + Type = AniDBNotifyType.Notification, + ID = id + } + ); + } + else + { + throw new UnexpectedUDPResponseException("Type was not M or N", code, receivedData, Command); + } + } + + return new UDPResponse<IList<ResponseNotifyId>> + { + Code = code, + Response = notifications + }; + } + + public RequestGetNotifyList(ILoggerFactory loggerFactory, IUDPConnectionHandler handler) : base(loggerFactory, handler) + { + } +} diff --git a/Shoko.Server/Providers/AniDB/UDP/User/RequestVoteAnime.cs b/Shoko.Server/Providers/AniDB/UDP/User/RequestVoteAnime.cs index a7cd75299..e6c0ae18d 100644 --- a/Shoko.Server/Providers/AniDB/UDP/User/RequestVoteAnime.cs +++ b/Shoko.Server/Providers/AniDB/UDP/User/RequestVoteAnime.cs @@ -28,7 +28,7 @@ public class RequestVoteAnime : UDPRequest<ResponseVote> /// </summary> public bool Temporary { get; set; } - protected override string BaseCommand => $"VOTE type={(Temporary ? 2 : 1)}&aid={AnimeID}&value={AniDBValue}"; + protected override string BaseCommand => $"VOTE type={(Temporary ? 2 : 1)}&id={AnimeID}&value={AniDBValue}"; protected override UDPResponse<ResponseVote> ParseResponse(UDPResponse<string> response) { diff --git a/Shoko.Server/Providers/AniDB/UDP/User/ResponseMessageContent.cs b/Shoko.Server/Providers/AniDB/UDP/User/ResponseMessageContent.cs new file mode 100644 index 000000000..402d62641 --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/User/ResponseMessageContent.cs @@ -0,0 +1,42 @@ +using System; +using Shoko.Server.Server; + +namespace Shoko.Server.Providers.AniDB.UDP.User; + +public class ResponseMessageContent +{ + /// <summary> + /// Message ID + /// </summary> + public int ID { get; set; } + + /// <summary> + /// Sender's ID + /// </summary> + public int SenderID { get; set; } + + /// <summary> + /// Sender's name + /// </summary> + public string SenderName { get; set; } + + /// <summary> + /// Time at which the message has been sent + /// </summary> + public DateTime SentTime { get; set; } + + /// <summary> + /// Type of message + /// </summary> + public AniDBMessageType Type { get; set; } + + /// <summary> + /// Message title + /// </summary> + public string Title { get; set; } + + /// <summary> + /// Message body/content + /// </summary> + public string Body { get; set; } +} diff --git a/Shoko.Server/Providers/AniDB/UDP/User/ResponseNotifyCount.cs b/Shoko.Server/Providers/AniDB/UDP/User/ResponseNotifyCount.cs new file mode 100644 index 000000000..3d029203f --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/User/ResponseNotifyCount.cs @@ -0,0 +1,19 @@ +namespace Shoko.Server.Providers.AniDB.UDP.User; + +public class ResponseNotificationCount +{ + /// <summary> + /// Number of pending file notifications + /// </summary> + public int Files { get; set; } + + /// <summary> + /// Number of unread messages + /// </summary> + public int Messages { get; set; } + + /// <summary> + /// Number of online buddies + /// </summary> + public int? BuddiesOnline { get; set; } +} diff --git a/Shoko.Server/Providers/AniDB/UDP/User/ResponseNotifyId.cs b/Shoko.Server/Providers/AniDB/UDP/User/ResponseNotifyId.cs new file mode 100644 index 000000000..3021d40b6 --- /dev/null +++ b/Shoko.Server/Providers/AniDB/UDP/User/ResponseNotifyId.cs @@ -0,0 +1,16 @@ +using Shoko.Server.Server; + +namespace Shoko.Server.Providers.AniDB.UDP.User; + +public class ResponseNotifyId +{ + /// <summary> + /// Notify type + /// </summary> + public AniDBNotifyType Type { get; set; } + + /// <summary> + /// Notification/message id + /// </summary> + public int ID { get; set; } +} diff --git a/Shoko.Server/Providers/JMMAutoUpdates/JMMAutoUpdatesHelper.cs b/Shoko.Server/Providers/JMMAutoUpdates/JMMAutoUpdatesHelper.cs deleted file mode 100644 index 1c160cd27..000000000 --- a/Shoko.Server/Providers/JMMAutoUpdates/JMMAutoUpdatesHelper.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Xml; -using NLog; -using Shoko.Commons.Utils; -using Shoko.Server.Server; - -namespace Shoko.Server.Providers.JMMAutoUpdates; - -public class JMMAutoUpdatesHelper -{ - private static Logger logger = LogManager.GetCurrentClassLogger(); - - - public static long ConvertToAbsoluteVersion(string version) - { - var numbers = version.Split('.'); - if (numbers.Length != 4) - { - return 0; - } - - return int.Parse(numbers[3]) * 100 + - int.Parse(numbers[2]) * 100 * 100 + - int.Parse(numbers[1]) * 100 * 100 * 100 + - int.Parse(numbers[0]) * 100 * 100 * 100 * 100; - } - - /* - public static Providers.JMMAutoUpdates.JMMVersions GetLatestVersionInfo() - { - try - { - // get the latest version as according to the release - string uri = string.Format("http://shokoanime.com/files/versions.xml"); - string xml = AniDBAPI.APIUtils.DownloadWebPage(uri); - - XmlSerializer x = new XmlSerializer(typeof(Providers.JMMAutoUpdates.JMMVersions)); - Providers.JMMAutoUpdates.JMMVersions myTest = - (Providers.JMMAutoUpdates.JMMVersions) x.Deserialize(new StringReader(xml)); - ServerState.Instance.ApplicationVersionLatest = myTest.versions.ServerVersionFriendly; - - return myTest; - } - catch (Exception ex) - { - logger.Error( ex,ex.ToString()); - return null; - } - }*/ - - public static string GetLatestVersionNumber(string channel) - { - var versionNumber = string.Empty; - try - { - // get the latest version as according to the release - var uri = "http://shokoanime.com/files/versions.xml"; - var xml = Misc.DownloadWebPage(uri, null, true); - - var xmldoc = new XmlDocument(); - xmldoc.LoadXml(xml); - // Load something into xmldoc - var nodeVersion = xmldoc.SelectSingleNode( - string.Format("//versioncheck/shokoserver/{0}/version", channel.ToLower())); - versionNumber = nodeVersion.InnerText; - ServerState.Instance.ApplicationVersionLatest = versionNumber; - } - catch (Exception ex) - { - logger.Error("Error during GetLatestVersionNumber: " + ex.Message); - } - - return versionNumber; - } -} diff --git a/Shoko.Server/Providers/JMMAutoUpdates/JMMVersions.cs b/Shoko.Server/Providers/JMMAutoUpdates/JMMVersions.cs deleted file mode 100644 index 95d3b2dca..000000000 --- a/Shoko.Server/Providers/JMMAutoUpdates/JMMVersions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Xml.Serialization; - -namespace Shoko.Server.Providers.JMMAutoUpdates; - -/// <remarks/> -[XmlType(AnonymousType = true)] -[XmlRoot("jmmversions", Namespace = "", IsNullable = false)] -public class JMMVersions -{ - [XmlElement("versions")] public Versions versions { get; set; } - - [XmlElement("updates")] public Updates updates { get; set; } -} diff --git a/Shoko.Server/Providers/JMMAutoUpdates/Update.cs b/Shoko.Server/Providers/JMMAutoUpdates/Update.cs deleted file mode 100644 index 7156a01f8..000000000 --- a/Shoko.Server/Providers/JMMAutoUpdates/Update.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Xml.Serialization; - -namespace Shoko.Server.Providers.JMMAutoUpdates; - -[XmlType(AnonymousType = true)] -public class Update -{ - /// <remarks/> - public string version { get; set; } - - /// <remarks/> - public string change { get; set; } - - public override string ToString() - { - return string.Format("{0} - {1}", version, change); - } - - public long VersionAbs => JMMAutoUpdatesHelper.ConvertToAbsoluteVersion(version); -} diff --git a/Shoko.Server/Providers/JMMAutoUpdates/Updates.cs b/Shoko.Server/Providers/JMMAutoUpdates/Updates.cs deleted file mode 100644 index 7bcb95205..000000000 --- a/Shoko.Server/Providers/JMMAutoUpdates/Updates.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using System.Xml.Serialization; - -namespace Shoko.Server.Providers.JMMAutoUpdates; - -[XmlType(AnonymousType = true)] -public class Updates -{ - /// <remarks/> - [XmlArrayItem("update", IsNullable = false)] - public List<Update> server { get; set; } - - /// <remarks/> - [XmlArrayItem("update", IsNullable = false)] - public List<Update> desktop { get; set; } -} diff --git a/Shoko.Server/Providers/JMMAutoUpdates/Versions.cs b/Shoko.Server/Providers/JMMAutoUpdates/Versions.cs deleted file mode 100644 index 58ae378e3..000000000 --- a/Shoko.Server/Providers/JMMAutoUpdates/Versions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Xml.Serialization; - -namespace Shoko.Server.Providers.JMMAutoUpdates; - -[XmlType(AnonymousType = true)] -public class Versions -{ - /// <remarks/> - public string serverversion { get; set; } - - /// <remarks/> - public string desktopversion { get; set; } - - public override string ToString() - { - return string.Format("Server: {0} --- Desktop: {1}", serverversion, desktopversion); - } - - public long ServerVersionAbs => JMMAutoUpdatesHelper.ConvertToAbsoluteVersion(serverversion); - - public string ServerVersionFriendly => serverversion; - - public long DesktopVersionAbs => JMMAutoUpdatesHelper.ConvertToAbsoluteVersion(desktopversion); -} diff --git a/Shoko.Server/Providers/MovieDB/MovieDBHelper.cs b/Shoko.Server/Providers/MovieDB/MovieDBHelper.cs deleted file mode 100644 index a399882c2..000000000 --- a/Shoko.Server/Providers/MovieDB/MovieDBHelper.cs +++ /dev/null @@ -1,280 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using System.Web; -using Microsoft.Extensions.Logging; -using Quartz; -using Shoko.Commons.Extensions; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Extensions; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling; -using Shoko.Server.Scheduling.Jobs.Actions; -using Shoko.Server.Scheduling.Jobs.TMDB; -using Shoko.Server.Utilities; -using TMDbLib.Client; - -namespace Shoko.Server.Providers.MovieDB; - -public class MovieDBHelper -{ - private readonly ILogger<MovieDBHelper> _logger; - private readonly ISchedulerFactory _schedulerFactory; - private readonly DatabaseFactory _databaseFactory; - private readonly JobFactory _jobFactory; - private const string APIKey = "8192e8032758f0ef4f7caa1ab7b32dd3"; - - public MovieDBHelper(ILogger<MovieDBHelper> logger, ISchedulerFactory schedulerFactory, DatabaseFactory databaseFactory, JobFactory jobFactory) - { - _logger = logger; - _schedulerFactory = schedulerFactory; - _databaseFactory = databaseFactory; - _jobFactory = jobFactory; - } - - private async Task SaveMovieToDatabase(MovieDB_Movie_Result searchResult, bool saveImages, bool isTrakt) - { - var scheduler = await _schedulerFactory.GetScheduler(); - // save to the DB - var movie = RepoFactory.MovieDb_Movie.GetByOnlineID(searchResult.MovieID) ?? new MovieDB_Movie(); - movie.Populate(searchResult); - - // Only save movie info if source is not trakt, this presents adding tv shows as movies - // Needs better fix later on - - if (!isTrakt) RepoFactory.MovieDb_Movie.Save(movie); - if (!saveImages) return; - - var numFanartDownloaded = 0; - var numPostersDownloaded = 0; - - // save data to the DB and determine the number of images we already have - foreach (var img in searchResult.Images) - { - if (img.ImageType.Equals("poster", StringComparison.InvariantCultureIgnoreCase)) - { - var poster = RepoFactory.MovieDB_Poster.GetByOnlineID(img.URL) ?? new MovieDB_Poster(); - poster.Populate(img, movie.MovieId); - RepoFactory.MovieDB_Poster.Save(poster); - - if (!string.IsNullOrEmpty(poster.GetFullImagePath()) && File.Exists(poster.GetFullImagePath())) numPostersDownloaded++; - } - else - { - // fanart (backdrop) - var fanart = RepoFactory.MovieDB_Fanart.GetByOnlineID(img.URL) ?? new MovieDB_Fanart(); - fanart.Populate(img, movie.MovieId); - RepoFactory.MovieDB_Fanart.Save(fanart); - - if (!string.IsNullOrEmpty(fanart.GetFullImagePath()) && File.Exists(fanart.GetFullImagePath())) numFanartDownloaded++; - } - } - - // download the posters - var settings = Utils.SettingsProvider.GetSettings(); - if (settings.MovieDb.AutoPosters || isTrakt) - { - foreach (var poster in RepoFactory.MovieDB_Poster.GetByMovieID(movie.MovieId)) - { - if (numPostersDownloaded < settings.MovieDb.AutoPostersAmount) - { - // download the image - if (string.IsNullOrEmpty(poster.GetFullImagePath()) || File.Exists(poster.GetFullImagePath())) continue; - - await scheduler.StartJob<DownloadTMDBImageJob>( - c => - { - c.Anime = movie.MovieName; - c.ImageID = poster.MovieDB_PosterID; - c.ImageType = ImageEntityType.MovieDB_Poster; - } - ); - numPostersDownloaded++; - } - else - { - //The MovieDB_AutoPostersAmount should prevent from saving image info without image - // we should clean those image that we didn't download because those don't exist in local repo - // first we check if file was downloaded - if (!File.Exists(poster.GetFullImagePath())) RepoFactory.MovieDB_Poster.Delete(poster.MovieDB_PosterID); - } - } - } - - // download the fanart - if (settings.MovieDb.AutoFanart || isTrakt) - { - foreach (var fanart in RepoFactory.MovieDB_Fanart.GetByMovieID(movie.MovieId)) - { - if (numFanartDownloaded < settings.MovieDb.AutoFanartAmount) - { - // download the image - if (string.IsNullOrEmpty(fanart.GetFullImagePath()) || File.Exists(fanart.GetFullImagePath())) continue; - - await scheduler.StartJob<DownloadTMDBImageJob>( - c => - { - c.Anime = movie.MovieName; - c.ImageID = fanart.MovieDB_FanartID; - c.ImageType = ImageEntityType.MovieDB_FanArt; - } - ); - numFanartDownloaded++; - } - else - { - //The MovieDB_AutoFanartAmount should prevent from saving image info without image - // we should clean those image that we didn't download because those don't exist in local repo - // first we check if file was downloaded - if (!File.Exists(fanart.GetFullImagePath())) RepoFactory.MovieDB_Fanart.Delete(fanart.MovieDB_FanartID); - } - } - } - } - - public async Task<List<MovieDB_Movie_Result>> Search(string criteria) - { - var results = new List<MovieDB_Movie_Result>(); - - try - { - var client = new TMDbClient(APIKey); - var resultsTemp = client.SearchMovie(HttpUtility.UrlDecode(criteria)); - - _logger.LogInformation("Got {Count} of {Results} results", resultsTemp.Results.Count, - resultsTemp.TotalResults); - foreach (var result in resultsTemp.Results) - { - var searchResult = new MovieDB_Movie_Result(); - var movie = client.GetMovie(result.Id); - var imgs = client.GetMovieImages(result.Id); - searchResult.Populate(movie, imgs); - results.Add(searchResult); - await SaveMovieToDatabase(searchResult, false, false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in MovieDB Search"); - } - - return results; - } - - public async Task UpdateAllMovieInfo(bool saveImages) - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - var all = RepoFactory.MovieDb_Movie.GetAll(); - var max = all.Count; - var i = 0; - foreach (var movie in all) - { - try - { - i++; - _logger.LogInformation("Updating MovieDB Movie {I}/{Max}", i, max); - await UpdateMovieInfo(movie.MovieId, saveImages); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to Update MovieDB Movie ID: {Id}", movie.MovieId); - } - } - } - - public async Task UpdateMovieInfo(int movieID, bool saveImages) - { - try - { - var client = new TMDbClient(APIKey); - var movie = client.GetMovie(movieID); - var imgs = client.GetMovieImages(movieID); - - var searchResult = new MovieDB_Movie_Result(); - searchResult.Populate(movie, imgs); - - // save to the DB - await SaveMovieToDatabase(searchResult, saveImages, false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in UpdateMovieInfo"); - } - } - - public async Task LinkAniDBMovieDB(int animeID, int movieDBID, bool fromWebCache) - { - // check if we have this information locally - // if not download it now - var movie = RepoFactory.MovieDb_Movie.GetByOnlineID(movieDBID); - if (movie == null) - { - // we download the series info here just so that we have the basic info in the - // database before the queued task runs later - await UpdateMovieInfo(movieDBID, false); - movie = RepoFactory.MovieDb_Movie.GetByOnlineID(movieDBID); - if (movie == null) return; - } - - // download and update series info and images - await UpdateMovieInfo(movieDBID, true); - - var xref = RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(animeID, CrossRefType.MovieDB) ?? new CrossRef_AniDB_Other(); - - xref.AnimeID = animeID; - xref.CrossRefSource = fromWebCache ? (int)CrossRefSource.WebCache : (int)CrossRefSource.User; - xref.CrossRefType = (int)CrossRefType.MovieDB; - xref.CrossRefID = movieDBID.ToString(); - RepoFactory.CrossRef_AniDB_Other.Save(xref); - _jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = animeID).Process().GetAwaiter().GetResult(); - - _logger.LogTrace("Changed moviedb association: {AnimeID}", animeID); - } - - public void RemoveLinkAniDBMovieDB(int animeID) - { - var xref = - RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(animeID, CrossRefType.MovieDB); - if (xref == null) return; - - // Disable auto-matching when we remove an existing match for the series. - var series = RepoFactory.AnimeSeries.GetByAnimeID(animeID); - if (series != null) - { - series.IsTMDBAutoMatchingDisabled = true; - RepoFactory.AnimeSeries.Save(series, false, true); - } - - RepoFactory.CrossRef_AniDB_Other.Delete(xref.CrossRef_AniDB_OtherID); - } - - public async Task ScanForMatches() - { - var allSeries = RepoFactory.AnimeSeries.GetAll(); - var scheduler = await _schedulerFactory.GetScheduler(); - - foreach (var ser in allSeries) - { - if (ser.IsTMDBAutoMatchingDisabled) continue; - - var anime = ser.AniDB_Anime; - if (anime == null) continue; - - // don't scan if it is associated on the TvDB - if (anime.GetCrossRefTvDB().Count > 0) continue; - - // don't scan if it is associated on the MovieDB - if (anime.CrossRefMovieDB != null) continue; - - // don't scan if it is not a movie - if (!anime.GetSearchOnMovieDB()) continue; - - _logger.LogTrace("Found anime movie without MovieDB association: {MainTitle}", anime.MainTitle); - - await scheduler.StartJob<SearchTMDBSeriesJob>(c => c.AnimeID = ser.AniDB_ID); - } - } -} diff --git a/Shoko.Server/Providers/MovieDB/MovieDB_Image_Result.cs b/Shoko.Server/Providers/MovieDB/MovieDB_Image_Result.cs deleted file mode 100644 index 1910179cb..000000000 --- a/Shoko.Server/Providers/MovieDB/MovieDB_Image_Result.cs +++ /dev/null @@ -1,30 +0,0 @@ -using TMDbLib.Objects.General; - -namespace Shoko.Server.Providers.MovieDB; - -public class MovieDB_Image_Result -{ - public string ImageID { get; set; } - public string ImageType { get; set; } - public string ImageSize { get; set; } - public string URL { get; set; } - public int ImageWidth { get; set; } - public int ImageHeight { get; set; } - - public override string ToString() - { - return string.Format("{0} - {1} - {2}x{3} - {4}", ImageType, ImageSize, ImageWidth, ImageHeight, URL); - } - - public bool Populate(ImageData result, string imgType) - { - ImageID = string.Empty; - ImageType = imgType; - ImageSize = Shoko.Models.Constants.MovieDBImageSize.Original; - URL = result.FilePath; - ImageWidth = result.Width; - ImageHeight = result.Height; - - return true; - } -} diff --git a/Shoko.Server/Providers/MovieDB/MovieDB_Movie_Result.cs b/Shoko.Server/Providers/MovieDB/MovieDB_Movie_Result.cs deleted file mode 100644 index 76d316424..000000000 --- a/Shoko.Server/Providers/MovieDB/MovieDB_Movie_Result.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using NLog; -using Shoko.Models.Client; -using TMDbLib.Objects.General; -using TMDbLib.Objects.Movies; - -namespace Shoko.Server.Providers.MovieDB; - -public class MovieDB_Movie_Result -{ - private static Logger logger = LogManager.GetCurrentClassLogger(); - - public int MovieID { get; set; } - public string MovieName { get; set; } - public string OriginalName { get; set; } - public string Overview { get; set; } - public double Rating { get; set; } - - public List<MovieDB_Image_Result> Images { get; set; } - - public override string ToString() - { - return "MovieDBSearchResult: " + MovieID + ": " + MovieName; - } - - public bool Populate(Movie movie, ImagesWithId imgs) - { - try - { - Images = new List<MovieDB_Image_Result>(); - - MovieID = movie.Id; - MovieName = movie.Title; - OriginalName = movie.Title; - Overview = movie.Overview; - Rating = movie.VoteAverage; - - if (imgs != null && imgs.Backdrops != null) - { - foreach (var img in imgs.Backdrops) - { - var imageResult = new MovieDB_Image_Result(); - if (imageResult.Populate(img, "backdrop")) - { - Images.Add(imageResult); - } - } - } - - if (imgs != null && imgs.Posters != null) - { - foreach (var img in imgs.Posters) - { - var imageResult = new MovieDB_Image_Result(); - if (imageResult.Populate(img, "poster")) - { - Images.Add(imageResult); - } - } - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - return false; - } - - return true; - } - - public CL_MovieDBMovieSearch_Response ToContract() - { - var cl = new CL_MovieDBMovieSearch_Response - { - MovieID = MovieID, MovieName = MovieName, OriginalName = OriginalName, Overview = Overview - }; - return cl; - } -} diff --git a/Shoko.Server/Providers/MovieDB/SyncExtensions.cs b/Shoko.Server/Providers/MovieDB/SyncExtensions.cs deleted file mode 100644 index 9626cd028..000000000 --- a/Shoko.Server/Providers/MovieDB/SyncExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Threading.Tasks; -using TMDbLib.Client; -using TMDbLib.Objects.General; -using TMDbLib.Objects.Movies; -using TMDbLib.Objects.Search; -using TMDbLib.Objects.TvShows; - -namespace Shoko.Server.Providers.MovieDB; - -public static class SyncExtensions -{ - public static Movie GetMovie(this TMDbClient client, int movieID, MovieMethods methods = MovieMethods.Undefined) - { - return Task.Run(async () => await client.GetMovieAsync(movieID, methods)).Result; - } - - public static ImagesWithId GetMovieImages(this TMDbClient client, int movieID) - { - return Task.Run(async () => await client.GetMovieImagesAsync(movieID)).Result; - } - - public static TvShow GetTvShow(this TMDbClient client, int movieID, TvShowMethods method, - string language = null) - { - return Task.Run(async () => await client.GetTvShowAsync(movieID, method, language)).Result; - } - - public static SearchContainer<SearchMovie> SearchMovie(this TMDbClient client, string criteria) - { - return Task.Run(async () => await client.SearchMovieAsync(criteria)).Result; - } -} diff --git a/Shoko.Server/Providers/TMDB/Enums.cs b/Shoko.Server/Providers/TMDB/Enums.cs new file mode 100644 index 000000000..e37543656 --- /dev/null +++ b/Shoko.Server/Providers/TMDB/Enums.cs @@ -0,0 +1,22 @@ + +namespace Shoko.Server.Providers.TMDB; + +public enum AlternateOrderingType +{ + Unknown = 0, + OriginalAirDate = 1, + Absolute = 2, + DVD = 3, + Digital = 4, + StoryArc = 5, + Production = 6, + TV = 7 +} + +public enum PersonGender +{ + Unknown = 0, + Female = 1, + Male = 2, + NonBinary = 3 +} diff --git a/Shoko.Server/Providers/TMDB/TmdbAutoSearchResult.cs b/Shoko.Server/Providers/TMDB/TmdbAutoSearchResult.cs new file mode 100644 index 000000000..727775510 --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbAutoSearchResult.cs @@ -0,0 +1,57 @@ + +using System.Diagnostics.CodeAnalysis; +using Shoko.Server.Models; +using TMDbLib.Objects.Search; + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public class TmdbAutoSearchResult +{ + /// <summary> + /// Indicates that this is a local match using existing data instead of a + /// remote match. + /// </summary> + public bool IsLocal { get; set; } = false; + + /// <summary> + /// Indicates that this is a remote match. + /// </summary> + public bool IsRemote { get; set; } = false; + + [MemberNotNullWhen(true, nameof(AnidbEpisode))] + [MemberNotNullWhen(true, nameof(TmdbMovie))] + [MemberNotNullWhen(false, nameof(TmdbShow))] + public bool IsMovie { get; init; } + + public SVR_AniDB_Anime AnidbAnime { get; init; } + + public SVR_AniDB_Episode? AnidbEpisode { get; init; } + + public SearchTv? TmdbShow { get; init; } + + public SearchMovie? TmdbMovie { get; init; } + + public TmdbAutoSearchResult(SVR_AniDB_Anime anime, SearchTv show) + { + IsMovie = false; + AnidbAnime = anime; + TmdbShow = show; + } + + public TmdbAutoSearchResult(SVR_AniDB_Anime anime, SVR_AniDB_Episode episode, SearchMovie movie) + { + IsMovie = true; + AnidbAnime = anime; + AnidbEpisode = episode; + TmdbMovie = movie; + } + + public TmdbAutoSearchResult(TmdbAutoSearchResult result) + { + AnidbAnime = result.AnidbAnime; + AnidbEpisode = result.AnidbEpisode; + TmdbMovie = result.TmdbMovie; + TmdbShow = result.TmdbShow; + } +} diff --git a/Shoko.Server/Providers/TMDB/TmdbExtensions.cs b/Shoko.Server/Providers/TMDB/TmdbExtensions.cs new file mode 100644 index 000000000..169f6f7fe --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbExtensions.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Commons.Extensions; +using Shoko.Models.Client; +using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models.TMDB; +using TMDbLib.Objects.Movies; +using TMDbLib.Objects.Search; +using TMDbLib.Objects.TvShows; + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public static class TmdbExtensions +{ + private static readonly TimeOnly MidDay = TimeOnly.FromTimeSpan(TimeSpan.FromHours(12)); + + public static List<string> GetGenres(this Movie movie) + => movie.Genres + .SelectMany(genre => genre.Name.Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .OrderBy(genre => genre) + .ToList(); + + public static IReadOnlyList<string> GetGenres(this SearchMovie movie) + { + var instance = TmdbMetadataService.Instance; + if (instance is null) + return []; + + var allMovieGenres = instance.GetMovieGenres().ConfigureAwait(false).GetAwaiter().GetResult(); + return movie.GenreIds + .Select(id => allMovieGenres.TryGetValue(id, out var genre) ? genre : null) + .WhereNotNull() + .SelectMany(genre => genre.Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .OrderBy(genre => genre) + .ToList(); + } + + public static List<string> GetGenres(this TvShow movie) + => movie.Genres + .SelectMany(genre => genre.Name.Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .OrderBy(genre => genre) + .ToList(); + + public static IReadOnlyList<string> GetGenres(this SearchTv show) + { + var instance = TmdbMetadataService.Instance; + if (instance is null) + return []; + + var allShowGenres = instance.GetShowGenres().ConfigureAwait(false).GetAwaiter().GetResult(); + return show.GenreIds + .Select(id => allShowGenres.TryGetValue(id, out var genre) ? genre : null) + .WhereNotNull() + .SelectMany(genre => genre.Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .OrderBy(genre => genre) + .ToList(); + } + + public static CL_MovieDBMovieSearch_Response ToContract(this SearchMovie movie) + => new() + { + MovieID = movie.Id, + MovieName = movie.Title, + OriginalName = movie.OriginalTitle, + Overview = movie.Overview, + }; + + public static DateOnly? GetAirDateAsDateOnly(this AniDB_Episode episode) + { + var dateTime = episode.GetAirDateAsDate(); + if (!dateTime.HasValue) + return null; + + return DateOnly.FromDateTime(dateTime.Value); + } + + public static DateTime ToDateTime(this DateOnly date) + => date.ToDateTime(MidDay, DateTimeKind.Utc); + + public static TMDB_Title? GetByLanguage(this IEnumerable<TMDB_Title> titles, TitleLanguage language) + { + var (languageCode, countryCode) = language.GetLanguageAndCountryCode(); + Func<TMDB_Title, bool> filter = string.IsNullOrEmpty(countryCode) + ? (title => string.Equals(title.LanguageCode, languageCode, StringComparison.OrdinalIgnoreCase)) + : (title => string.Equals(title.LanguageCode, languageCode, StringComparison.OrdinalIgnoreCase) && + string.Equals(title.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)); + return titles.FirstOrDefault(filter); + } + + public static IEnumerable<TMDB_Title> WhereInLanguages(this IEnumerable<TMDB_Title> contentRatings, IReadOnlySet<TitleLanguage>? languages) + { + if (languages is null) + return contentRatings; + + var countyCodes = languages + .Select(c => c.GetLanguageAndCountryCode()) + .Where(c => c.countryCode is not null) + .Select(c => c.countryCode) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var languagesCodes = languages + .Select(c => c.GetLanguageAndCountryCode()) + .Where(c => c.countryCode is null) + .Select(c => c.languageCode) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return contentRatings.Where(cr => languagesCodes.Contains(cr.LanguageCode) || countyCodes.Contains(cr.CountryCode)); + } + + public static TMDB_Overview? GetByLanguage(this IEnumerable<TMDB_Overview> titles, TitleLanguage language) + { + var (languageCode, countryCode) = language.GetLanguageAndCountryCode(); + Func<TMDB_Overview, bool> filter = string.IsNullOrEmpty(countryCode) + ? (title => string.Equals(title.LanguageCode, languageCode, StringComparison.OrdinalIgnoreCase)) + : (title => string.Equals(title.LanguageCode, languageCode, StringComparison.OrdinalIgnoreCase) && + string.Equals(title.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)); + return titles.FirstOrDefault(filter); + } + + public static IEnumerable<TMDB_Overview> WhereInLanguages(this IEnumerable<TMDB_Overview> contentRatings, IReadOnlySet<TitleLanguage>? languages) + { + if (languages is null) + return contentRatings; + + var countyCodes = languages + .Select(c => c.GetLanguageAndCountryCode()) + .Where(c => c.countryCode is not null) + .Select(c => c.countryCode) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var languagesCodes = languages + .Select(c => c.GetLanguageAndCountryCode()) + .Where(c => c.countryCode is null) + .Select(c => c.languageCode) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return contentRatings.Where(cr => languagesCodes.Contains(cr.LanguageCode) || countyCodes.Contains(cr.CountryCode)); + } + + public static IEnumerable<TMDB_ContentRating> WhereInLanguages(this IEnumerable<TMDB_ContentRating> contentRatings, IReadOnlySet<TitleLanguage>? languages) + { + if (languages is null) + return contentRatings; + + var countyCodes = languages + .Select(c => c.GetLanguageAndCountryCode()) + .Where(c => c.countryCode is not null) + .Select(c => c.countryCode) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var languagesCodes = languages + .Select(c => c.GetLanguageAndCountryCode()) + .Where(c => c.countryCode is null) + .Select(c => c.languageCode) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return contentRatings.Where(cr => languagesCodes.Contains(cr.LanguageCode) || countyCodes.Contains(cr.CountryCode)); + } +} diff --git a/Shoko.Server/Providers/TMDB/TmdbImageService.cs b/Shoko.Server/Providers/TMDB/TmdbImageService.cs new file mode 100644 index 000000000..a0dcae28b --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbImageService.cs @@ -0,0 +1,232 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Repositories.Direct; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.TMDB; +using Shoko.Server.Server; +using TMDbLib.Objects.General; + +// Suggestions we don't need in this file. +#pragma warning disable CA1822 +#pragma warning disable CA1826 + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public class TmdbImageService +{ + private readonly ILogger<TmdbImageService> _logger; + + private readonly ISchedulerFactory _schedulerFactory; + + private readonly TMDB_ImageRepository _tmdbImages; + + private readonly AniDB_Anime_PreferredImageRepository _preferredImages; + + public TmdbImageService( + ILogger<TmdbImageService> logger, + ISchedulerFactory schedulerFactory, + TMDB_ImageRepository tmdbImages, + AniDB_Anime_PreferredImageRepository preferredImages + ) + { + _logger = logger; + _schedulerFactory = schedulerFactory; + _tmdbImages = tmdbImages; + _preferredImages = preferredImages; + } + + #region Image + + public async Task DownloadImageByType(string filePath, ImageEntityType type, ForeignEntityType foreignType, int foreignId, bool forceDownload = false) + { + var image = _tmdbImages.GetByRemoteFileNameAndType(filePath, type) ?? new(filePath, type); + image.Populate(foreignType, foreignId); + if (string.IsNullOrEmpty(image.LocalPath)) + return; + + _tmdbImages.Save(image); + + // Skip downloading if it already exists and we're not forcing it. + if (File.Exists(image.LocalPath) && !forceDownload) + return; + + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<DownloadTmdbImageJob>(c => + { + c.ImageID = image.TMDB_ImageID; + c.ImageType = image.ImageType; + c.ForceDownload = forceDownload; + }); + } + + public async Task DownloadImagesByType(IReadOnlyList<ImageData> images, ImageEntityType type, ForeignEntityType foreignType, int foreignId, int maxCount, List<TitleLanguage> languages, bool forceDownload = false) + { + var count = 0; + var isLimitEnabled = maxCount > 0; + if (languages.Count > 0) + images = isLimitEnabled + ? images + .Select(image => (Image: image, Language: (image.Iso_639_1 ?? string.Empty).GetTitleLanguage())) + .Where(x => languages.Contains(x.Language)) + .OrderBy(x => languages.IndexOf(x.Language)) + .Select(x => x.Image) + .ToList() + : images + .Where(x => languages.Contains((x.Iso_639_1 ?? string.Empty).GetTitleLanguage())) + .ToList(); + foreach (var imageData in images) + { + if (isLimitEnabled && count >= maxCount) + break; + + count++; + var image = _tmdbImages.GetByRemoteFileNameAndType(imageData.FilePath, type) ?? new(imageData.FilePath, type); + var updated = image.Populate(imageData, foreignType, foreignId); + if (updated) + _tmdbImages.Save(image); + } + + count = 0; + var scheduler = await _schedulerFactory.GetScheduler(); + var storedImages = _tmdbImages.GetByForeignIDAndType(foreignId, foreignType, type); + if (languages.Count > 0 && isLimitEnabled) + storedImages = storedImages + .OrderBy(x => languages.IndexOf(x.Language) is var index && index >= 0 ? index : int.MaxValue) + .ToList(); + foreach (var image in storedImages) + { + // Clean up invalid entries. + var path = image.LocalPath; + if (string.IsNullOrEmpty(path)) + { + _tmdbImages.Delete(image.TMDB_ImageID); + continue; + } + + // Download image if the limit is disabled or if we're below the limit. + var fileExists = File.Exists(path); + if (!isLimitEnabled || count < maxCount) + { + // Skip downloading if it already exists and we're not forcing it. + count++; + if (fileExists && !forceDownload) + continue; + + // Otherwise scheduled the image to be downloaded. + await scheduler.StartJob<DownloadTmdbImageJob>(c => + { + c.ImageID = image.TMDB_ImageID; + c.ImageType = image.ImageType; + c.ForceDownload = forceDownload; + }); + } + // TODO: check if the image is linked to any other entries, and keep it if the other entries are within the limit. + // Else delete it from the local cache and database. + else + { + if (fileExists) + { + try + { + File.Delete(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete image file: {Path}", path); + } + } + _tmdbImages.Delete(image.TMDB_ImageID); + } + } + } + + public void PurgeImages(ForeignEntityType foreignType, int foreignId, bool removeImageFiles) + { + var imagesToRemove = _tmdbImages.GetByForeignID(foreignId, foreignType); + + _logger.LogDebug( + "Removing {count} images for {type} with id {EntityId}", + imagesToRemove.Count, + foreignType.ToString().ToLowerInvariant(), + foreignId); + foreach (var image in imagesToRemove) + PurgeImage(image, foreignType, removeImageFiles); + } + + public void PurgeImage(TMDB_Image image, ForeignEntityType foreignType, bool removeFile) + { + // Skip the operation if th flag is not set. + if (!image.ForeignType.HasFlag(foreignType)) + return; + + // Disable the flag. + image.ForeignType &= ~foreignType; + + // Only delete the image metadata and/or file if all references were removed. + if (image.ForeignType is ForeignEntityType.None) + { + if (removeFile && !string.IsNullOrEmpty(image.LocalPath) && File.Exists(image.LocalPath)) + File.Delete(image.LocalPath); + + _tmdbImages.Delete(image.TMDB_ImageID); + } + // Remove the ID since we're keeping the metadata a little bit longer. + else + { + switch (foreignType) + { + case ForeignEntityType.Movie: + image.TmdbMovieID = null; + break; + case ForeignEntityType.Episode: + image.TmdbEpisodeID = null; + break; + case ForeignEntityType.Season: + image.TmdbSeasonID = null; + break; + case ForeignEntityType.Show: + image.TmdbShowID = null; + break; + case ForeignEntityType.Collection: + image.TmdbCollectionID = null; + break; + } + } + } + + public void ResetPreferredImage(int anidbAnimeId, ForeignEntityType foreignType, int foreignId) + { + var images = _preferredImages.GetByAnimeID(anidbAnimeId); + foreach (var defaultImage in images) + { + if (defaultImage.ImageSource == DataSourceType.TMDB) + { + var image = _tmdbImages.GetByID(defaultImage.ImageID); + if (image == null) + { + _logger.LogTrace("Removing preferred image for anime {AnimeId} because the preferred image could not be found.", anidbAnimeId); + _preferredImages.Delete(defaultImage); + } + else if (image.ForeignType.HasFlag(foreignType) && image.GetForeignID(foreignType) == foreignId) + { + _logger.LogTrace("Removing preferred image for anime {AnimeId} because it belongs to now TMDB {Type} {Id}", anidbAnimeId, foreignType.ToString(), foreignId); + _preferredImages.Delete(defaultImage); + } + } + } + } + + #endregion +} diff --git a/Shoko.Server/Providers/TMDB/TmdbLinkingService.cs b/Shoko.Server/Providers/TMDB/TmdbLinkingService.cs new file mode 100644 index 000000000..67247287d --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbLinkingService.cs @@ -0,0 +1,760 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.Models; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Repositories.Direct; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.TMDB; +using Shoko.Server.Server; +using Shoko.Server.Utilities; + +using CrossRefSource = Shoko.Models.Enums.CrossRefSource; +using MatchRating = Shoko.Models.Enums.MatchRating; + +// Suggestions we don't need in this file. +#pragma warning disable CA1822 +#pragma warning disable CA1826 + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public class TmdbLinkingService +{ + private readonly ILogger<TmdbLinkingService> _logger; + + private readonly ISchedulerFactory _schedulerFactory; + + private readonly TmdbImageService _imageService; + + private readonly AnimeSeriesRepository _animeSeries; + + private readonly AniDB_AnimeRepository _anidbAnime; + + private readonly AniDB_EpisodeRepository _anidbEpisodes; + + private readonly AniDB_Episode_TitleRepository _anidbEpisodeTitles; + + private readonly TMDB_ShowRepository _tmdbShows; + + private readonly TMDB_EpisodeRepository _tmdbEpisodes; + + private readonly CrossRef_AniDB_TMDB_MovieRepository _xrefAnidbTmdbMovies; + + private readonly CrossRef_AniDB_TMDB_ShowRepository _xrefAnidbTmdbShows; + + private readonly CrossRef_AniDB_TMDB_EpisodeRepository _xrefAnidbTmdbEpisodes; + + public TmdbLinkingService( + ILogger<TmdbLinkingService> logger, + ISchedulerFactory schedulerFactory, + TmdbImageService imageService, + AnimeSeriesRepository animeSeries, + AniDB_AnimeRepository anidbAnime, + AniDB_EpisodeRepository anidbEpisodes, + AniDB_Episode_TitleRepository anidbEpisodeTitles, + TMDB_ShowRepository tmdbShows, + TMDB_EpisodeRepository tmdbEpisodes, + CrossRef_AniDB_TMDB_MovieRepository xrefAnidbTmdbMovies, + CrossRef_AniDB_TMDB_ShowRepository xrefAnidbTmdbShows, + CrossRef_AniDB_TMDB_EpisodeRepository xrefAnidbTmdbEpisodes + ) + { + _logger = logger; + _schedulerFactory = schedulerFactory; + _imageService = imageService; + _animeSeries = animeSeries; + _anidbAnime = anidbAnime; + _anidbEpisodes = anidbEpisodes; + _anidbEpisodeTitles = anidbEpisodeTitles; + _tmdbShows = tmdbShows; + _tmdbEpisodes = tmdbEpisodes; + _xrefAnidbTmdbMovies = xrefAnidbTmdbMovies; + _xrefAnidbTmdbShows = xrefAnidbTmdbShows; + _xrefAnidbTmdbEpisodes = xrefAnidbTmdbEpisodes; + } + + #region Movie Links + + public async Task AddMovieLinkForEpisode(int anidbEpisodeId, int tmdbMovieId, bool additiveLink = false, bool isAutomatic = false) + { + // Remove all existing links. + if (!additiveLink) + await RemoveAllMovieLinksForEpisode(anidbEpisodeId); + + var episode = _anidbEpisodes.GetByEpisodeID(anidbEpisodeId); + if (episode == null) + { + _logger.LogWarning("AniDB Episode (ID:{AnidbID}) not found", anidbEpisodeId); + return; + } + + // Add or update the link. + _logger.LogInformation("Adding TMDB Movie Link: AniDB episode (EpisodeID={EpisodeID},AnimeID={AnimeID}) → TMDB movie (MovieID={TmdbID})", anidbEpisodeId, episode.AnimeID, tmdbMovieId); + var xref = _xrefAnidbTmdbMovies.GetByAnidbEpisodeAndTmdbMovieIDs(anidbEpisodeId, tmdbMovieId) ?? new(anidbEpisodeId, episode.AnimeID, tmdbMovieId); + xref.AnidbAnimeID = episode.AnimeID; + xref.Source = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; + _xrefAnidbTmdbMovies.Save(xref); + } + + public async Task RemoveMovieLinkForEpisode(int anidbEpisodeId, int tmdbMovieId, bool purge = false, bool removeImageFiles = true) + { + var xref = _xrefAnidbTmdbMovies.GetByAnidbEpisodeAndTmdbMovieIDs(anidbEpisodeId, tmdbMovieId); + if (xref == null) + return; + + // Disable auto-matching when we remove an existing match for the series. + if (_anidbEpisodes.GetByEpisodeID(anidbEpisodeId) is { } anidbEpisode && _animeSeries.GetByAnimeID(anidbEpisode.AnimeID) is { } series && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + _animeSeries.Save(series, false, true, true); + } + + await RemoveMovieLink(xref, removeImageFiles, purge); + } + + public async Task RemoveAllMovieLinksForAnime(int anidbAnimeId, bool purge = false, bool removeImageFiles = true) + { + var xrefs = _xrefAnidbTmdbMovies.GetByAnidbAnimeID(anidbAnimeId); + _logger.LogInformation("Removing {Count} TMDB movie links for AniDB anime. (AnimeID={AnimeID})", xrefs.Count, anidbAnimeId); + if (xrefs.Count == 0) + return; + + // Disable auto-matching when we remove an existing match for the series. + if (_animeSeries.GetByAnimeID(anidbAnimeId) is { } series && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + _animeSeries.Save(series, false, true, true); + } + + foreach (var xref in xrefs) + await RemoveMovieLink(xref, removeImageFiles, purge); + } + + public async Task RemoveAllMovieLinksForEpisode(int anidbEpisodeId, bool purge = false, bool removeImageFiles = true) + { + var xrefs = _xrefAnidbTmdbMovies.GetByAnidbEpisodeID(anidbEpisodeId); + _logger.LogInformation("Removing {Count} TMDB movie links for AniDB episode. (EpisodeID={EpisodeID})", xrefs.Count, anidbEpisodeId); + if (xrefs.Count == 0) + return; + + // Disable auto-matching when we remove an existing match for the series. + if (_anidbEpisodes.GetByEpisodeID(anidbEpisodeId) is { } anidbEpisode && _animeSeries.GetByAnimeID(anidbEpisode.AnimeID) is { } series && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + _animeSeries.Save(series, false, true, true); + } + + foreach (var xref in xrefs) + await RemoveMovieLink(xref, removeImageFiles, purge); + } + + public async Task RemoveAllMovieLinksForMovie(int tmdbMovieId) + { + var xrefs = _xrefAnidbTmdbMovies.GetByTmdbMovieID(tmdbMovieId); + _logger.LogInformation("Removing {Count} TMDB movie links for TMDB movie. (MovieID={MovieID})", xrefs.Count, tmdbMovieId); + if (xrefs.Count == 0) + return; + + foreach (var xref in xrefs) + await RemoveMovieLink(xref, false, false); + } + + private async Task RemoveMovieLink(CrossRef_AniDB_TMDB_Movie xref, bool removeImageFiles = true, bool purge = false) + { + _imageService.ResetPreferredImage(xref.AnidbAnimeID, ForeignEntityType.Movie, xref.TmdbMovieID); + + _logger.LogInformation("Removing TMDB movie link: AniDB episode (EpisodeID={EpisodeID},AnimeID={AnimeID}) → TMDB movie (ID:{TmdbID})", xref.AnidbEpisodeID, xref.AnidbAnimeID, xref.TmdbMovieID); + _xrefAnidbTmdbMovies.Delete(xref); + + if (purge) + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<PurgeTmdbMovieJob>(c => + { + c.TmdbMovieID = xref.TmdbMovieID; + c.RemoveImageFiles = removeImageFiles; + }); + } + + #endregion + + #region Show Links + + public async Task AddShowLink(int anidbAnimeId, int tmdbShowId, bool additiveLink = true, bool isAutomatic = false) + { + // Remove all existing links. + if (!additiveLink) + await RemoveAllShowLinksForAnime(anidbAnimeId); + + // Add or update the link. + _logger.LogInformation("Adding TMDB show link: AniDB (AnimeID={AnidbID}) → TMDB Show (ID={TmdbID})", anidbAnimeId, tmdbShowId); + var xref = _xrefAnidbTmdbShows.GetByAnidbAnimeAndTmdbShowIDs(anidbAnimeId, tmdbShowId) ?? + new(anidbAnimeId, tmdbShowId); + xref.Source = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; + _xrefAnidbTmdbShows.Save(xref); + await Task.Run(() => MatchAnidbToTmdbEpisodes(anidbAnimeId, tmdbShowId, null, true, true)); + } + + public async Task RemoveShowLink(int anidbAnimeId, int tmdbShowId, bool purge = false, bool removeImageFiles = true) + { + var xref = _xrefAnidbTmdbShows.GetByAnidbAnimeAndTmdbShowIDs(anidbAnimeId, tmdbShowId); + if (xref == null) + return; + + // Disable auto-matching when we remove an existing match for the series. + var series = _animeSeries.GetByAnimeID(anidbAnimeId); + if (series != null && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + _animeSeries.Save(series, false, true, true); + } + + await RemoveShowLink(xref, removeImageFiles, purge); + } + + public async Task RemoveAllShowLinksForAnime(int animeId, bool purge = false, bool removeImageFiles = true) + { + var xrefs = _xrefAnidbTmdbShows.GetByAnidbAnimeID(animeId); + _logger.LogInformation("Removing {Count} TMDB show links for AniDB anime. (AnimeID={AnimeID})", xrefs.Count, animeId); + if (xrefs.Count == 0) + return; + + // Disable auto-matching when we remove an existing match for the series. + var series = _animeSeries.GetByAnimeID(animeId); + if (series != null && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + _animeSeries.Save(series, false, true, true); + } + + foreach (var xref in xrefs) + await RemoveShowLink(xref, removeImageFiles, purge); + } + + public async Task RemoveAllShowLinksForShow(int showId) + { + var xrefs = _xrefAnidbTmdbShows.GetByTmdbShowID(showId); + if (xrefs.Count == 0) + return; + + foreach (var xref in xrefs) + await RemoveShowLink(xref, false, false); + } + + private async Task RemoveShowLink(CrossRef_AniDB_TMDB_Show xref, bool removeImageFiles = true, bool purge = false) + { + _imageService.ResetPreferredImage(xref.AnidbAnimeID, ForeignEntityType.Show, xref.TmdbShowID); + + _logger.LogInformation("Removing TMDB show link: AniDB anime (AnimeID={AnidbID}) → TMDB show (ID={TmdbID})", xref.AnidbAnimeID, xref.TmdbShowID); + _xrefAnidbTmdbShows.Delete(xref); + + var xrefs = _xrefAnidbTmdbEpisodes.GetOnlyByAnidbAnimeAndTmdbShowIDs(xref.AnidbAnimeID, xref.TmdbShowID).ToList(); + if (_xrefAnidbTmdbShows.GetByAnidbAnimeID(xref.AnidbAnimeID).Count > 0 && _xrefAnidbTmdbEpisodes.GetByAnidbAnimeID(xref.AnidbAnimeID) is { Count: > 0 } extraXrefs) + xrefs.AddRange(extraXrefs); + _logger.LogInformation("Removing {XRefsCount} episodes cross-references for AniDB anime (AnimeID={AnidbID}) and TMDB show (ID={TmdbID})", xrefs.Count, xref.AnidbAnimeID, xref.TmdbShowID); + _xrefAnidbTmdbEpisodes.Delete(xrefs); + + var scheduler = await _schedulerFactory.GetScheduler(); + if (purge) + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<PurgeTmdbShowJob>(c => + { + c.TmdbShowID = xref.TmdbShowID; + c.RemoveImageFiles = removeImageFiles; + }); + } + + #endregion + + #region Episode Links + + public void ResetAllEpisodeLinks(int anidbAnimeId, bool allowAuto) + { + var hasXrefs = _xrefAnidbTmdbShows.GetByAnidbAnimeID(anidbAnimeId).Count > 0; + if (hasXrefs) + { + var xrefs = _xrefAnidbTmdbEpisodes.GetByAnidbAnimeID(anidbAnimeId); + var toSave = new List<CrossRef_AniDB_TMDB_Episode>(); + var toDelete = new List<CrossRef_AniDB_TMDB_Episode>(); + + // Reset existing xrefs. + var existingIDs = new HashSet<int>(); + foreach (var xref in xrefs) + { + if (existingIDs.Add(xref.AnidbEpisodeID)) + { + xref.TmdbShowID = 0; + xref.TmdbEpisodeID = 0; + xref.Ordering = 0; + xref.MatchRating = allowAuto ? MatchRating.SarahJessicaParker : MatchRating.UserVerified; + toSave.Add(xref); + } + else + { + toDelete.Add(xref); + } + } + + // Add missing xrefs. + var anidbEpisodesWithoutXrefs = _anidbEpisodes.GetByAnimeID(anidbAnimeId) + .Where(episode => !existingIDs.Contains(episode.AniDB_EpisodeID) && episode.EpisodeType is (int)EpisodeType.Episode or (int)EpisodeType.Special) + .ToList(); + foreach (var anidbEpisode in anidbEpisodesWithoutXrefs) + toSave.Add(new(anidbEpisode.AniDB_EpisodeID, anidbAnimeId, 0, 0, allowAuto ? MatchRating.SarahJessicaParker : MatchRating.UserVerified)); + + // Save the changes. + _xrefAnidbTmdbEpisodes.Save(toSave); + _xrefAnidbTmdbEpisodes.Delete(toDelete); + } + else + { + // Remove all episode cross-references if no show is linked. + var xrefs = _xrefAnidbTmdbEpisodes.GetByAnidbAnimeID(anidbAnimeId); + _xrefAnidbTmdbEpisodes.Delete(xrefs); + } + } + + public bool SetEpisodeLink(int anidbEpisodeId, int tmdbEpisodeId, bool additiveLink = true, int? index = null) + { + var anidbEpisode = _anidbEpisodes.GetByEpisodeID(anidbEpisodeId); + if (anidbEpisode == null) + return false; + + // Set an empty link. + if (tmdbEpisodeId == 0) + { + var xrefs = _xrefAnidbTmdbEpisodes.GetByAnidbEpisodeID(anidbEpisodeId); + var toSave = xrefs.Count > 0 ? xrefs[0] : new(anidbEpisodeId, anidbEpisode.AnimeID, 0, 0); + toSave.TmdbShowID = 0; + toSave.TmdbEpisodeID = 0; + toSave.Ordering = 0; + toSave.MatchRating = MatchRating.UserVerified; + var toDelete = xrefs.Skip(1).ToList(); + _xrefAnidbTmdbEpisodes.Save(toSave); + _xrefAnidbTmdbEpisodes.Delete(toDelete); + + return true; + } + + var tmdbEpisode = _tmdbEpisodes.GetByTmdbEpisodeID(tmdbEpisodeId); + if (tmdbEpisode == null) + return false; + + // Add another link + if (additiveLink) + { + var toSave = _xrefAnidbTmdbEpisodes.GetByAnidbEpisodeAndTmdbEpisodeIDs(anidbEpisodeId, tmdbEpisodeId) + ?? new(anidbEpisodeId, anidbEpisode.AnimeID, tmdbEpisodeId, tmdbEpisode.TmdbShowID); + var existingAnidbLinks = _xrefAnidbTmdbEpisodes.GetByAnidbEpisodeID(anidbEpisodeId).MaxBy(x => x.Ordering) is { } x1 ? x1.Ordering + 1 : 0; + var existingTmdbLinks = _xrefAnidbTmdbEpisodes.GetByTmdbEpisodeID(tmdbEpisodeId).MaxBy(x => x.Ordering) is { } x2 ? x2.Ordering + 1 : 0; + if (toSave.CrossRef_AniDB_TMDB_EpisodeID == 0 && !index.HasValue) + index = existingAnidbLinks > 0 ? existingAnidbLinks : existingTmdbLinks > 0 ? existingTmdbLinks : 0; + if (index.HasValue) + toSave.Ordering = index.Value; + toSave.MatchRating = MatchRating.UserVerified; + _xrefAnidbTmdbEpisodes.Save(toSave); + } + else + { + var xrefs = _xrefAnidbTmdbEpisodes.GetByAnidbEpisodeID(anidbEpisodeId); + var toSave = xrefs.Count > 0 ? xrefs[0] : new(anidbEpisodeId, anidbEpisode.AnimeID, tmdbEpisodeId, tmdbEpisode.TmdbShowID); + toSave.TmdbShowID = tmdbEpisode.TmdbShowID; + toSave.TmdbEpisodeID = tmdbEpisode.TmdbEpisodeID; + if (!index.HasValue && anidbEpisode.EpisodeNumber is > 0 && + _anidbEpisodes.GetByAnimeIDAndEpisodeTypeNumber(anidbEpisode.AnimeID, anidbEpisode.EpisodeTypeEnum, anidbEpisode.EpisodeNumber - 1).FirstOrDefault() is { } previousEpisode) + { + var previousXrefs = _xrefAnidbTmdbEpisodes.GetByAnidbEpisodeID(previousEpisode.EpisodeID); + if (previousXrefs.Count is 1 && previousXrefs[0].TmdbEpisodeID == tmdbEpisodeId) + index = previousXrefs[0].Ordering + 1; + } + toSave.Ordering = index ?? 0; + toSave.MatchRating = MatchRating.UserVerified; + var toDelete = xrefs.Skip(1).ToList(); + _xrefAnidbTmdbEpisodes.Save(toSave); + _xrefAnidbTmdbEpisodes.Delete(toDelete); + } + + return true; + } + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> MatchAnidbToTmdbEpisodes(int anidbAnimeId, int tmdbShowId, int? tmdbSeasonId, bool useExisting = false, bool saveToDatabase = false, bool useExistingOtherShows = true) + { + var anime = _anidbAnime.GetByAnimeID(anidbAnimeId); + if (anime == null) + return []; + + var show = _tmdbShows.GetByTmdbShowID(tmdbShowId); + if (show == null) + return []; + + var startedAt = DateTime.Now; + _logger.LogTrace("Mapping AniDB Anime {AnidbAnimeId} to TMDB Show {TmdbShowId} (Season: {TmdbSeasonId}, Use Existing: {UseExisting}, Save To Database: {SaveToDatabase})", anidbAnimeId, tmdbShowId, tmdbSeasonId, useExisting, saveToDatabase); + + // Mapping logic + var isOVA = anime.AbstractAnimeType is AnimeType.OVA; + var toSkip = new HashSet<int>(); + var toAdd = new List<CrossRef_AniDB_TMDB_Episode>(); + var crossReferences = new List<CrossRef_AniDB_TMDB_Episode>(); + var secondPass = new List<SVR_AniDB_Episode>(); + var thirdPass = new List<SVR_AniDB_Episode>(); + var existing = _xrefAnidbTmdbEpisodes.GetAllByAnidbAnimeAndTmdbShowIDs(anidbAnimeId, tmdbShowId) + .GroupBy(xref => xref.AnidbEpisodeID) + .ToDictionary(grouped => grouped.Key, grouped => grouped.ToList()); + var anidbEpisodes = _anidbEpisodes.GetByAnimeID(anidbAnimeId) + .Where(episode => episode.EpisodeType is (int)EpisodeType.Episode or (int)EpisodeType.Special) + .OrderBy(episode => episode.EpisodeTypeEnum) + .ThenBy(episode => episode.EpisodeNumber) + .ToDictionary(episode => episode.EpisodeID); + var tmdbEpisodeDict = _tmdbEpisodes.GetByTmdbShowID(tmdbShowId) + .ToDictionary(episode => episode.TmdbEpisodeID); + var tmdbEpisodes = tmdbEpisodeDict.Values + .Where(episode => episode.SeasonNumber == 0 || !tmdbSeasonId.HasValue || episode.TmdbSeasonID == tmdbSeasonId.Value) + .ToList(); + var tmdbNormalEpisodes = isOVA ? tmdbEpisodes : tmdbEpisodes + .Where(episode => episode.SeasonNumber != 0) + .OrderBy(episode => episode.SeasonNumber) + .ThenBy(episode => episode.EpisodeNumber) + .ToList(); + var tmdbSpecialEpisodes = isOVA ? tmdbEpisodes : tmdbEpisodes + .Where(episode => episode.SeasonNumber == 0) + .OrderBy(episode => episode.EpisodeNumber) + .ToList(); + var current = 0; + foreach (var episode in anidbEpisodes.Values) + { + current++; + _logger.LogTrace("Checking episode {EpisodeType} {EpisodeNumber}. (AniDB ID: {AnidbEpisodeID}, Progress: {Current}/{Total}, Pass: 1/2)", episode.EpisodeTypeEnum, episode.EpisodeNumber, episode.EpisodeID, current, anidbEpisodes.Count); + var shouldAddNewLinks = true; + if (useExisting && existing.TryGetValue(episode.EpisodeID, out var existingLinks) && existingLinks.Any(link => link.MatchRating is MatchRating.UserVerified or MatchRating.DateAndTitleMatches)) + { + // Remove empty links if we have one or more empty links and at least one non-empty link. + if (existingLinks.Any(a => a.TmdbEpisodeID is 0 && a.TmdbShowID is 0) && existingLinks.Any(a => a.TmdbEpisodeID is not 0 || a.TmdbShowID is not 0)) + existingLinks = existingLinks + .Where(link => link.TmdbEpisodeID is not 0 || link.TmdbShowID is not 0) + .ToList(); + + // Remove duplicates, if any. + existingLinks = existingLinks.DistinctBy(link => (link.TmdbShowID, link.TmdbEpisodeID)).ToList(); + + if (existingLinks.Count == 1 && existingLinks[0].TmdbEpisodeID is 0 && existingLinks[0].TmdbShowID is 0 && existingLinks[0].MatchRating is not MatchRating.UserVerified) + goto skipExistingLinks; + + // If hidden and no user verified links, then unset the auto link. + shouldAddNewLinks = false; + if ((episode.AnimeEpisode?.IsHidden ?? false) && !existingLinks.Any(link => link.MatchRating is MatchRating.UserVerified)) + { + _logger.LogTrace("Skipping hidden episode. (AniDB ID: {AnidbEpisodeID})", episode.EpisodeID); + var link = existingLinks[0]; + if (link.TmdbEpisodeID is 0 && link.TmdbShowID is 0) + { + crossReferences.Add(link); + toSkip.Add(link.CrossRef_AniDB_TMDB_EpisodeID); + } + else + { + crossReferences.Add(new(episode.EpisodeID, anidbAnimeId, 0, 0, MatchRating.SarahJessicaParker, 0)); + } + continue; + } + + // Else return all existing links. + foreach (var link in existingLinks) + { + _logger.LogTrace("Skipping existing link for episode. (AniDB ID: {AnidbEpisodeID}, TMDB ID: {TmdbEpisodeID}, Rating: {MatchRating})", episode.EpisodeID, link.TmdbEpisodeID, link.MatchRating); + crossReferences.Add(link); + toSkip.Add(link.CrossRef_AniDB_TMDB_EpisodeID); + + // Exclude the linked episodes from the auto-match candidates. + var index = tmdbEpisodes.FindIndex(episode => episode.TmdbEpisodeID == link.TmdbEpisodeID); + if (index >= 0) + tmdbEpisodes.RemoveAt(index); + index = tmdbNormalEpisodes.FindIndex(episode => episode.TmdbEpisodeID == link.TmdbEpisodeID); + if (index >= 0) + tmdbNormalEpisodes.RemoveAt(index); + index = tmdbSpecialEpisodes.FindIndex(episode => episode.TmdbEpisodeID == link.TmdbEpisodeID); + if (index >= 0) + tmdbSpecialEpisodes.RemoveAt(index); + } + } + + skipExistingLinks:; + if (shouldAddNewLinks) + { + // If hidden then skip linking episode. + if (episode.AnimeEpisode?.IsHidden ?? false) + { + _logger.LogTrace("Skipping hidden episode. (AniDB ID: {AnidbEpisodeID})", episode.EpisodeID); + crossReferences.Add(new(episode.EpisodeID, anidbAnimeId, 0, 0, MatchRating.SarahJessicaParker, 0)); + continue; + } + + // Else try find a match. + _logger.LogTrace("Linking episode. (AniDB ID: {AnidbEpisodeID}, Pass: 1/2)", episode.EpisodeID); + var isSpecial = episode.AbstractEpisodeType is EpisodeType.Special || anime.AbstractAnimeType is not AnimeType.TVSeries and not AnimeType.Web; + var episodeList = isSpecial ? tmdbSpecialEpisodes : tmdbNormalEpisodes; + var crossRef = TryFindAnidbAndTmdbMatch(anime, episode, episodeList, isSpecial && !isOVA); + if (crossRef.MatchRating is MatchRating.DateAndTitleMatches) + { + var index = episodeList.FindIndex(episode => episode.TmdbEpisodeID == crossRef.TmdbEpisodeID); + if (index != -1) + episodeList.RemoveAt(index); + + crossReferences.Add(crossRef); + toAdd.Add(crossRef); + _logger.LogTrace("Adding new link for episode. (AniDB ID: {AnidbEpisodeID}, TMDB ID: {TMDbEpisodeID}, Rating: {MatchRating}, Pass: 1/3)", episode.EpisodeID, crossRef.TmdbEpisodeID, crossRef.MatchRating); + } + else + { + _logger.LogTrace("Skipping new link for episode for first pass. (AniDB ID: {AnidbEpisodeID}, TMDB ID: {TMDbEpisodeID}, Rating: {MatchRating}, Pass: 1/3)", episode.EpisodeID, crossRef.TmdbEpisodeID, crossRef.MatchRating); + secondPass.Add(episode); + } + } + } + + // Run a second pass on the episodes that weren't OV and DT links in the first pass. + if (secondPass.Count > 0) + { + // Filter the new links by the currently in use seasons from the existing (and/or new) OV/DT links. + var currentSessions = crossReferences + .Select(xref => xref.TmdbEpisodeID is not 0 && tmdbEpisodeDict.TryGetValue(xref.TmdbEpisodeID, out var tmdbEpisode) ? tmdbEpisode.SeasonNumber : -1) + .Except([-1]) + .Append(0) + .ToHashSet(); + // We always include season 0, so check if we have more than one session. + if (currentSessions.Count > 1) + { + _logger.LogTrace("Filtering new links by current sessions. (Current Sessions: {CurrentSessions})", string.Join(", ", currentSessions)); + tmdbEpisodes = (isOVA ? tmdbEpisodes : tmdbNormalEpisodes.Concat(tmdbSpecialEpisodes)) + .Where(episode => currentSessions.Contains(episode.SeasonNumber)) + .ToList(); + tmdbNormalEpisodes = isOVA ? tmdbEpisodes : tmdbEpisodes + .Where(episode => episode.SeasonNumber != 0) + .OrderBy(episode => episode.SeasonNumber) + .ThenBy(episode => episode.EpisodeNumber) + .ToList(); + tmdbSpecialEpisodes = isOVA ? tmdbEpisodes : tmdbEpisodes + .Where(episode => episode.SeasonNumber == 0) + .OrderBy(episode => episode.EpisodeNumber) + .ToList(); + } + + current = 0; + foreach (var episode in secondPass) + { + // Try find a match. + current++; + _logger.LogTrace("Linking episode {EpisodeType} {EpisodeNumber}. (AniDB ID: {EpisodeID}, Progress: {Current}/{Total}, Pass: 2/3)", episode.EpisodeTypeEnum, episode.EpisodeNumber, episode.EpisodeID, current, secondPass.Count); + var isSpecial = episode.AbstractEpisodeType is EpisodeType.Special || anime.AbstractAnimeType is not AnimeType.TVSeries and not AnimeType.Web; + var episodeList = isSpecial ? tmdbSpecialEpisodes : tmdbNormalEpisodes; + var crossRef = TryFindAnidbAndTmdbMatch(anime, episode, episodeList, isSpecial && !isOVA); + if (crossRef.MatchRating is MatchRating.TitleMatches) + { + var index = episodeList.FindIndex(episode => episode.TmdbEpisodeID == crossRef.TmdbEpisodeID); + if (index != -1) + episodeList.RemoveAt(index); + + crossReferences.Add(crossRef); + toAdd.Add(crossRef); + _logger.LogTrace("Adding new link for episode. (AniDB ID: {AnidbEpisodeID}, TMDB ID: {TMDbEpisodeID}, Rating: {MatchRating}, Pass: 2/3)", episode.EpisodeID, crossRef.TmdbEpisodeID, crossRef.MatchRating); + } + else + { + _logger.LogTrace("Skipping new link for episode for first pass. (AniDB ID: {AnidbEpisodeID}, TMDB ID: {TMDbEpisodeID}, Rating: {MatchRating}, Pass: 2/3)", episode.EpisodeID, crossRef.TmdbEpisodeID, crossRef.MatchRating); + thirdPass.Add(episode); + } + } + } + + // Run a third pass on the episodes that weren't OV, DT or T links in the first and second pass. + if (thirdPass.Count > 0) + { + // Filter the new links by the currently in use seasons from the existing (and/or new) OV/DT links. + var currentSessions = crossReferences + .Select(xref => xref.TmdbEpisodeID is not 0 && tmdbEpisodeDict.TryGetValue(xref.TmdbEpisodeID, out var tmdbEpisode) ? tmdbEpisode.SeasonNumber : -1) + .Except([-1]) + .Append(0) + .ToHashSet(); + // We always include season 0, so check if we have more than one session. + if (currentSessions.Count > 1) + { + _logger.LogTrace("Filtering new links by current sessions. (Current Sessions: {CurrentSessions})", string.Join(", ", currentSessions)); + tmdbEpisodes = (isOVA ? tmdbEpisodes : tmdbNormalEpisodes.Concat(tmdbSpecialEpisodes)) + .Where(episode => currentSessions.Contains(episode.SeasonNumber)) + .ToList(); + tmdbNormalEpisodes = isOVA ? tmdbEpisodes : tmdbEpisodes + .Where(episode => episode.SeasonNumber != 0) + .OrderBy(episode => episode.SeasonNumber) + .ThenBy(episode => episode.EpisodeNumber) + .ToList(); + tmdbSpecialEpisodes = isOVA ? tmdbEpisodes : tmdbEpisodes + .Where(episode => episode.SeasonNumber == 0) + .OrderBy(episode => episode.EpisodeNumber) + .ToList(); + } + + current = 0; + foreach (var episode in thirdPass) + { + // Try find a match. + current++; + _logger.LogTrace("Linking episode {EpisodeType} {EpisodeNumber}. (AniDB ID: {EpisodeID}, Progress: {Current}/{Total}, Pass: 3/3)", episode.EpisodeTypeEnum, episode.EpisodeNumber, episode.EpisodeID, current, secondPass.Count); + var isSpecial = episode.AbstractEpisodeType is EpisodeType.Special || anime.AbstractAnimeType is not AnimeType.TVSeries and not AnimeType.Web; + var episodeList = isSpecial ? tmdbSpecialEpisodes : tmdbNormalEpisodes; + var crossRef = TryFindAnidbAndTmdbMatch(anime, episode, episodeList, isSpecial && !isOVA); + if (crossRef.TmdbEpisodeID != 0) + { + _logger.LogTrace("Adding new link for episode. (AniDB ID: {AnidbEpisodeID}, TMDB ID: {TMDbEpisodeID}, Rating: {MatchRating}, Pass: 3/3)", episode.EpisodeID, crossRef.TmdbEpisodeID, crossRef.MatchRating); + var index = episodeList.FindIndex(episode => episode.TmdbEpisodeID == crossRef.TmdbEpisodeID); + if (index != -1) + episodeList.RemoveAt(index); + } + else + { + _logger.LogTrace("No match found for episode. (AniDB ID: {AnidbEpisodeID}, Pass: 3/3)", episode.EpisodeID); + } + + crossReferences.Add(crossRef); + toAdd.Add(crossRef); + } + } + + if (!saveToDatabase) + { + _logger.LogDebug( + "Found {a} anidb/tmdb episode links for show {ShowTitle} in {Delta}ms. (Anime={AnimeId},Show={ShowId})", + crossReferences.Count, + anime.PreferredTitle, + (DateTime.Now - startedAt).TotalMilliseconds, + anidbAnimeId, + tmdbShowId + ); + return crossReferences; + } + + // Remove the current anidb episodes that does not overlap with the show. + var toRemove = existing.Values + .SelectMany(list => list) + .Where(xref => anidbEpisodes.ContainsKey(xref.AnidbEpisodeID) && !toSkip.Contains(xref.CrossRef_AniDB_TMDB_EpisodeID)) + .ToList(); + + _logger.LogDebug( + "Added/removed/skipped {a}/{r}/{s} anidb/tmdb episode cross-references for show {ShowTitle} in {Delta} (Anime={AnimeId},Show={ShowId})", + toAdd.Count, + toRemove.Count, + existing.Count - toRemove.Count, + anime.PreferredTitle, + DateTime.Now - startedAt, + anidbAnimeId, + tmdbShowId); + _xrefAnidbTmdbEpisodes.Save(toAdd); + _xrefAnidbTmdbEpisodes.Delete(toRemove); + + return crossReferences; + } + + private CrossRef_AniDB_TMDB_Episode TryFindAnidbAndTmdbMatch(SVR_AniDB_Anime anime, SVR_AniDB_Episode anidbEpisode, IReadOnlyList<TMDB_Episode> tmdbEpisodes, bool isSpecial) + { + // Skip matching if we try to match a music video or complete movie. + var anidbTitle = _anidbEpisodeTitles.GetByEpisodeIDAndLanguage(anidbEpisode.EpisodeID, TitleLanguage.English) + .Where(title => !title.Title.Trim().Equals($"Episode {anidbEpisode.EpisodeNumber}", StringComparison.InvariantCultureIgnoreCase)) + .FirstOrDefault()?.Title; + var titlesToNotSearch = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { "Complete Movie", "Music Video" }; + if (!string.IsNullOrEmpty(anidbTitle) && titlesToNotSearch.Any(title => anidbTitle.Contains(title, StringComparison.InvariantCultureIgnoreCase))) + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, 0, 0, MatchRating.SarahJessicaParker); + + // Fix up the title for the first/single episode of a few anime types. + var titlesToSearch = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { "OAD", "OVA", "Short Movie", "Special", "TV Special", "Web" }; + if (!string.IsNullOrEmpty(anidbTitle) && titlesToSearch.Contains(anidbTitle)) + { + var englishAnimeTitle = anime.Titles.FirstOrDefault(title => title.TitleType == TitleType.Official && title.Language == TitleLanguage.English)?.Title; + if (englishAnimeTitle is not null) + { + var i = englishAnimeTitle.IndexOf(':'); + anidbTitle = i > 0 && i < englishAnimeTitle.Length - 1 ? englishAnimeTitle[(i + 1)..].TrimStart() : englishAnimeTitle; + } + } + + var anidbDate = anidbEpisode.GetAirDateAsDateOnly(); + if (anidbDate is not null && anidbDate > DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1))) + { + _logger.LogTrace("Skipping future episode {EpisodeID}", anidbEpisode.EpisodeID); + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, 0, 0, MatchRating.SarahJessicaParker, 0); + } + + var airdateProbability = tmdbEpisodes + .Select(episode => new { episode, probability = CalculateAirDateProbability(anidbDate, episode.AiredAt) }) + .Where(result => result.probability != 0) + .OrderByDescending(result => result.probability) + .ThenBy(result => result.episode.SeasonNumber == 0) + .ThenBy(result => result.episode.SeasonNumber) + .ThenBy(result => result.episode.EpisodeNumber) + .ToList(); + var titleSearchResults = !string.IsNullOrEmpty(anidbTitle) ? tmdbEpisodes + .Search(anidbTitle, episode => [episode.EnglishTitle], true) + .OrderBy(result => result) + .ToList() : []; + + // Exact match first. + if (titleSearchResults.Count > 0 && titleSearchResults[0] is { } exactTitleMatch && exactTitleMatch.ExactMatch && exactTitleMatch.LengthDifference < 3) + { + var tmdbEpisode = exactTitleMatch.Result; + var dateMatches = airdateProbability.Any(result => result.episode == tmdbEpisode); + var rating = dateMatches ? MatchRating.DateAndTitleMatches : MatchRating.TitleMatches; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, rating); + } + + // Almost exact match second. + if (titleSearchResults.Count > 0 && titleSearchResults[0] is { } kindaTitleMatch && kindaTitleMatch.Distance < 0.2D && kindaTitleMatch.LengthDifference < 6) + { + var tmdbEpisode = kindaTitleMatch.Result; + var dateMatches = airdateProbability.Any(result => result.episode == tmdbEpisode); + var rating = dateMatches ? MatchRating.DateAndTitleKindaMatches : MatchRating.TitleKindaMatches; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, rating); + } + + // Followed by checking the air date. + if (airdateProbability.Count > 0) + { + var tmdbEpisode = airdateProbability.FirstOrDefault(r => titleSearchResults.Any(result => result.Result == r.episode))?.episode; + var rating = tmdbEpisode is null ? MatchRating.DateMatches : MatchRating.DateAndTitleKindaMatches; + tmdbEpisode ??= airdateProbability[0].episode; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, rating); + } + + // Followed by _any_ title match. + if (titleSearchResults.Count > 0) + { + var tmdbEpisode = titleSearchResults[0]!.Result; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, MatchRating.TitleKindaMatches); + } + + // And finally, just pick the first available episode if it's not a special. + if (!isSpecial && tmdbEpisodes.Count > 0) + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisodes[0].TmdbEpisodeID, tmdbEpisodes[0].TmdbShowID, MatchRating.FirstAvailable); + + // And if all above failed, then return an empty link. + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, 0, 0, MatchRating.SarahJessicaParker); + } + + private static double CalculateAirDateProbability(DateOnly? firstDate, DateOnly? secondDate, int maxDifferenceInDays = 2) + { + if (!firstDate.HasValue || !secondDate.HasValue) + return 0; + + var difference = Math.Abs(secondDate.Value.DayNumber - firstDate.Value.DayNumber); + if (difference == 0) + return 1; + + if (difference <= maxDifferenceInDays) + return (maxDifferenceInDays - difference) / (double)maxDifferenceInDays; + + return 0; + } + + #endregion +} diff --git a/Shoko.Server/Providers/TMDB/TmdbMetadataService.cs b/Shoko.Server/Providers/TMDB/TmdbMetadataService.cs new file mode 100644 index 000000000..c1223e89f --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbMetadataService.cs @@ -0,0 +1,2330 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Bulkhead; +using Polly.RateLimit; +using Polly.Retry; +using Quartz; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models.Interfaces; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Repositories.Direct; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.TMDB; +using Shoko.Server.Server; +using Shoko.Server.Settings; +using Shoko.Server.Utilities; +using TMDbLib.Client; +using TMDbLib.Objects.Collections; +using TMDbLib.Objects.Exceptions; +using TMDbLib.Objects.General; +using TMDbLib.Objects.Movies; +using TMDbLib.Objects.People; +using TMDbLib.Objects.TvShows; + +using TitleLanguage = Shoko.Plugin.Abstractions.DataModels.TitleLanguage; +using MovieCredits = TMDbLib.Objects.Movies.Credits; + +// Suggestions we don't need in this file. +#pragma warning disable CA1822 +#pragma warning disable CA1826 + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public class TmdbMetadataService +{ + private static readonly int _maxConcurrency = Math.Min(6, Environment.ProcessorCount); + + private static TmdbMetadataService? _instance = null; + + private static readonly object _instanceLockObj = new(); + + internal static TmdbMetadataService? Instance + { + get + { + if (_instance is not null) + return _instance; + + lock (_instanceLockObj) + { + if (_instance is not null) + return _instance; + + return _instance = Utils.ServiceContainer?.GetService<TmdbMetadataService>(); + } + } + } + + private static string? _imageServerUrl = null; + + public static string? ImageServerUrl + { + get + { + // Return cached version if possible. + if (_imageServerUrl is not null) + return _imageServerUrl; + + // In case the server url is attempted to be accessed before the lazily initialized instance has been created, create it now if the service container is available. + var instance = Instance; + if (instance is null) + return null; + + try + { + var config = instance.UseClient(c => c.GetAPIConfiguration(), "Get API configuration").Result; + return _imageServerUrl = config.Images.SecureBaseUrl; + } + catch (Exception ex) + { + instance._logger.LogError(ex, "Encountered an exception while trying to find the image server url to use; {ErrorMessage}", ex.Message); + throw; + } + } + } + + private readonly ILogger<TmdbMetadataService> _logger; + + private readonly ISchedulerFactory _schedulerFactory; + + private readonly ISettingsProvider _settingsProvider; + + private readonly TmdbImageService _imageService; + + private readonly TmdbLinkingService _linkingService; + + private readonly AnimeSeriesRepository _animeSeries; + + private readonly TMDB_AlternateOrderingRepository _tmdbAlternateOrdering; + + private readonly TMDB_AlternateOrdering_EpisodeRepository _tmdbAlternateOrderingEpisodes; + + private readonly TMDB_AlternateOrdering_SeasonRepository _tmdbAlternateOrderingSeasons; + + private readonly TMDB_CollectionRepository _tmdbCollections; + + private readonly TMDB_CompanyRepository _tmdbCompany; + + private readonly TMDB_EpisodeRepository _tmdbEpisodes; + + private readonly TMDB_Episode_CastRepository _tmdbEpisodeCast; + + private readonly TMDB_Episode_CrewRepository _tmdbEpisodeCrew; + + private readonly TMDB_ImageRepository _tmdbImages; + + private readonly TMDB_MovieRepository _tmdbMovies; + + private readonly TMDB_Movie_CastRepository _tmdbMovieCast; + + private readonly TMDB_Movie_CrewRepository _tmdbMovieCrew; + + private readonly TMDB_NetworkRepository _tmdbNetwork; + + private readonly TMDB_OverviewRepository _tmdbOverview; + + private readonly TMDB_PersonRepository _tmdbPeople; + + private readonly TMDB_SeasonRepository _tmdbSeasons; + + private readonly TMDB_ShowRepository _tmdbShows; + + private readonly TMDB_TitleRepository _tmdbTitle; + + private readonly CrossRef_AniDB_TMDB_MovieRepository _xrefAnidbTmdbMovies; + + private readonly CrossRef_AniDB_TMDB_ShowRepository _xrefAnidbTmdbShows; + + private readonly TMDB_Collection_MovieRepository _xrefTmdbCollectionMovies; + + private readonly TMDB_Company_EntityRepository _xrefTmdbCompanyEntity; + + private readonly TMDB_Show_NetworkRepository _xrefTmdbShowNetwork; + + private TMDbClient? _rawClient = null; + + // We lazy-init it on first use, this will give us time to set up the server before we attempt to init the tmdb client. + private TMDbClient CachedClient => _rawClient ??= new(_settingsProvider.GetSettings().TMDB.UserApiKey ?? ( + Constants.TMDB.ApiKey != "TMDB_API_KEY_GOES_HERE" + ? Constants.TMDB.ApiKey + : throw new Exception("You need to provide an api key before using the TMDB provider!") + )); + + // This policy will ensure only 10 requests can be in-flight at the same time. + private readonly AsyncBulkheadPolicy _bulkheadPolicy; + + // This policy will ensure we can only make 40 requests per 10 seconds. + private readonly AsyncRateLimitPolicy _rateLimitPolicy; + + // This policy, together with the above policy, will ensure the rate limits are enforced, while also ensuring we + // throw if an exception that's not rate-limit related is thrown. + private readonly AsyncRetryPolicy _retryPolicy; + + private readonly ConcurrentDictionary<string, SemaphoreSlim> _concurrencyGuards = new(); + + /// <summary> + /// Execute the given function with the TMDb client, applying rate limiting and retry policies. + /// </summary> + /// <typeparam name="T">The type of the result of the function.</typeparam> + /// <param name="func">The function to execute with the TMDb client.</param> + /// <param name="displayName">The name of the function to display in the logs.</param> + /// <returns>A task that will complete with the result of the function, after applying the rate limiting and retry policies.</returns> + public async Task<T> UseClient<T>(Func<TMDbClient, Task<T>> func, string? displayName) + { + displayName ??= func.Method.Name; + var now = DateTime.Now; + var attempts = 0; + var waitTime = TimeSpan.Zero; + try + { + _logger.LogTrace("Scheduled call: {DisplayName}", displayName); + var val = await _bulkheadPolicy.ExecuteAsync(() => + { + var now1 = DateTime.Now; + waitTime = now1 - now; + now = now1; + _logger.LogTrace("Executing call: {DisplayName} (Waited {Waited}ms)", displayName, waitTime.TotalMilliseconds); + + return _retryPolicy.ExecuteAsync(() => + { + ++attempts; + return _rateLimitPolicy.ExecuteAsync(() => func(CachedClient)); + }); + }).ConfigureAwait(false); + + var delta = DateTime.Now - now; + _logger.LogTrace("Completed call: {DisplayName} (Waited {Waited}ms, Executed: {Delta}ms, {Attempts} attempts)", displayName, waitTime.TotalMilliseconds, delta.TotalMilliseconds, attempts); + return val; + } + catch (Exception ex) + { + var delta = DateTime.Now - now; + _logger.LogError(ex, "Failed call: {DisplayName} (Waited {Waited}ms, Executed: {Delta}ms, {Attempts} attempts)", displayName, waitTime.TotalMilliseconds, delta.TotalMilliseconds, attempts); + throw; + } + } + + public TmdbMetadataService( + ILogger<TmdbMetadataService> logger, + ISchedulerFactory commandFactory, + ISettingsProvider settingsProvider, + TmdbImageService imageService, + TmdbLinkingService linkingService, + AnimeSeriesRepository animeSeries, + TMDB_AlternateOrderingRepository tmdbAlternateOrdering, + TMDB_AlternateOrdering_EpisodeRepository tmdbAlternateOrderingEpisodes, + TMDB_AlternateOrdering_SeasonRepository tmdbAlternateOrderingSeasons, + TMDB_CollectionRepository tmdbCollections, + TMDB_CompanyRepository tmdbCompany, + TMDB_EpisodeRepository tmdbEpisodes, + TMDB_Episode_CastRepository tmdbEpisodeCast, + TMDB_Episode_CrewRepository tmdbEpisodeCrew, + TMDB_ImageRepository tmdbImages, + TMDB_MovieRepository tmdbMovies, + TMDB_Movie_CastRepository tmdbMovieCast, + TMDB_Movie_CrewRepository tmdbMovieCrew, + TMDB_NetworkRepository tmdbNetwork, + TMDB_OverviewRepository tmdbOverview, + TMDB_PersonRepository tmdbPeople, + TMDB_SeasonRepository tmdbSeasons, + TMDB_ShowRepository tmdbShows, + TMDB_TitleRepository tmdbTitle, + CrossRef_AniDB_TMDB_MovieRepository xrefAnidbTmdbMovies, + CrossRef_AniDB_TMDB_ShowRepository xrefAnidbTmdbShows, + TMDB_Collection_MovieRepository xrefTmdbCollectionMovies, + TMDB_Company_EntityRepository xrefTmdbCompanyEntity, + TMDB_Show_NetworkRepository xrefTmdbShowNetwork + ) + { + _logger = logger; + _schedulerFactory = commandFactory; + _settingsProvider = settingsProvider; + _imageService = imageService; + _linkingService = linkingService; + _animeSeries = animeSeries; + _tmdbAlternateOrdering = tmdbAlternateOrdering; + _tmdbAlternateOrderingEpisodes = tmdbAlternateOrderingEpisodes; + _tmdbAlternateOrderingSeasons = tmdbAlternateOrderingSeasons; + _tmdbCollections = tmdbCollections; + _tmdbCompany = tmdbCompany; + _tmdbEpisodes = tmdbEpisodes; + _tmdbEpisodeCast = tmdbEpisodeCast; + _tmdbEpisodeCrew = tmdbEpisodeCrew; + _tmdbImages = tmdbImages; + _tmdbMovies = tmdbMovies; + _tmdbMovieCast = tmdbMovieCast; + _tmdbMovieCrew = tmdbMovieCrew; + _tmdbNetwork = tmdbNetwork; + _tmdbOverview = tmdbOverview; + _tmdbPeople = tmdbPeople; + _tmdbSeasons = tmdbSeasons; + _tmdbShows = tmdbShows; + _tmdbTitle = tmdbTitle; + _xrefAnidbTmdbMovies = xrefAnidbTmdbMovies; + _xrefAnidbTmdbShows = xrefAnidbTmdbShows; + _xrefTmdbCollectionMovies = xrefTmdbCollectionMovies; + _xrefTmdbCompanyEntity = xrefTmdbCompanyEntity; + _xrefTmdbShowNetwork = xrefTmdbShowNetwork; + _instance ??= this; + _bulkheadPolicy = Policy.BulkheadAsync(_maxConcurrency, int.MaxValue); + _rateLimitPolicy = Policy.RateLimitAsync(45, TimeSpan.FromSeconds(10), 45); + _retryPolicy = Policy + .Handle<RateLimitRejectedException>() + .Or<HttpRequestException>() + .Or<RequestLimitExceededException>() + .WaitAndRetryAsync(int.MaxValue, (_, _) => TimeSpan.Zero, async (ex, ts, retryCount, ctx) => + { + // Retry on rate limit exceptions, throw on everything else. + switch (ex) + { + // If we got a _local_ rate limit exception, wait and try again. + case RateLimitRejectedException rlrEx: + { + var retryAfter = rlrEx.RetryAfter; + await Task.Delay(retryAfter).ConfigureAwait(false); + break; + } + // If we got a _remote_ rate limit exception, wait and try again. + case RequestLimitExceededException rleEx: + { + // Note: We don't actually wait here since the library has already waited for us. + var retryAfter = rleEx.RetryAfter ?? TimeSpan.FromSeconds(1); + _logger.LogTrace("Hit remote rate limit. Waiting and retrying. Retry count: {RetryCount}, Retry after: {RetryAfter}", retryCount, retryAfter); + break; + } + // If we timed out or got a too many requests exception, just wait and try again. + case HttpRequestException hrEx when hrEx.InnerException is TaskCanceledException: + { + // If we timed out more than 3 times, just throw the exception, since the exceptions were likely caused by other network issues. + var timeoutRetryCount = ctx.TryGetValue("timeoutRetryCount", out var timeoutRetryCountValue) ? (int)timeoutRetryCountValue : 0; + if (timeoutRetryCount >= 3) + goto default; + ctx["timeoutRetryCount"] = timeoutRetryCount + 1; + break; + } + default: + throw ex; + } + }); + } + + public async Task ScheduleSearchForMatch(int anidbId, bool force) + { + await (await _schedulerFactory.GetScheduler()).StartJob<SearchTmdbJob>(c => + { + c.AnimeID = anidbId; + c.ForceRefresh = force; + }); + } + + public async Task ScanForMatches() + { + var settings = _settingsProvider.GetSettings(); + if (!settings.TMDB.AutoLink) + return; + + var allSeries = _animeSeries.GetAll(); + var scheduler = await _schedulerFactory.GetScheduler(); + foreach (var ser in allSeries) + { + if (ser.IsTMDBAutoMatchingDisabled) + continue; + + var anime = ser.AniDB_Anime; + if (anime == null) + continue; + + if (anime.IsRestricted && !settings.TMDB.AutoLinkRestricted) + continue; + + if (anime.TmdbMovieCrossReferences is { Count: > 0 }) + continue; + + if (anime.TmdbShowCrossReferences is { Count: > 0 }) + continue; + + _logger.LogTrace("Found anime without TMDB association: {MainTitle}", anime.MainTitle); + + await scheduler.StartJob<SearchTmdbJob>(c => c.AnimeID = ser.AniDB_ID); + } + } + + #region Movies + + #region Genres (Movies) + + private IReadOnlyDictionary<int, string>? _movieGenres = null; + + public async Task<IReadOnlyDictionary<int, string>> GetMovieGenres() + { + if (_movieGenres is not null) + return _movieGenres; + + using (await GetLockForEntity(ForeignEntityType.Movie, 0, "genre", "Load").ConfigureAwait(false)) + { + if (_movieGenres is not null) + return _movieGenres; + + var genres = await UseClient(c => c.GetMovieGenresAsync(), "Get Movie Genres").ConfigureAwait(false); + _movieGenres = genres.ToDictionary(x => x.Id, x => x.Name); + return _movieGenres; + } + } + + #endregion + + #region Update (Movies) + + public bool IsMovieUpdating(int movieId) + => IsEntityLocked(ForeignEntityType.Movie, movieId, "metadata"); + + public async Task UpdateAllMovies(bool force, bool saveImages) + { + var allXRefs = _xrefAnidbTmdbMovies.GetAll(); + _logger.LogInformation("Scheduling {Count} movies to be updated.", allXRefs.Count); + var scheduler = await _schedulerFactory.GetScheduler(); + foreach (var xref in allXRefs) + await scheduler.StartJob<UpdateTmdbMovieJob>( + c => + { + c.TmdbMovieID = xref.TmdbMovieID; + c.ForceRefresh = force; + c.DownloadImages = saveImages; + } + ).ConfigureAwait(false); + } + + public async Task ScheduleUpdateOfMovie(int movieId, bool forceRefresh = false, bool downloadImages = false, bool? downloadCrewAndCast = null, bool? downloadCollections = null) + { + // Schedule the movie info to be downloaded or updated. + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<UpdateTmdbMovieJob>(c => + { + c.TmdbMovieID = movieId; + c.ForceRefresh = forceRefresh; + c.DownloadImages = downloadImages; + c.DownloadCrewAndCast = downloadCrewAndCast; + c.DownloadCollections = downloadCollections; + }).ConfigureAwait(false); + } + + public async Task<bool> UpdateMovie(int movieId, bool forceRefresh = false, bool downloadImages = false, bool downloadCrewAndCast = false, bool downloadCollections = false) + { + using (await GetLockForEntity(ForeignEntityType.Movie, movieId, "metadata", "Update").ConfigureAwait(false)) + { + // Abort if we're within a certain time frame as to not try and get us rate-limited. + var tmdbMovie = _tmdbMovies.GetByTmdbMovieID(movieId) ?? new(movieId); + var newlyAdded = tmdbMovie.TMDB_MovieID == 0; + if (!forceRefresh && tmdbMovie.CreatedAt != tmdbMovie.LastUpdatedAt && tmdbMovie.LastUpdatedAt > DateTime.Now.AddHours(-1)) + { + _logger.LogInformation("Skipping update of movie {MovieID} as it was last updated {LastUpdatedAt}", movieId, tmdbMovie.LastUpdatedAt); + return false; + } + + // Abort if we couldn't find the movie by id. + var methods = MovieMethods.Translations | MovieMethods.ReleaseDates | MovieMethods.ExternalIds; + if (downloadCrewAndCast) + methods |= MovieMethods.Credits; + var movie = await UseClient(c => c.GetMovieAsync(movieId, "en-US", null, methods), $"Get movie {movieId}").ConfigureAwait(false); + if (movie == null) + return false; + + var settings = _settingsProvider.GetSettings(); + var preferredTitleLanguages = settings.TMDB.DownloadAllTitles ? null : Languages.PreferredNamingLanguages.Select(a => a.Language).ToHashSet(); + var preferredOverviewLanguages = settings.TMDB.DownloadAllOverviews ? null : Languages.PreferredDescriptionNamingLanguages.Select(a => a.Language).ToHashSet(); + var contentRantingLanguages = settings.TMDB.DownloadAllContentRatings + ? null + : Languages.PreferredNamingLanguages.Select(a => a.Language) + .Concat(Languages.PreferredEpisodeNamingLanguages.Select(a => a.Language)) + .Except([TitleLanguage.Main, TitleLanguage.Unknown, TitleLanguage.None]) + .ToHashSet(); + var updated = tmdbMovie.Populate(movie, contentRantingLanguages); + var (titlesUpdated, overviewsUpdated) = UpdateTitlesAndOverviewsWithTuple(tmdbMovie, movie.Translations, preferredTitleLanguages, preferredOverviewLanguages); + updated = titlesUpdated || overviewsUpdated || updated; + updated = UpdateMovieExternalIDs(tmdbMovie, movie.ExternalIds) || updated; + updated = await UpdateCompanies(tmdbMovie, movie.ProductionCompanies) || updated; + if (downloadCrewAndCast) + updated = await UpdateMovieCastAndCrew(tmdbMovie, movie.Credits, forceRefresh, downloadImages) || updated; + if (updated) + { + tmdbMovie.LastUpdatedAt = DateTime.Now; + _tmdbMovies.Save(tmdbMovie); + } + + if (downloadCollections) + await UpdateMovieCollections(movie); + + foreach (var xref in _xrefAnidbTmdbMovies.GetByTmdbMovieID(movieId)) + { + if ((titlesUpdated || overviewsUpdated) && xref.AnimeSeries is { } series) + { + if (titlesUpdated) + { + series.ResetPreferredTitle(); + series.ResetAnimeTitles(); + } + + if (overviewsUpdated) + series.ResetPreferredOverview(); + } + } + + if (downloadImages) + await DownloadMovieImages(movieId, tmdbMovie.OriginalLanguage); + + if (newlyAdded || updated) + ShokoEventHandler.Instance.OnMovieUpdated(tmdbMovie, newlyAdded ? UpdateReason.Added : UpdateReason.Updated); + + return updated; + } + } + + private async Task<bool> UpdateMovieCastAndCrew(TMDB_Movie tmdbMovie, MovieCredits credits, bool forceRefresh, bool downloadImages) + { + var peopleToKeep = new HashSet<int>(); + + var counter = 0; + var castToAdd = 0; + var castToKeep = new HashSet<string>(); + var castToSave = new List<TMDB_Movie_Cast>(); + var existingCastDict = _tmdbMovieCast.GetByTmdbMovieID(tmdbMovie.Id) + .ToDictionary(cast => cast.TmdbCreditID); + foreach (var cast in credits.Cast) + { + var ordering = counter++; + peopleToKeep.Add(cast.Id); + castToKeep.Add(cast.CreditId); + + var roleUpdated = false; + if (!existingCastDict.TryGetValue(cast.CreditId, out var role)) + { + role = new() + { + TmdbMovieID = tmdbMovie.Id, + TmdbPersonID = cast.Id, + TmdbCreditID = cast.CreditId, + }; + castToAdd++; + roleUpdated = true; + } + + if (role.CharacterName != cast.Character) + { + role.CharacterName = cast.Character; + roleUpdated = true; + } + + if (role.Ordering != ordering) + { + role.Ordering = ordering; + roleUpdated = true; + } + + if (roleUpdated) + { + castToSave.Add(role); + } + } + + var crewToAdd = 0; + var crewToKeep = new HashSet<string>(); + var crewToSave = new List<TMDB_Movie_Crew>(); + var existingCrewDict = _tmdbMovieCrew.GetByTmdbMovieID(tmdbMovie.Id) + .ToDictionary(crew => crew.TmdbCreditID); + foreach (var crew in credits.Crew) + { + peopleToKeep.Add(crew.Id); + crewToKeep.Add(crew.CreditId); + + var roleUpdated = false; + if (!existingCrewDict.TryGetValue(crew.CreditId, out var role)) + { + role = new() + { + TmdbMovieID = tmdbMovie.Id, + TmdbPersonID = crew.Id, + TmdbCreditID = crew.CreditId, + }; + crewToAdd++; + roleUpdated = true; + } + + if (role.Department != crew.Department) + { + role.Department = crew.Department; + roleUpdated = true; + } + + if (role.Job != crew.Job) + { + role.Job = crew.Job; + roleUpdated = true; + } + + if (roleUpdated) + { + crewToSave.Add(role); + } + } + + var castToRemove = existingCastDict.Values + .ExceptBy(castToKeep, cast => cast.TmdbCreditID) + .ToList(); + var crewToRemove = existingCrewDict.Values + .ExceptBy(crewToKeep, crew => crew.TmdbCreditID) + .ToList(); + + _tmdbMovieCast.Save(castToSave); + _tmdbMovieCrew.Save(crewToSave); + _tmdbMovieCast.Delete(castToRemove); + _tmdbMovieCrew.Delete(crewToRemove); + + _logger.LogDebug( + "Added/updated/removed/skipped {aa}/{au}/{ar}/{as} cast and {ra}/{ru}/{rr}/{rs} crew for movie {MovieTitle} (Movie={MovieId})", + castToAdd, + castToSave.Count - castToAdd, + castToRemove.Count, + existingCastDict.Count - (castToSave.Count - castToAdd), + crewToAdd, + crewToSave.Count - crewToAdd, + crewToRemove.Count, + existingCrewDict.Count - (crewToSave.Count - crewToAdd), + tmdbMovie.EnglishTitle, + tmdbMovie.Id + ); + + // Only add/remove staff if we're not doing a quick refresh. + var peopleAdded = 0; + var peopleUpdated = 0; + var peoplePurged = 0; + var peopleToPurge = existingCastDict.Values.Select(cast => cast.TmdbPersonID) + .Concat(existingCrewDict.Values.Select(crew => crew.TmdbPersonID)) + .Except(peopleToKeep) + .ToHashSet(); + foreach (var personId in peopleToKeep) + { + var (added, updated) = await UpdatePerson(personId, forceRefresh, downloadImages); + if (added) + peopleAdded++; + if (updated) + peopleUpdated++; + } + foreach (var personId in peopleToPurge) + { + if (await PurgePerson(personId)) + peoplePurged++; + } + + _logger.LogDebug("Added/removed {a}/{u}/{r}/{s} staff for movie {MovieTitle} (Movie={MovieId})", + peopleAdded, + peopleUpdated, + peoplePurged, + peopleToPurge.Count + peopleToPurge.Count - peopleAdded - peopleUpdated - peoplePurged, + tmdbMovie.EnglishTitle, + tmdbMovie.Id + ); + return castToSave.Count > 0 || + castToRemove.Count > 0 || + crewToSave.Count > 0 || + crewToRemove.Count > 0 || + peopleAdded > 0 || + peopleUpdated > 0 || + peoplePurged > 0; + } + + private async Task UpdateMovieCollections(Movie movie) + { + if (movie.BelongsToCollection?.Id is not { } collectionId) + { + CleanupMovieCollection(movie.Id); + return; + } + + var movieXRefs = _xrefTmdbCollectionMovies.GetByTmdbCollectionID(collectionId); + var tmdbCollection = _tmdbCollections.GetByTmdbCollectionID(collectionId) ?? new(collectionId); + var collection = await UseClient(c => c.GetCollectionAsync(collectionId, CollectionMethods.Images | CollectionMethods.Translations), $"Get movie collection {collectionId} for movie {movie.Id} \"{movie.Title}\"").ConfigureAwait(false); + if (collection == null) + { + PurgeMovieCollection(collectionId); + return; + } + + var settings = _settingsProvider.GetSettings(); + var preferredTitleLanguages = settings.TMDB.DownloadAllTitles ? null : Languages.PreferredNamingLanguages.Select(a => a.Language).ToHashSet(); + var preferredOverviewLanguages = settings.TMDB.DownloadAllOverviews ? null : Languages.PreferredDescriptionNamingLanguages.Select(a => a.Language).ToHashSet(); + + var updated = tmdbCollection.Populate(collection); + updated = UpdateTitlesAndOverviews(tmdbCollection, collection.Translations, preferredTitleLanguages, preferredOverviewLanguages) || updated; + + var xrefsToAdd = 0; + var xrefsToSave = new List<TMDB_Collection_Movie>(); + var xrefsToRemove = movieXRefs.Where(xref => !collection.Parts.Any(part => xref.TmdbMovieID == part.Id)).ToList(); + var movieXref = movieXRefs.FirstOrDefault(xref => xref.TmdbMovieID == movie.Id); + var index = collection.Parts.FindIndex(part => part.Id == movie.Id); + if (index == -1) + index = collection.Parts.Count; + if (movieXref == null) + { + xrefsToAdd++; + xrefsToSave.Add(new(collectionId, movie.Id, index + 1)); + } + else if (movieXref.Ordering != index + 1) + { + movieXref.Ordering = index + 1; + xrefsToSave.Add(movieXref); + } + + _logger.LogDebug( + "Added/updated/removed/skipped {ta}/{tu}/{tr}/{ts} movie cross-references for movie collection {CollectionTitle} (Id={CollectionId})", + xrefsToAdd, + xrefsToSave.Count - xrefsToAdd, + xrefsToRemove.Count, + movieXRefs.Count + xrefsToAdd - xrefsToRemove.Count - xrefsToSave.Count, + tmdbCollection.EnglishTitle, + tmdbCollection.Id); + _xrefTmdbCollectionMovies.Save(xrefsToSave); + _xrefTmdbCollectionMovies.Delete(xrefsToRemove); + + if (updated || xrefsToSave.Count > 0 || xrefsToRemove.Count > 0) + { + tmdbCollection.LastUpdatedAt = DateTime.Now; + _tmdbCollections.Save(tmdbCollection); + } + } + + public async Task ScheduleDownloadAllMovieImages(int movieId, bool forceDownload = false) + { + // Schedule the movie info to be downloaded or updated. + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<DownloadTmdbMovieImagesJob>(c => + { + c.TmdbMovieID = movieId; + c.ForceDownload = forceDownload; + }).ConfigureAwait(false); + } + + public async Task DownloadAllMovieImages(int movieId, bool forceDownload = false) + { + using (await GetLockForEntity(ForeignEntityType.Movie, movieId, "images", "Update").ConfigureAwait(false)) + { + var tmdbMovie = _tmdbMovies.GetByTmdbMovieID(movieId); + if (tmdbMovie is null) + return; + + await DownloadMovieImages(movieId, tmdbMovie.OriginalLanguage, forceDownload); + } + } + + private async Task DownloadMovieImages(int movieId, TitleLanguage? mainLanguage = null, bool forceDownload = false) + { + var settings = _settingsProvider.GetSettings(); + if (!settings.TMDB.AutoDownloadPosters && !settings.TMDB.AutoDownloadLogos && !settings.TMDB.AutoDownloadBackdrops) + return; + + var images = await UseClient(c => c.GetMovieImagesAsync(movieId), $"Get images for movie {movieId}").ConfigureAwait(false); + var languages = GetLanguages(mainLanguage); + if (settings.TMDB.AutoDownloadPosters) + await _imageService.DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoPosters, languages, forceDownload); + if (settings.TMDB.AutoDownloadLogos) + await _imageService.DownloadImagesByType(images.Logos, ImageEntityType.Logo, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoLogos, languages, forceDownload); + if (settings.TMDB.AutoDownloadBackdrops) + await _imageService.DownloadImagesByType(images.Backdrops, ImageEntityType.Backdrop, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + } + + #endregion + + #region Purge (Movies) + + public async Task PurgeAllUnusedMovies() + { + var allMovies = _tmdbMovies.GetAll().Select(movie => movie.TmdbMovieID) + .Concat(_tmdbImages.GetAll().Where(image => image.TmdbMovieID.HasValue).Select(image => image.TmdbMovieID!.Value)) + .Concat(_xrefAnidbTmdbMovies.GetAll().Select(xref => xref.TmdbMovieID)) + .Concat(_xrefTmdbCompanyEntity.GetAll().Where(x => x.TmdbEntityType == ForeignEntityType.Movie).Select(x => x.TmdbEntityID)) + .Concat(_tmdbMovieCast.GetAll().Select(x => x.TmdbMovieID)) + .Concat(_tmdbMovieCrew.GetAll().Select(x => x.TmdbMovieID)) + .Concat(_tmdbCollections.GetAll().Select(collection => collection.TmdbCollectionID)) + .Concat(_xrefTmdbCollectionMovies.GetAll().Select(collectionMovie => collectionMovie.TmdbMovieID)) + .ToHashSet(); + var toKeep = _xrefAnidbTmdbMovies.GetAll() + .Select(xref => xref.TmdbMovieID) + .ToHashSet(); + var toBePurged = allMovies + .Except(toKeep) + .ToHashSet(); + + _logger.LogInformation("Scheduling {Count} out of {AllCount} movies to be purged.", toBePurged.Count, allMovies.Count); + var scheduler = await _schedulerFactory.GetScheduler(); + foreach (var movieID in toBePurged) + await scheduler.StartJob<PurgeTmdbMovieJob>(c => c.TmdbMovieID = movieID); + } + + public async Task SchedulePurgeOfMovie(int movieId, bool removeImageFiles = true) + { + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<PurgeTmdbMovieJob>(c => + { + c.TmdbMovieID = movieId; + c.RemoveImageFiles = removeImageFiles; + }); + } + + /// <summary> + /// Purge a TMDB movie from the local database. + /// </summary> + /// <param name="movieId">TMDB Movie ID.</param> + /// <param name="removeImageFiles">Remove image files.</param> + public async Task PurgeMovie(int movieId, bool removeImageFiles = true) + { + using (await GetLockForEntity(ForeignEntityType.Movie, movieId, "metadata", "Purge").ConfigureAwait(false)) + { + await _linkingService.RemoveAllMovieLinksForMovie(movieId); + + _imageService.PurgeImages(ForeignEntityType.Movie, movieId, removeImageFiles); + + var movie = _tmdbMovies.GetByTmdbMovieID(movieId); + if (movie != null) + { + _logger.LogTrace("Removing movie {MovieName} (Movie={MovieID})", movie.OriginalTitle, movie.Id); + _tmdbMovies.Delete(movie); + } + + PurgeMovieCompanies(movieId, removeImageFiles); + + PurgeMovieCastAndCrew(movieId, removeImageFiles); + + CleanupMovieCollection(movieId); + + PurgeTitlesAndOverviews(ForeignEntityType.Movie, movieId); + } + } + + private void PurgeMovieCompanies(int movieId, bool removeImageFiles = true) + { + var xrefsToRemove = _xrefTmdbCompanyEntity.GetByTmdbEntityTypeAndID(ForeignEntityType.Movie, movieId); + foreach (var xref in xrefsToRemove) + { + // Delete xref or purge company. + var xrefs = _xrefTmdbCompanyEntity.GetByTmdbCompanyID(xref.TmdbCompanyID); + if (xrefs.Count > 1) + _xrefTmdbCompanyEntity.Delete(xref); + else + PurgeCompany(xref.TmdbCompanyID, removeImageFiles); + } + } + + private async void PurgeMovieCastAndCrew(int movieId, bool removeImageFiles = true) + { + var castMembers = _tmdbMovieCast.GetByTmdbMovieID(movieId); + var crewMembers = _tmdbMovieCrew.GetByTmdbMovieID(movieId); + + _tmdbMovieCast.Delete(castMembers); + _tmdbMovieCrew.Delete(crewMembers); + + var allPeopleSet = castMembers.Select(c => c.TmdbPersonID) + .Concat(crewMembers.Select(c => c.TmdbPersonID)) + .Distinct() + .ToHashSet(); + foreach (var personId in allPeopleSet) + await PurgePerson(personId, removeImageFiles); + } + + private void CleanupMovieCollection(int movieId, bool removeImageFiles = true) + { + var xref = _xrefTmdbCollectionMovies.GetByTmdbMovieID(movieId); + if (xref == null) + return; + + var allXRefs = _xrefTmdbCollectionMovies.GetByTmdbCollectionID(xref.TmdbCollectionID); + if (allXRefs.Count > 1) + _xrefTmdbCollectionMovies.Delete(xref); + else + PurgeMovieCollection(xref.TmdbCollectionID, removeImageFiles); + } + + private void PurgeMovieCollection(int collectionId, bool removeImageFiles = true) + { + var collection = _tmdbCollections.GetByTmdbCollectionID(collectionId); + var collectionXRefs = _xrefTmdbCollectionMovies.GetByTmdbCollectionID(collectionId); + if (collectionXRefs.Count > 0) + { + _logger.LogTrace( + "Removing {Count} cross-references for movie collection {CollectionName} (Collection={CollectionID})", + collectionXRefs.Count, collection?.EnglishTitle ?? string.Empty, + collectionId + ); + _xrefTmdbCollectionMovies.Delete(collectionXRefs); + } + + _imageService.PurgeImages(ForeignEntityType.Collection, collectionId, removeImageFiles); + + PurgeTitlesAndOverviews(ForeignEntityType.Collection, collectionId); + + if (collection != null) + { + _logger.LogTrace( + "Removing movie collection {CollectionName} (Collection={CollectionID})", + collection.EnglishTitle, + collectionId + ); + _tmdbCollections.Delete(collection); + } + } + + #endregion + + #endregion + + #region Shows + + #region Genres (Shows) + + private IReadOnlyDictionary<int, string>? _tvShowGenres = null; + + public async Task<IReadOnlyDictionary<int, string>> GetShowGenres() + { + if (_tvShowGenres is not null) + return _tvShowGenres; + + using (await GetLockForEntity(ForeignEntityType.Show, 0, "genre", "Load").ConfigureAwait(false)) + { + if (_tvShowGenres is not null) + return _tvShowGenres; + + var genres = await UseClient(c => c.GetTvGenresAsync(), "Get TV Show Genres").ConfigureAwait(false); + _tvShowGenres = genres.ToDictionary(x => x.Id, x => x.Name); + return _tvShowGenres; + } + } + + #endregion + + #region Update (Shows) + + public async Task UpdateAllShows(bool force = false, bool downloadImages = false) + { + var allXRefs = _xrefAnidbTmdbShows.GetAll(); + _logger.LogInformation("Scheduling {Count} shows to be updated.", allXRefs.Count); + var scheduler = await _schedulerFactory.GetScheduler(); + foreach (var xref in allXRefs) + { + await scheduler.StartJob<UpdateTmdbShowJob>( + c => + { + c.TmdbShowID = xref.TmdbShowID; + c.ForceRefresh = force; + c.DownloadImages = downloadImages; + } + ).ConfigureAwait(false); + } + } + + public bool IsShowUpdating(int showId) + => IsEntityLocked(ForeignEntityType.Show, showId, "metadata"); + + public async Task ScheduleUpdateOfShow(int showId, bool forceRefresh = false, bool downloadImages = false, bool? downloadCrewAndCast = null, bool? downloadAlternateOrdering = null) + { + // Schedule the show info to be downloaded or updated. + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<UpdateTmdbShowJob>(c => + { + c.TmdbShowID = showId; + c.ForceRefresh = forceRefresh; + c.DownloadImages = downloadImages; + c.DownloadCrewAndCast = downloadCrewAndCast; + c.DownloadAlternateOrdering = downloadAlternateOrdering; + }).ConfigureAwait(false); + } + + public async Task<bool> UpdateShow(int showId, bool forceRefresh = false, bool downloadImages = false, bool downloadCrewAndCast = false, bool downloadAlternateOrdering = false, bool quickRefresh = false) + { + using (await GetLockForEntity(ForeignEntityType.Show, showId, "metadata", "Update").ConfigureAwait(false)) + { + // Abort if we're within a certain time frame as to not try and get us rate-limited. + var tmdbShow = _tmdbShows.GetByTmdbShowID(showId) ?? new(showId); + var newlyAdded = tmdbShow.CreatedAt == tmdbShow.LastUpdatedAt; + var xrefs = _xrefAnidbTmdbShows.GetByTmdbShowID(showId); + if (!forceRefresh && tmdbShow.CreatedAt != tmdbShow.LastUpdatedAt && tmdbShow.LastUpdatedAt > DateTime.Now.AddHours(-1)) + { + _logger.LogInformation("Skipping update of show {ShowID} as it was last updated {LastUpdatedAt}", showId, tmdbShow.LastUpdatedAt); + + // Do the auto-matching if we're not doing a quick refresh. + if (!quickRefresh) + foreach (var xref in xrefs) + _linkingService.MatchAnidbToTmdbEpisodes(xref.AnidbAnimeID, xref.TmdbShowID, null, true, true); + + return false; + } + + var methods = TvShowMethods.ContentRatings | TvShowMethods.Translations | TvShowMethods.ExternalIds; + if (downloadAlternateOrdering && !quickRefresh) + methods |= TvShowMethods.EpisodeGroups; + var show = await UseClient(c => c.GetTvShowAsync(showId, methods, "en-US"), $"Get Show {showId}").ConfigureAwait(false); + if (show == null) + return false; + + var settings = _settingsProvider.GetSettings(); + var preferredTitleLanguages = settings.TMDB.DownloadAllTitles ? null : Languages.PreferredNamingLanguages.Select(a => a.Language).ToHashSet(); + var preferredOverviewLanguages = settings.TMDB.DownloadAllOverviews ? null : Languages.PreferredDescriptionNamingLanguages.Select(a => a.Language).ToHashSet(); + var contentRantingLanguages = settings.TMDB.DownloadAllContentRatings + ? null + : Languages.PreferredNamingLanguages.Select(a => a.Language) + .Concat(Languages.PreferredEpisodeNamingLanguages.Select(a => a.Language)) + .Except([TitleLanguage.Main, TitleLanguage.Unknown, TitleLanguage.None]) + .ToHashSet(); + var shouldFireEvents = !quickRefresh || xrefs.Count > 0; + var updated = tmdbShow.Populate(show, contentRantingLanguages); + var (titlesUpdated, overviewsUpdated) = UpdateTitlesAndOverviewsWithTuple(tmdbShow, show.Translations, preferredTitleLanguages, preferredOverviewLanguages); + updated = titlesUpdated || overviewsUpdated || updated; + updated = UpdateShowExternalIDs(tmdbShow, show.ExternalIds) || updated; + updated = await UpdateCompanies(tmdbShow, show.ProductionCompanies) || updated; + var (episodesOrSeasonsUpdated, updatedEpisodes) = await UpdateShowSeasonsAndEpisodes(show, downloadCrewAndCast, forceRefresh, downloadImages, quickRefresh, shouldFireEvents); + updated = episodesOrSeasonsUpdated || updated; + if (downloadAlternateOrdering && !quickRefresh) + updated = await UpdateShowAlternateOrdering(show) || updated; + if (newlyAdded || updated) + { + if (shouldFireEvents) + tmdbShow.LastUpdatedAt = DateTime.Now; + _tmdbShows.Save(tmdbShow); + } + + foreach (var xref in xrefs) + { + // Don't do the auto-matching if we're just doing a quick refresh. + if (!quickRefresh) + _linkingService.MatchAnidbToTmdbEpisodes(xref.AnidbAnimeID, xref.TmdbShowID, null, true, true); + + if ((titlesUpdated || overviewsUpdated) && xref.AnimeSeries is { } series) + { + if (titlesUpdated) + { + series.ResetPreferredTitle(); + series.ResetAnimeTitles(); + } + + if (overviewsUpdated) + series.ResetPreferredOverview(); + } + } + + if (downloadImages && !quickRefresh) + await ScheduleDownloadAllShowImages(showId, false); + + if (shouldFireEvents && (newlyAdded || updated || updatedEpisodes.Count > 0)) + ShokoEventHandler.Instance.OnSeriesUpdated(tmdbShow, newlyAdded ? UpdateReason.Added : UpdateReason.Updated, updatedEpisodes); + + return updated; + } + } + + private async Task<(bool episodesOrSeasonsUpdated, Dictionary<TMDB_Episode, UpdateReason> updatedEpisodes)> UpdateShowSeasonsAndEpisodes(TvShow show, bool downloadCrewAndCast = false, bool forceRefresh = false, bool downloadImages = false, bool quickRefresh = false, bool shouldFireEvents = false) + { + var settings = _settingsProvider.GetSettings(); + var preferredTitleLanguages = settings.TMDB.DownloadAllTitles ? null : Languages.PreferredEpisodeNamingLanguages.Select(a => a.Language).ToHashSet(); + var preferredOverviewLanguages = settings.TMDB.DownloadAllOverviews ? null : Languages.PreferredDescriptionNamingLanguages.Select(a => a.Language).ToHashSet(); + + var existingSeasons = _tmdbSeasons.GetByTmdbShowID(show.Id) + .ToDictionary(season => season.Id); + var seasonsToAdd = 0; + var seasonsToSkip = new HashSet<int>(); + var seasonsToSave = new List<TMDB_Season>(); + + var existingEpisodes = new ConcurrentDictionary<int, TMDB_Episode>(); + foreach (var episode in _tmdbEpisodes.GetByTmdbShowID(show.Id)) + existingEpisodes.TryAdd(episode.Id, episode); + var episodesToAdd = 0; + var episodesToSkip = new ConcurrentBag<int>(); + var episodesToSave = new ConcurrentBag<TMDB_Episode>(); + var episodeEventsToEmit = new Dictionary<TMDB_Episode, UpdateReason>(); + var allPeopleToCheck = new ConcurrentBag<int>(); + var allPeopleToRemove = new ConcurrentBag<int>(); + foreach (var reducedSeason in show.Seasons) + { + _logger.LogDebug("Checking season {SeasonNumber} for show {ShowTitle} (Show={ShowId})", reducedSeason.SeasonNumber, show.Name, show.Id); + var season = await UseClient(c => c.GetTvSeasonAsync(show.Id, reducedSeason.SeasonNumber, TvSeasonMethods.Translations), $"Get season {reducedSeason.SeasonNumber} for show {show.Id} \"{show.Name}\"").ConfigureAwait(false) ?? + throw new Exception($"Unable to fetch season {reducedSeason.SeasonNumber} for show \"{show.Name}\"."); + if (!existingSeasons.TryGetValue(reducedSeason.Id, out var tmdbSeason)) + { + seasonsToAdd++; + tmdbSeason = new(reducedSeason.Id); + } + + var seasonUpdated = tmdbSeason.Populate(show, season); + seasonUpdated = UpdateTitlesAndOverviews(tmdbSeason, season.Translations, preferredTitleLanguages, preferredOverviewLanguages) || seasonUpdated; + if (seasonUpdated) + { + tmdbSeason.LastUpdatedAt = DateTime.Now; + seasonsToSave.Add(tmdbSeason); + } + + seasonsToSkip.Add(tmdbSeason.Id); + + await ProcessWithConcurrencyAsync(_maxConcurrency, season.Episodes, async (reducedEpisode) => + { + _logger.LogDebug("Checking episode {EpisodeNumber} in season {SeasonNumber} for show {ShowTitle} (Show={ShowId})", reducedEpisode.EpisodeNumber, reducedSeason.SeasonNumber, show.Name, show.Id); + if (!existingEpisodes.TryGetValue(reducedEpisode.Id, out var tmdbEpisode)) + { + episodesToAdd++; + tmdbEpisode = new(reducedEpisode.Id); + } + var newlyAddedEpisode = tmdbEpisode.CreatedAt == tmdbEpisode.LastUpdatedAt; + + // If quick refresh is enabled then skip the API call per episode. (Part 1) + TvEpisode? episode = null; + if (!quickRefresh) + { + var episodeMethods = TvEpisodeMethods.ExternalIds | TvEpisodeMethods.Translations; + if (downloadCrewAndCast) + episodeMethods |= TvEpisodeMethods.Credits; + episode = await UseClient(c => c.GetTvEpisodeAsync(show.Id, season.SeasonNumber, reducedEpisode.EpisodeNumber, episodeMethods), $"Get episode {reducedEpisode.EpisodeNumber} in season {season.SeasonNumber} for show {show.Id} \"{show.Name}\"").ConfigureAwait(false); + } + + var episodeUpdated = tmdbEpisode.Populate(show, season, reducedEpisode, episode?.Translations); + + // If quick refresh is enabled then skip the API call per episode, but do add the titles/overviews. (Part 2) + if (quickRefresh) + { + episodeUpdated = UpdateTitlesAndOverviews(tmdbEpisode, null, preferredTitleLanguages, preferredOverviewLanguages) || episodeUpdated; + } + else + { + episodeUpdated = UpdateTitlesAndOverviews(tmdbEpisode, episode!.Translations, preferredTitleLanguages, preferredOverviewLanguages) || episodeUpdated; + episodeUpdated = UpdateEpisodeExternalIDs(tmdbEpisode, episode.ExternalIds) || episodeUpdated; + + // Update crew & cast. + if (downloadCrewAndCast) + { + var (castOrCrewUpdated, peopleToCheck, peopleToRemove) = UpdateEpisodeCastAndCrew(tmdbEpisode, episode.Credits); + episodeUpdated |= castOrCrewUpdated; + foreach (var personId in peopleToCheck) + allPeopleToCheck.Add(personId); + foreach (var personId in peopleToRemove) + allPeopleToRemove.Add(personId); + } + } + + if ((newlyAddedEpisode && shouldFireEvents) || episodeUpdated) + { + episodeEventsToEmit.Add(tmdbEpisode, newlyAddedEpisode ? UpdateReason.Added : UpdateReason.Updated); + if (shouldFireEvents) + tmdbEpisode.LastUpdatedAt = DateTime.Now; + episodesToSave.Add(tmdbEpisode); + } + + episodesToSkip.Add(tmdbEpisode.Id); + }); + } + + var seasonsToRemove = existingSeasons.Values + .ExceptBy(seasonsToSkip, season => season.Id) + .ToList(); + var episodesToRemove = existingEpisodes.Values + .ExceptBy(episodesToSkip, episode => episode.Id) + .ToList(); + + _logger.LogDebug( + "Added/updated/removed/skipped {a}/{u}/{r}/{s} seasons for show {ShowTitle} (Show={ShowId})", + seasonsToAdd, + seasonsToSave.Count - seasonsToAdd, + seasonsToRemove.Count, + existingSeasons.Count + seasonsToAdd - seasonsToRemove.Count - seasonsToSave.Count, + show.Name, + show.Id); + _tmdbSeasons.Save(seasonsToSave); + + foreach (var season in seasonsToRemove) + PurgeShowSeason(season); + + _tmdbSeasons.Delete(seasonsToRemove); + + _logger.LogDebug( + "Added/updated/removed/skipped {a}/{u}/{r}/{s} episodes for show {ShowTitle} (Show={ShowId})", + episodesToAdd, + episodesToSave.Count - episodesToAdd, + episodesToRemove.Count, + existingEpisodes.Count + episodesToAdd - episodesToRemove.Count - episodesToSave.Count, + show.Name, + show.Id); + _tmdbEpisodes.Save(episodesToSave); + + foreach (var episode in episodesToRemove) + { + PurgeShowEpisode(episode); + episodeEventsToEmit.Add(episode, UpdateReason.Removed); + } + + _tmdbEpisodes.Delete(episodesToRemove); + + if (quickRefresh) + return ( + seasonsToSave.Count > 0 || seasonsToRemove.Count > 0 || episodesToSave.IsEmpty || episodesToRemove.Count > 0, + episodeEventsToEmit + ); + + // Only add/remove staff if we're not doing a quick refresh. + var peopleAdded = 0; + var peopleUpdated = 0; + var peoplePurged = 0; + var peopleToCheck = allPeopleToCheck.ToArray().Distinct().ToList(); + var peopleToPurge = allPeopleToRemove.ToArray().Distinct().Except(peopleToCheck).ToList(); + foreach (var personId in peopleToCheck) + { + var (added, updated) = await UpdatePerson(personId, forceRefresh, downloadImages); + if (added) + peopleAdded++; + if (updated) + peopleUpdated++; + } + foreach (var personId in peopleToPurge) + { + if (await PurgePerson(personId)) + peoplePurged++; + } + + _logger.LogDebug("Added/removed {a}/{u}/{r}/{s} staff for show {ShowTitle} (Show={ShowId})", + peopleAdded, + peopleUpdated, + peoplePurged, + peopleToPurge.Count + peopleToCheck.Count - peopleAdded - peopleUpdated - peoplePurged, + show.Name, + show.Id + ); + + return ( + seasonsToSave.Count > 0 || seasonsToRemove.Count > 0 || episodesToSave.IsEmpty || episodesToRemove.Count > 0 || peopleAdded > 0 || peoplePurged > 0, + episodeEventsToEmit + ); + } + + private async Task<bool> UpdateShowAlternateOrdering(TvShow show) + { + _logger.LogDebug( + "Checking {count} episode group collections to create alternate orderings for show {ShowTitle} (Show={ShowId})", + show.EpisodeGroups.Results.Count, + show.Name, + show.Id); + + var existingOrdering = _tmdbAlternateOrdering.GetByTmdbShowID(show.Id) + .ToDictionary(ordering => ordering.Id); + var orderingToAdd = 0; + var orderingToSkip = new HashSet<string>(); + var orderingToSave = new List<TMDB_AlternateOrdering>(); + + var existingSeasons = _tmdbAlternateOrderingSeasons.GetByTmdbShowID(show.Id) + .ToDictionary(season => season.Id); + var seasonsToAdd = 0; + var seasonsToSkip = new HashSet<string>(); + var seasonsToSave = new HashSet<TMDB_AlternateOrdering_Season>(); + + var existingEpisodes = _tmdbAlternateOrderingEpisodes.GetByTmdbShowID(show.Id) + .ToDictionary(episode => episode.Id); + var episodesToAdd = 0; + var episodesToSkip = new HashSet<string>(); + var episodesToSave = new List<TMDB_AlternateOrdering_Episode>(); + + foreach (var reducedCollection in show.EpisodeGroups.Results) + { + // The object sent from the show endpoint doesn't have the groups, + // we need to send another request for the full episode group + // collection to get the groups. + var collection = await UseClient(c => c.GetTvEpisodeGroupsAsync(reducedCollection.Id), $"Get alternate ordering {reducedCollection.Id} \"{reducedCollection.Name}\" for show {show.Id} \"{show.Name}\"").ConfigureAwait(false) ?? + throw new Exception($"Unable to fetch alternate ordering \"{reducedCollection.Name}\" for show \"{show.Name}\"."); + + if (!existingOrdering.TryGetValue(collection.Id, out var tmdbOrdering)) + { + orderingToAdd++; + tmdbOrdering = new(collection.Id); + } + + var orderingUpdated = tmdbOrdering.Populate(collection, show.Id); + if (orderingUpdated) + { + tmdbOrdering.LastUpdatedAt = DateTime.Now; + orderingToSave.Add(tmdbOrdering); + } + + foreach (var episodeGroup in collection.Groups) + { + if (!existingSeasons.TryGetValue(episodeGroup.Id, out var tmdbSeason)) + { + seasonsToAdd++; + tmdbSeason = new(episodeGroup.Id); + } + + var seasonUpdated = tmdbSeason.Populate(episodeGroup, collection.Id, show.Id, episodeGroup.Order); + if (seasonUpdated) + { + tmdbSeason.LastUpdatedAt = DateTime.Now; + seasonsToSave.Add(tmdbSeason); + } + + var episodeNumberCount = 1; + foreach (var episode in episodeGroup.Episodes) + { + if (!episode.Id.HasValue) + continue; + + var episodeNumber = episodeNumberCount++; + var episodeId = episode.Id.Value; + + if (!existingEpisodes.TryGetValue($"{episodeGroup.Id}:{episodeId}", out var tmdbEpisode)) + { + episodesToAdd++; + tmdbEpisode = new(episodeGroup.Id, episodeId); + } + + var episodeUpdated = tmdbEpisode.Populate(collection.Id, show.Id, episodeGroup.Order, episodeNumber); + if (episodeUpdated) + { + tmdbEpisode.LastUpdatedAt = DateTime.Now; + episodesToSave.Add(tmdbEpisode); + } + + episodesToSkip.Add(tmdbEpisode.Id); + } + + seasonsToSkip.Add(tmdbSeason.Id); + } + + orderingToSkip.Add(tmdbOrdering.Id); + } + var orderingToRemove = existingOrdering.Values + .ExceptBy(orderingToSkip, ordering => ordering.Id) + .ToList(); + var seasonsToRemove = existingSeasons.Values + .ExceptBy(seasonsToSkip, season => season.Id) + .ToList(); + var episodesToRemove = existingEpisodes.Values + .ExceptBy(episodesToSkip, episode => episode.Id) + .ToList(); + + _logger.LogDebug( + "Added/updated/removed/skipped {oa}/{ou}/{or}/{os} alternate orderings, {sa}/{su}/{sr}/{ss} alternate ordering seasons, and {ea}/{eu}/{er}/{es} alternate ordering episodes for show {ShowTitle} (Show={ShowId})", + orderingToAdd, + orderingToSave.Count - orderingToAdd, + orderingToRemove.Count, + existingOrdering.Count + orderingToAdd - orderingToRemove.Count - orderingToSave.Count, + seasonsToAdd, + seasonsToSave.Count - seasonsToAdd, + seasonsToRemove.Count, + existingSeasons.Count + seasonsToAdd - seasonsToRemove.Count - seasonsToSave.Count, + episodesToAdd, + episodesToSave.Count - episodesToAdd, + episodesToRemove.Count, + existingEpisodes.Count + episodesToAdd - episodesToRemove.Count - episodesToSave.Count, + show.Name, + show.Id); + + _tmdbAlternateOrdering.Save(orderingToSave); + _tmdbAlternateOrdering.Delete(orderingToRemove); + + _tmdbAlternateOrderingSeasons.Save(seasonsToSave); + _tmdbAlternateOrderingSeasons.Delete(seasonsToRemove); + + _tmdbAlternateOrderingEpisodes.Save(episodesToSave); + _tmdbAlternateOrderingEpisodes.Delete(episodesToRemove); + + return orderingToSave.Count > 0 || + orderingToRemove.Count > 0 || + seasonsToSave.Count > 0 || + seasonsToRemove.Count > 0 || + episodesToSave.Count > 0 || + episodesToRemove.Count > 0; + } + + private (bool, IEnumerable<int>, IEnumerable<int>) UpdateEpisodeCastAndCrew(TMDB_Episode tmdbEpisode, CreditsWithGuestStars credits) + { + var peopleToAdd = new HashSet<int>(); + var knownPeopleDict = new Dictionary<int, TMDB_Person>(); + + var counter = 0; + var castToAdd = 0; + var castToKeep = new HashSet<string>(); + var castToSave = new List<TMDB_Episode_Cast>(); + var existingCastDict = _tmdbEpisodeCast.GetByTmdbEpisodeID(tmdbEpisode.Id) + .ToDictionary(cast => cast.TmdbCreditID); + var guestOffset = credits.Cast.Count; + foreach (var cast in credits.Cast.Concat(credits.GuestStars)) + { + var ordering = counter++; + var isGuestRole = ordering >= guestOffset; + castToKeep.Add(cast.CreditId); + peopleToAdd.Add(cast.Id); + + var roleUpdated = false; + if (!existingCastDict.TryGetValue(cast.CreditId, out var role)) + { + role = new() + { + TmdbShowID = tmdbEpisode.TmdbShowID, + TmdbSeasonID = tmdbEpisode.TmdbSeasonID, + TmdbEpisodeID = tmdbEpisode.Id, + TmdbPersonID = cast.Id, + TmdbCreditID = cast.CreditId, + Ordering = ordering, + IsGuestRole = isGuestRole, + }; + castToAdd++; + roleUpdated = true; + } + + if (role.CharacterName != cast.Character) + { + role.CharacterName = cast.Character; + roleUpdated = true; + } + + if (role.Ordering != ordering) + { + role.Ordering = ordering; + roleUpdated = true; + } + + if (role.IsGuestRole != isGuestRole) + { + role.IsGuestRole = isGuestRole; + roleUpdated = true; + } + + if (roleUpdated) + { + castToSave.Add(role); + } + } + + var crewToAdd = 0; + var crewToKeep = new HashSet<string>(); + var crewToSave = new List<TMDB_Episode_Crew>(); + var existingCrewDict = _tmdbEpisodeCrew.GetByTmdbEpisodeID(tmdbEpisode.Id) + .ToDictionary(crew => crew.TmdbCreditID); + foreach (var crew in credits.Crew) + { + peopleToAdd.Add(crew.Id); + crewToKeep.Add(crew.CreditId); + var roleUpdated = false; + if (!existingCrewDict.TryGetValue(crew.CreditId, out var role)) + { + role = new() + { + TmdbShowID = tmdbEpisode.TmdbShowID, + TmdbSeasonID = tmdbEpisode.TmdbSeasonID, + TmdbEpisodeID = tmdbEpisode.Id, + TmdbPersonID = crew.Id, + TmdbCreditID = crew.CreditId, + }; + crewToAdd++; + roleUpdated = true; + } + + if (role.Department != crew.Department) + { + role.Department = crew.Department; + roleUpdated = true; + } + + if (role.Job != crew.Job) + { + role.Job = crew.Job; + roleUpdated = true; + } + + if (roleUpdated) + { + crewToSave.Add(role); + } + } + + var castToRemove = existingCastDict.Values + .ExceptBy(castToKeep, cast => cast.TmdbCreditID) + .ToList(); + var crewToRemove = existingCrewDict.Values + .ExceptBy(crewToKeep, crew => crew.TmdbCreditID) + .ToList(); + + _tmdbEpisodeCast.Save(castToSave); + _tmdbEpisodeCrew.Save(crewToSave); + _tmdbEpisodeCast.Delete(castToRemove); + _tmdbEpisodeCrew.Delete(crewToRemove); + + var peopleToCheck = existingCastDict.Values + .Select(cast => cast.TmdbPersonID) + .Concat(existingCrewDict.Values.Select(crew => crew.TmdbPersonID)) + .Except(knownPeopleDict.Keys) + .ToHashSet(); + + _logger.LogDebug( + "Added/updated/removed/skipped {aa}/{au}/{ar}/{as} cast and {ra}/{ru}/{rr}/{rs} crew for episode {EpisodeTitle} (Show={ShowId},Season={SeasonId},Episode={EpisodeId})", + castToAdd, + castToSave.Count - castToAdd, + castToRemove.Count, + existingCastDict.Count - (castToSave.Count - castToAdd), + crewToAdd, + crewToSave.Count - crewToAdd, + crewToRemove.Count, + existingCrewDict.Count - (crewToSave.Count - crewToAdd), + tmdbEpisode.EnglishTitle, + tmdbEpisode.TmdbShowID, + tmdbEpisode.TmdbSeasonID, + tmdbEpisode.TmdbEpisodeID + ); + return ( + castToSave.Count > 0 || + castToRemove.Count > 0 || + crewToSave.Count > 0 || + crewToRemove.Count > 0, + peopleToAdd, + peopleToCheck + ); + } + + public async Task ScheduleDownloadAllShowImages(int showId, bool forceDownload = false) + { + // Schedule the movie info to be downloaded or updated. + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<DownloadTmdbShowImagesJob>(c => + { + c.TmdbShowID = showId; + c.ForceDownload = forceDownload; + }).ConfigureAwait(false); + } + + public async Task DownloadAllShowImages(int showId, bool forceDownload = false) + { + using (await GetLockForEntity(ForeignEntityType.Show, showId, "images", "Update").ConfigureAwait(false)) + { + // Abort if we're within a certain time frame as to not try and get us rate-limited. + var tmdbShow = _tmdbShows.GetByTmdbShowID(showId); + if (tmdbShow is null) + return; + + _logger.LogDebug("Downloading all images for show {ShowTitle} (Show={ShowId})", tmdbShow.EnglishTitle, showId); + + await DownloadShowImages(showId, tmdbShow.OriginalLanguage, forceDownload); + + foreach (var tmdbSeason in tmdbShow.TmdbSeasons) + { + await DownloadSeasonImages(tmdbSeason.TmdbSeasonID, tmdbSeason.TmdbShowID, tmdbSeason.SeasonNumber, tmdbShow.OriginalLanguage, forceDownload); + foreach (var tmdbEpisode in tmdbSeason.TmdbEpisodes) + { + await DownloadEpisodeImages(tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, tmdbSeason.SeasonNumber, tmdbEpisode.EpisodeNumber, tmdbShow.OriginalLanguage, forceDownload); + } + } + } + } + + private async Task DownloadShowImages(int showId, TitleLanguage? mainLanguage = null, bool forceDownload = false) + { + var settings = _settingsProvider.GetSettings(); + if (!settings.TMDB.AutoDownloadPosters && !settings.TMDB.AutoDownloadLogos && !settings.TMDB.AutoDownloadBackdrops) + return; + + _logger.LogDebug("Downloading images for show. (Show={ShowId})", showId); + + var images = await UseClient(c => c.GetTvShowImagesAsync(showId), $"Get images for show {showId}").ConfigureAwait(false); + var languages = GetLanguages(mainLanguage); + if (settings.TMDB.AutoDownloadPosters) + await _imageService.DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + if (settings.TMDB.AutoDownloadLogos) + await _imageService.DownloadImagesByType(images.Logos, ImageEntityType.Logo, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + if (settings.TMDB.AutoDownloadBackdrops) + await _imageService.DownloadImagesByType(images.Backdrops, ImageEntityType.Backdrop, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + } + + private async Task DownloadSeasonImages(int seasonId, int showId, int seasonNumber, TitleLanguage? mainLanguage = null, bool forceDownload = false) + { + var settings = _settingsProvider.GetSettings(); + if (!settings.TMDB.AutoDownloadPosters) + return; + + _logger.LogDebug("Downloading images for season {SeasonNumber}. (Show={ShowId}, Season={SeasonId})", seasonNumber, showId, seasonId); + + var images = await UseClient(c => c.GetTvSeasonImagesAsync(showId, seasonNumber), $"Get images for season {seasonNumber} in show {showId}").ConfigureAwait(false); + var languages = GetLanguages(mainLanguage); + await _imageService.DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Season, seasonId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + } + + private async Task DownloadEpisodeImages(int episodeId, int showId, int seasonNumber, int episodeNumber, TitleLanguage mainLanguage, bool forceDownload = false) + { + var settings = _settingsProvider.GetSettings(); + if (!settings.TMDB.AutoDownloadThumbnails) + return; + + _logger.LogDebug("Downloading images for episode {EpisodeNumber} in season {SeasonNumber}. (Show={ShowId}, Episode={EpisodeId})", episodeNumber, seasonNumber, showId, episodeId); + + var images = await UseClient(c => c.GetTvEpisodeImagesAsync(showId, seasonNumber, episodeNumber), $"Get images for episode {episodeNumber} in season {seasonNumber} in show {showId}").ConfigureAwait(false); + var languages = GetLanguages(mainLanguage); + await _imageService.DownloadImagesByType(images.Stills, ImageEntityType.Thumbnail, ForeignEntityType.Episode, episodeId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + } + + private List<TitleLanguage> GetLanguages(TitleLanguage? mainLanguage = null) => _settingsProvider.GetSettings().TMDB.ImageLanguageOrder + .Select(a => a is TitleLanguage.Main ? mainLanguage is not TitleLanguage.None and not TitleLanguage.Unknown ? mainLanguage : null : a) + .WhereNotNull() + .Distinct() + .ToList(); + + #endregion + + #region Purge (Shows) + + public async Task PurgeAllUnusedShows() + { + var allShows = _tmdbShows.GetAll().Select(show => show.TmdbShowID) + .Concat(_tmdbImages.GetAll().Where(image => image.TmdbShowID.HasValue).Select(image => image.TmdbShowID!.Value)) + .Concat(_xrefAnidbTmdbShows.GetAll().Select(xref => xref.TmdbShowID)) + .Concat(_xrefTmdbCompanyEntity.GetAll().Where(x => x.TmdbEntityType == ForeignEntityType.Show).Select(x => x.TmdbEntityID)) + .Concat(_xrefTmdbShowNetwork.GetAll().Select(x => x.TmdbShowID)) + .Concat(_tmdbSeasons.GetAll().Select(x => x.TmdbShowID)) + .Concat(_tmdbEpisodes.GetAll().Select(x => x.TmdbShowID)) + .Concat(_tmdbAlternateOrdering.GetAll().Select(ordering => ordering.TmdbShowID)) + .Concat(_tmdbAlternateOrderingSeasons.GetAll().Select(season => season.TmdbShowID)) + .Concat(_tmdbAlternateOrderingEpisodes.GetAll().Select(episode => episode.TmdbShowID)) + .ToHashSet(); + var toKeep = _xrefAnidbTmdbShows.GetAll() + .Select(xref => xref.TmdbShowID) + .ToHashSet(); + var toBePurged = allShows + .Except(toKeep) + .ToHashSet(); + + _logger.LogInformation("Scheduling {Count} out of {AllCount} shows to be purged.", toBePurged.Count, allShows.Count); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + foreach (var showID in toBePurged) + await scheduler.StartJob<PurgeTmdbShowJob>(c => c.TmdbShowID = showID); + } + + public async Task SchedulePurgeOfShow(int showId, bool removeImageFiles = true) + { + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob<PurgeTmdbShowJob>(c => + { + c.TmdbShowID = showId; + c.RemoveImageFiles = removeImageFiles; + }); + } + + public async Task PurgeShow(int showId, bool removeImageFiles = true) + { + using (await GetLockForEntity(ForeignEntityType.Show, showId, "metadata", "Purge").ConfigureAwait(false)) + { + var show = _tmdbShows.GetByTmdbShowID(showId); + + await _linkingService.RemoveAllShowLinksForShow(showId); + + _imageService.PurgeImages(ForeignEntityType.Show, showId, removeImageFiles); + + if (show != null) + { + _logger.LogTrace( + "Removing show {ShowName} (Show={ShowId})", + show.EnglishTitle, + showId + ); + _tmdbShows.Delete(show); + } + + PurgeTitlesAndOverviews(ForeignEntityType.Show, showId); + + PurgeShowCompanies(showId, removeImageFiles); + + PurgeShowNetworks(showId, removeImageFiles); + + PurgeShowEpisodes(showId, removeImageFiles); + + PurgeShowSeasons(showId, removeImageFiles); + + await PurgeShowCastAndCrew(showId, removeImageFiles); + + PurgeShowEpisodeGroups(showId); + } + } + + private void PurgeShowCompanies(int showId, bool removeImageFiles = true) + { + var xrefsToRemove = _xrefTmdbCompanyEntity.GetByTmdbEntityTypeAndID(ForeignEntityType.Show, showId); + foreach (var xref in xrefsToRemove) + { + // Delete xref or purge company. + var xrefs = _xrefTmdbCompanyEntity.GetByTmdbCompanyID(xref.TmdbCompanyID); + if (xrefs.Count > 1) + _xrefTmdbCompanyEntity.Delete(xref); + else + PurgeCompany(xref.TmdbCompanyID, removeImageFiles); + } + } + + private void PurgeShowNetworks(int showId, bool removeImageFiles = true) + { + var xrefsToRemove = _xrefTmdbShowNetwork.GetByTmdbShowID(showId); + foreach (var xref in xrefsToRemove) + { + // Delete xref or purge company. + var xrefs = _xrefTmdbShowNetwork.GetByTmdbNetworkID(xref.TmdbNetworkID); + if (xrefs.Count > 1) + _xrefTmdbShowNetwork.Delete(xref); + else + PurgeShowNetwork(xref.TmdbNetworkID, removeImageFiles); + } + } + + private void PurgeShowNetwork(int networkId, bool removeImageFiles = true) + { + var tmdbNetwork = _tmdbNetwork.GetByTmdbNetworkID(networkId); + if (tmdbNetwork != null) + { + _logger.LogDebug("Removing TMDB Network (Network={NetworkId})", networkId); + _tmdbNetwork.Delete(tmdbNetwork); + } + + var images = _tmdbImages.GetByTmdbCompanyID(networkId); + if (images.Count > 0) + foreach (var image in images) + _imageService.PurgeImage(image, ForeignEntityType.Company, removeImageFiles); + + var xrefs = _xrefTmdbShowNetwork.GetByTmdbNetworkID(networkId); + if (xrefs.Count > 0) + { + _logger.LogDebug("Removing {count} cross-references for TMDB Network (Network={NetworkId})", xrefs.Count, networkId); + _xrefTmdbShowNetwork.Delete(xrefs); + } + } + + private void PurgeShowEpisodes(int showId, bool removeImageFiles = true) + { + var episodesToRemove = _tmdbEpisodes.GetByTmdbShowID(showId); + + _logger.LogDebug( + "Removing {count} episodes for show (Show={ShowId})", + episodesToRemove.Count, + showId + ); + foreach (var episode in episodesToRemove) + PurgeShowEpisode(episode, removeImageFiles); + + _tmdbEpisodes.Delete(episodesToRemove); + } + + private void PurgeShowEpisode(TMDB_Episode episode, bool removeImageFiles = true) + { + _imageService.PurgeImages(ForeignEntityType.Episode, episode.Id, removeImageFiles); + + PurgeTitlesAndOverviews(ForeignEntityType.Episode, episode.Id); + } + + private void PurgeShowSeasons(int showId, bool removeImageFiles = true) + { + var seasonsToRemove = _tmdbSeasons.GetByTmdbShowID(showId); + + _logger.LogDebug( + "Removing {count} seasons for show (Show={ShowId})", + seasonsToRemove.Count, + showId + ); + foreach (var season in seasonsToRemove) + PurgeShowSeason(season, removeImageFiles); + + _tmdbSeasons.Delete(seasonsToRemove); + } + + private void PurgeShowSeason(TMDB_Season season, bool removeImageFiles = true) + { + _imageService.PurgeImages(ForeignEntityType.Season, season.Id, removeImageFiles); + + PurgeTitlesAndOverviews(ForeignEntityType.Season, season.Id); + } + + private async Task PurgeShowCastAndCrew(int showId, bool removeImageFiles = true) + { + var castMembers = _tmdbEpisodeCast.GetByTmdbShowID(showId); + var crewMembers = _tmdbEpisodeCrew.GetByTmdbShowID(showId); + + _tmdbEpisodeCast.Delete(castMembers); + _tmdbEpisodeCrew.Delete(crewMembers); + + var allPeopleSet = castMembers.Select(c => c.TmdbPersonID) + .Concat(crewMembers.Select(c => c.TmdbPersonID)) + .Distinct() + .ToHashSet(); + foreach (var personId in allPeopleSet) + await PurgePerson(personId, removeImageFiles); + } + + private void PurgeShowEpisodeGroups(int showId) + { + var episodes = _tmdbAlternateOrderingEpisodes.GetByTmdbShowID(showId); + var seasons = _tmdbAlternateOrderingSeasons.GetByTmdbShowID(showId); + var orderings = _tmdbAlternateOrdering.GetByTmdbShowID(showId); + + _logger.LogDebug("Removing {EpisodeCount} episodes and {SeasonCount} seasons across {OrderingCount} alternate orderings for show. (Show={ShowId})", episodes.Count, seasons.Count, orderings.Count, showId); + _tmdbAlternateOrderingEpisodes.Delete(episodes); + _tmdbAlternateOrderingSeasons.Delete(seasons); + _tmdbAlternateOrdering.Delete(orderings); + } + + #endregion + + #endregion + + #region Shared + + #region Titles & Overviews (Shared) + + /// <summary> + /// Updates the titles and overviews for the <paramref name="tmdbEntity"/> + /// using the translation data available in the <paramref name="translations"/>. + /// </summary> + /// <param name="tmdbEntity">The local TMDB Entity to update titles and overviews for.</param> + /// <param name="translations">The translations container returned from the API.</param> + /// <param name="preferredTitleLanguages">The preferred title languages to store. If not set then we will store all languages.</param> + /// <param name="preferredOverviewLanguages">The preferred overview languages to store. If not set then we will store all languages.</param> + /// <returns>A boolean indicating if any changes were made to the titles and/or overviews.</returns> + private bool UpdateTitlesAndOverviews(IEntityMetadata tmdbEntity, TranslationsContainer? translations, HashSet<TitleLanguage>? preferredTitleLanguages, HashSet<TitleLanguage>? preferredOverviewLanguages) + { + var (titlesUpdated, overviewsUpdated) = UpdateTitlesAndOverviewsWithTuple(tmdbEntity, translations, preferredTitleLanguages, preferredOverviewLanguages); + return titlesUpdated || overviewsUpdated; + } + + /// <summary> + /// Updates the titles and overviews for the <paramref name="tmdbEntity"/> + /// using the translation data available in the <paramref name="translations"/>. + /// </summary> + /// <param name="tmdbEntity">The local TMDB Entity to update titles and overviews for.</param> + /// <param name="translations">The translations container returned from the API.</param> + /// <param name="preferredTitleLanguages">The preferred title languages to store. If not set then we will store all languages.</param> + /// <param name="preferredOverviewLanguages">The preferred overview languages to store. If not set then we will store all languages.</param> + /// <returns>A tuple indicating if any changes were made to the titles and/or overviews.</returns> + private (bool titlesUpdated, bool overviewsUpdated) UpdateTitlesAndOverviewsWithTuple(IEntityMetadata tmdbEntity, TranslationsContainer? translations, HashSet<TitleLanguage>? preferredTitleLanguages, HashSet<TitleLanguage>? preferredOverviewLanguages) + { + var existingOverviews = _tmdbOverview.GetByParentTypeAndID(tmdbEntity.Type, tmdbEntity.Id); + var existingTitles = _tmdbTitle.GetByParentTypeAndID(tmdbEntity.Type, tmdbEntity.Id); + var overviewsToAdd = 0; + var overviewsToSkip = new HashSet<int>(); + var overviewsToSave = new List<TMDB_Overview>(); + var titlesToAdd = 0; + var titlesToSkip = new HashSet<int>(); + var titlesToSave = new List<TMDB_Title>(); + foreach (var translation in translations?.Translations ?? [new() { EnglishName = string.Empty, Iso_3166_1 = "US", Iso_639_1 = "en", Data = new() { Name = string.Empty, Overview = string.Empty } }]) + { + var languageCode = translation.Iso_639_1.ToLowerInvariant(); + var countryCode = translation.Iso_3166_1.ToUpperInvariant(); + + var alwaysInclude = false; + var currentTitle = translation.Data.Name ?? string.Empty; + if (!string.IsNullOrEmpty(tmdbEntity.OriginalLanguageCode) && languageCode == tmdbEntity.OriginalLanguageCode) + { + currentTitle = tmdbEntity.OriginalTitle; + alwaysInclude = true; + } + else if (languageCode == "en" && countryCode == "US") + { + currentTitle = tmdbEntity.EnglishTitle; + alwaysInclude = true; + } + + var shouldInclude = alwaysInclude || preferredTitleLanguages is null || preferredTitleLanguages.Contains(languageCode.GetTitleLanguage()); + var existingTitle = existingTitles.FirstOrDefault(title => title.LanguageCode == languageCode && title.CountryCode == countryCode); + if (shouldInclude && !string.IsNullOrEmpty(currentTitle) && !( + // Make sure the "translation" is not just the English Title or + (languageCode != "en" && languageCode != "US" && !string.IsNullOrEmpty(tmdbEntity.EnglishTitle) && string.Equals(tmdbEntity.EnglishTitle, currentTitle, StringComparison.InvariantCultureIgnoreCase)) || + // the Original Title. + (!string.IsNullOrEmpty(tmdbEntity.OriginalLanguageCode) && languageCode != tmdbEntity.OriginalLanguageCode && !string.IsNullOrEmpty(tmdbEntity.OriginalTitle) && string.Equals(tmdbEntity.OriginalTitle, currentTitle, StringComparison.InvariantCultureIgnoreCase)) + )) + { + if (existingTitle == null) + { + titlesToAdd++; + titlesToSave.Add(new(tmdbEntity.Type, tmdbEntity.Id, currentTitle, languageCode, countryCode)); + } + else + { + if (!string.Equals(existingTitle.Value, currentTitle)) + { + existingTitle.Value = currentTitle; + titlesToSave.Add(existingTitle); + } + titlesToSkip.Add(existingTitle.TMDB_TitleID); + } + } + + alwaysInclude = false; + var currentOverview = translation.Data.Overview ?? string.Empty; + if (languageCode == "en" && countryCode == "US") + { + alwaysInclude = true; + currentOverview = tmdbEntity.EnglishOverview ?? translation.Data.Overview ?? string.Empty; + } + + shouldInclude = alwaysInclude || preferredOverviewLanguages is null || preferredOverviewLanguages.Contains(languageCode.GetTitleLanguage()); + var existingOverview = existingOverviews.FirstOrDefault(overview => overview.LanguageCode == languageCode && overview.CountryCode == countryCode); + if (shouldInclude && !string.IsNullOrEmpty(currentOverview)) + { + if (existingOverview == null) + { + overviewsToAdd++; + overviewsToSave.Add(new(tmdbEntity.Type, tmdbEntity.Id, currentOverview, languageCode, countryCode)); + } + else + { + if (!string.Equals(existingOverview.Value, currentOverview)) + { + existingOverview.Value = currentOverview; + overviewsToSave.Add(existingOverview); + } + overviewsToSkip.Add(existingOverview.TMDB_OverviewID); + } + } + } + + var titlesToRemove = existingTitles.ExceptBy(titlesToSkip, t => t.TMDB_TitleID).ToList(); + var overviewsToRemove = existingOverviews.ExceptBy(overviewsToSkip, o => o.TMDB_OverviewID).ToList(); + _logger.LogDebug( + "Added/updated/removed/skipped {ta}/{tu}/{tr}/{ts} titles and {oa}/{ou}/{or}/{os} overviews for {type} {EntityTitle} ({EntityType}={EntityId})", + titlesToAdd, + titlesToSave.Count - titlesToAdd, + titlesToRemove.Count, + titlesToSkip.Count + titlesToAdd - titlesToSave.Count, + overviewsToAdd, + overviewsToSave.Count - overviewsToAdd, + overviewsToRemove.Count, + overviewsToSkip.Count + overviewsToAdd - overviewsToSave.Count, + tmdbEntity.Type.ToString().ToLowerInvariant(), + tmdbEntity.OriginalTitle ?? tmdbEntity.EnglishTitle ?? $"<untitled {tmdbEntity.Type.ToString().ToLowerInvariant()}>", + tmdbEntity.Type.ToString(), + tmdbEntity.Id); + _tmdbOverview.Save(overviewsToSave); + _tmdbOverview.Delete(overviewsToRemove); + _tmdbTitle.Save(titlesToSave); + _tmdbTitle.Delete(titlesToRemove); + + return ( + titlesToSave.Count > 0 || titlesToRemove.Count > 0, + overviewsToSave.Count > 0 || overviewsToRemove.Count > 0 + ); + } + + private void PurgeTitlesAndOverviews(ForeignEntityType foreignType, int foreignId) + { + var overviewsToRemove = _tmdbOverview.GetByParentTypeAndID(foreignType, foreignId); + var titlesToRemove = _tmdbTitle.GetByParentTypeAndID(foreignType, foreignId); + + _logger.LogDebug( + "Removing {tr} titles and {or} overviews for {type} with id {EntityId}", + titlesToRemove.Count, + overviewsToRemove.Count, + foreignType.ToString().ToLowerInvariant(), + foreignId); + _tmdbOverview.Delete(overviewsToRemove); + _tmdbTitle.Delete(titlesToRemove); + } + + #endregion + + #region Companies (Shared) + + private async Task<bool> UpdateCompanies(IEntityMetadata tmdbEntity, List<ProductionCompany> companies) + { + var existingXrefs = _xrefTmdbCompanyEntity.GetByTmdbEntityTypeAndID(tmdbEntity.Type, tmdbEntity.Id) + .GroupBy(xref => xref.TmdbCompanyID) + .ToDictionary(xref => xref.Key, groupBy => groupBy.ToList()); + var xrefsToAdd = 0; + var xrefsToSkip = new HashSet<int>(); + var xrefsToSave = new List<TMDB_Company_Entity>(); + var indexCounter = 0; + foreach (var company in companies) + { + var currentIndex = indexCounter++; + if (existingXrefs.TryGetValue(company.Id, out var existingXrefList)) + { + var existingXref = existingXrefList[0]; + if (existingXref.Ordering != currentIndex || existingXref.ReleasedAt != tmdbEntity.ReleasedAt) + { + existingXref.Ordering = currentIndex; + existingXref.ReleasedAt = tmdbEntity.ReleasedAt; + xrefsToSave.Add(existingXref); + } + xrefsToSkip.Add(existingXref.TMDB_Company_EntityID); + } + else + { + xrefsToAdd++; + xrefsToSave.Add(new(company.Id, tmdbEntity.Type, tmdbEntity.Id, currentIndex, tmdbEntity.ReleasedAt)); + } + + await UpdateCompany(company); + } + var xrefsToRemove = existingXrefs.Values + .SelectMany(xrefs => xrefs) + .ExceptBy(xrefsToSkip, o => o.TMDB_Company_EntityID) + .ToList(); + + _logger.LogDebug( + "Added/updated/removed/skipped {oa}/{ou}/{or}/{os} company cross-references for {type} {EntityTitle} ({EntityType}={EntityId})", + xrefsToAdd, + xrefsToSave.Count - xrefsToAdd, + xrefsToRemove.Count, + xrefsToSkip.Count + xrefsToAdd - xrefsToSave.Count, + tmdbEntity.Type.ToString().ToLowerInvariant(), + tmdbEntity.OriginalTitle, + tmdbEntity.Type.ToString(), + tmdbEntity.Id); + + _xrefTmdbCompanyEntity.Save(xrefsToSave); + foreach (var xref in xrefsToRemove) + { + // Delete xref or purge company. + var xrefs = _xrefTmdbCompanyEntity.GetByTmdbCompanyID(xref.TmdbCompanyID); + if (xrefs.Count > 1) + _xrefTmdbCompanyEntity.Delete(xref); + else + PurgeCompany(xref.TmdbCompanyID); + } + + + return false; + } + + private async Task UpdateCompany(ProductionCompany company) + { + var tmdbCompany = _tmdbCompany.GetByTmdbCompanyID(company.Id) ?? new(company.Id); + var updated = tmdbCompany.Populate(company); + if (updated) + { + _logger.LogDebug("Updating studio. (Company={CompanyId})", company.Id); + _tmdbCompany.Save(tmdbCompany); + } + + var settings = _settingsProvider.GetSettings(); + if (!string.IsNullOrEmpty(company.LogoPath) && settings.TMDB.AutoDownloadStudioImages) + await _imageService.DownloadImageByType(company.LogoPath, ImageEntityType.Logo, ForeignEntityType.Company, company.Id); + } + + private void PurgeCompany(int companyId, bool removeImageFiles = true) + { + var tmdbCompany = _tmdbCompany.GetByTmdbCompanyID(companyId); + if (tmdbCompany != null) + { + _logger.LogDebug("Removing studio. (Company={CompanyId})", companyId); + _tmdbCompany.Delete(tmdbCompany); + } + + var images = _tmdbImages.GetByTmdbCompanyID(companyId); + if (images.Count > 0) + foreach (var image in images) + _imageService.PurgeImage(image, ForeignEntityType.Company, removeImageFiles); + + var xrefs = _xrefTmdbCompanyEntity.GetByTmdbCompanyID(companyId); + if (xrefs.Count > 0) + { + _logger.LogDebug("Removing {count} cross-references for studio. (Company={CompanyId})", xrefs.Count, companyId); + _xrefTmdbCompanyEntity.Delete(xrefs); + } + } + + #endregion + + #region People + + public async Task<(bool added, bool updated)> UpdatePerson(int personId, bool forceRefresh = false, bool downloadImages = false) + { + using (await GetLockForEntity(ForeignEntityType.Person, personId, "metadata & images", "Update").ConfigureAwait(false)) + { + var tmdbPerson = _tmdbPeople.GetByTmdbPersonID(personId) ?? new(personId); + if (!forceRefresh && tmdbPerson.LastUpdatedAt > DateTime.UtcNow.AddMinutes(-15)) + { + _logger.LogDebug("Skipping update for staff. (Person={PersonId})", personId); + return (false, false); + } + + _logger.LogDebug("Updating staff. (Person={PersonId})", personId); + var methods = PersonMethods.Translations; + if (downloadImages) + methods |= PersonMethods.Images; + var newlyAdded = tmdbPerson.TMDB_PersonID is 0; + var person = await UseClient(c => c.GetPersonAsync(personId, methods), $"Get person {personId}"); + var updated = tmdbPerson.Populate(person); + if (updated) + { + tmdbPerson.LastUpdatedAt = DateTime.Now; + _tmdbPeople.Save(tmdbPerson); + } + + if (downloadImages) + await DownloadPersonImages(personId, person.Images); + + return (newlyAdded, updated); + } + } + + private async Task DownloadPersonImages(int personId, ProfileImages images, bool forceDownload = false) + { + var settings = _settingsProvider.GetSettings(); + if (!settings.TMDB.AutoDownloadStaffImages) + return; + + _logger.LogDebug("Downloading images for staff. (Person={personId})", personId); + await _imageService.DownloadImagesByType(images.Profiles, ImageEntityType.Person, ForeignEntityType.Person, personId, settings.TMDB.MaxAutoStaffImages, [], forceDownload); + } + + public async Task<bool> PurgePerson(int personId, bool removeImageFiles = true) + { + using (await GetLockForEntity(ForeignEntityType.Person, personId, "metadata & images", "Purge")) + { + if (IsPersonLinkedToOtherEntities(personId)) + return false; + + var person = _tmdbPeople.GetByTmdbPersonID(personId); + if (person != null) + { + _logger.LogDebug("Removing staff. (Person={PersonId})", personId); + _tmdbPeople.Delete(person); + } + + var images = _tmdbImages.GetByTmdbPersonID(personId); + if (images.Count > 0) + foreach (var image in images) + _imageService.PurgeImage(image, ForeignEntityType.Person, removeImageFiles); + + var movieCast = _tmdbMovieCast.GetByTmdbPersonID(personId); + if (movieCast.Count > 0) + { + _logger.LogDebug("Removing {count} movie cast roles for staff. (Person={PersonId})", movieCast.Count, personId); + _tmdbMovieCast.Delete(movieCast); + } + + var movieCrew = _tmdbMovieCrew.GetByTmdbPersonID(personId); + if (movieCrew.Count > 0) + { + _logger.LogDebug("Removing {count} movie crew roles for staff. (Person={PersonId})", movieCrew.Count, personId); + _tmdbMovieCrew.Delete(movieCrew); + } + + var episodeCast = _tmdbEpisodeCast.GetByTmdbPersonID(personId); + if (episodeCast.Count > 0) + { + _logger.LogDebug("Removing {count} show cast roles for staff. (Person={PersonId})", episodeCast.Count, personId); + _tmdbEpisodeCast.Delete(episodeCast); + } + + var episodeCrew = _tmdbEpisodeCrew.GetByTmdbPersonID(personId); + if (episodeCrew.Count > 0) + { + _logger.LogDebug("Removing {count} show crew roles for staff. (Person={PersonId})", episodeCrew.Count, personId); + _tmdbEpisodeCrew.Delete(episodeCrew); + } + + return true; + } + } + + private bool IsPersonLinkedToOtherEntities(int tmdbPersonId) + { + var movieCastLinks = _tmdbMovieCast.GetByTmdbPersonID(tmdbPersonId); + if (movieCastLinks.Any()) + return true; + + var movieCrewLinks = _tmdbMovieCrew.GetByTmdbPersonID(tmdbPersonId); + if (movieCrewLinks.Any()) + return true; + + var episodeCastLinks = _tmdbEpisodeCast.GetByTmdbPersonID(tmdbPersonId); + if (episodeCastLinks.Any()) + return true; + + var episodeCrewLinks = _tmdbEpisodeCrew.GetByTmdbPersonID(tmdbPersonId); + if (episodeCrewLinks.Any()) + return true; + + return false; + } + + #endregion + + #endregion + + #region Helpers (Shared) + + private static async Task ProcessWithConcurrencyAsync<T>( + int maxConcurrent, + IEnumerable<T> enumerable, + Func<T, Task> processAsync + ) + { + if (maxConcurrent < 1) + throw new ArgumentOutOfRangeException(nameof(maxConcurrent), "Concurrency level must be at least 1."); + + var semaphore = new SemaphoreSlim(maxConcurrent); + var exceptions = new List<Exception>(); + var cancellationTokenSource = new CancellationTokenSource(); + var tasks = enumerable + .Select(item => Task.Run(async () => + { + try + { + await semaphore.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + try + { + await processAsync(item).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + })) + .ToList(); + while (tasks.Count > 0) + { + try + { + var task = await Task.WhenAny(tasks).ConfigureAwait(false); + tasks.Remove(task); + } + catch (Exception ex) + { + var task = tasks.First(task => task.IsFaulted); + tasks.Remove(task); + exceptions.Add(ex); + if (exceptions.Count > maxConcurrent) + { + cancellationTokenSource.Cancel(); + throw new AggregateException(exceptions); + } + continue; + } + } + } + + #endregion + + #region External IDs (Shared) + + /// <summary> + /// Update TvDB ID for the TMDB show if needed and the ID is available. + /// </summary> + /// <param name="show">TMDB Show.</param> + /// <param name="externalIds">External IDs.</param> + /// <returns>Indicates that the ID was updated.</returns> + private bool UpdateShowExternalIDs(TMDB_Show show, ExternalIdsTvShow externalIds) + { + if (string.IsNullOrEmpty(externalIds.TvdbId)) + { + if (!show.TvdbShowID.HasValue) + return false; + + show.TvdbShowID = null; + return true; + } + + if (!int.TryParse(externalIds.TvdbId, out var tvdbId) || tvdbId <= 0 || show.TvdbShowID == tvdbId) + return false; + + show.TvdbShowID = tvdbId; + return true; + } + + /// <summary> + /// Update TvDB ID for the TMDB episode if needed and the ID is available. + /// </summary> + /// <param name="episode">TMDB Episode.</param> + /// <param name="externalIds">External IDs.</param> + /// <returns>Indicates that the ID was updated.</returns> + private bool UpdateEpisodeExternalIDs(TMDB_Episode episode, ExternalIdsTvEpisode externalIds) + { + if (string.IsNullOrEmpty(externalIds.TvdbId)) + { + if (!episode.TvdbEpisodeID.HasValue) + return false; + + episode.TvdbEpisodeID = null; + return true; + } + + if (!int.TryParse(externalIds.TvdbId, out var tvdbId) || tvdbId <= 0 || episode.TvdbEpisodeID == tvdbId) + return false; + + episode.TvdbEpisodeID = tvdbId; + return true; + } + + /// <summary> + /// Update IMDb ID for the TMDB movie if needed and the ID is available. + /// </summary> + /// <param name="movie">TMDB Movie.</param> + /// <param name="externalIds">External IDs.</param> + /// <returns>Indicates that the ID was updated.</returns> + private bool UpdateMovieExternalIDs(TMDB_Movie movie, ExternalIdsMovie externalIds) + { + if (movie.ImdbMovieID == externalIds.ImdbId) + return false; + + movie.ImdbMovieID = externalIds.ImdbId; + return true; + } + + #endregion + + #region Locking + + private bool IsEntityLocked(ForeignEntityType entityType, int id, string metadataKey) + { + var key = $"{entityType.ToString().ToLowerInvariant()}-{metadataKey}:{id}"; + if (!_concurrencyGuards.TryGetValue(key, out var semaphore)) + return false; + return semaphore.CurrentCount == 0; + } + + private async Task<IDisposable> GetLockForEntity(ForeignEntityType entityType, int id, string metadataKey, string reason) + { + var startedAt = DateTime.Now; + var key = $"{entityType.ToString().ToLowerInvariant()}-{metadataKey}:{id}"; + _logger.LogDebug("Acquiring lock '{MetadataKey}' for {EntityType} {Id}. (Reason: {Reason})", metadataKey, entityType, id, reason); + var semaphore = _concurrencyGuards.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + var acquiredLock = await semaphore.WaitAsync(500).ConfigureAwait(false); + if (!acquiredLock) + { + _logger.LogDebug("Waiting for lock '{MetadataKey}' for {EntityType} {Id}. (Reason: {Reason})", metadataKey, entityType, id, reason); + await semaphore.WaitAsync().ConfigureAwait(false); + var deltaTime = DateTime.Now - startedAt; + _logger.LogDebug("Waited {Waited} for lock '{MetadataKey}' for {EntityType} {Id}. (Reason: {Reason})", deltaTime, metadataKey, entityType, id, reason); + } + _logger.LogDebug("Acquired lock '{MetadataKey}' for {EntityType} {Id}. (Reason: {Reason})", metadataKey, entityType, id, reason); + + var released = false; + return new DisposableAction(() => + { + if (released) return; + released = true; + var deltaTime = DateTime.Now - startedAt; + // We remove the semaphore from the dictionary before releasing it + // so new threads will acquire a new semaphore instead. + _concurrencyGuards.TryRemove(key, out _); + semaphore.Release(); + _logger.LogDebug("Released lock '{MetadataKey}' for {EntityType} {Id} after {Run}. (Reason: {Reason})", metadataKey, entityType, id, deltaTime, reason); + }); + } + + internal class DisposableAction : IDisposable + { + private readonly Action _action; + + public DisposableAction(Action action) + { + _action = action; + } + + public void Dispose() => _action(); + } + + #endregion +} diff --git a/Shoko.Server/Providers/TMDB/TmdbSearchService.cs b/Shoko.Server/Providers/TMDB/TmdbSearchService.cs new file mode 100644 index 000000000..f5e3cd130 --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbSearchService.cs @@ -0,0 +1,514 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.Models; +using TMDbLib.Objects.Search; + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public partial class TmdbSearchService +{ + + /// <summary> + /// This regex might save the day if the local database doesn't contain any prequel metadata, but the title itself contains a suffix that indicates it's a sequel of sorts. + /// </summary> + [GeneratedRegex(@"\(\d{4}\)$|\bs(?:eason)? (?:\d+|(?=[MDCLXVI])M*(?:C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3}))$|\bs\d+$|第(零〇一二三四五六七八九十百千萬億兆京垓點)+季$|\b(?:second|2nd|third|3rd|fourth|4th|fifth|5th|sixth|6th) season$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline)] + private partial Regex SequelSuffixRemovalRegex(); + + private readonly ILogger<TmdbSearchService> _logger; + + private readonly TmdbMetadataService _tmdbService; + + /// <summary> + /// Max days into the future to search for matches against. + /// </summary> + private readonly TimeSpan _maxDaysIntoTheFuture = TimeSpan.FromDays(15); + + public TmdbSearchService(ILogger<TmdbSearchService> logger, TmdbMetadataService tmdbService) + { + _logger = logger; + _tmdbService = tmdbService; + } + + public async Task<IReadOnlyList<TmdbAutoSearchResult>> SearchForAutoMatch(SVR_AniDB_Anime anime) + { + if (anime.AnimeType == (int)AnimeType.Movie) + { + return await AutoSearchForMovies(anime).ConfigureAwait(false); + } + + return await AutoSearchForShow(anime).ConfigureAwait(false); + } + + #region Movie + + public async Task<(List<SearchMovie> Page, int TotalCount)> SearchMovies(string query, bool includeRestricted = false, int year = 0, int page = 1, int pageSize = 6) + { + var results = new List<SearchMovie>(); + var firstPage = await _tmdbService.UseClient(c => c.SearchMovieAsync(query, 1, includeRestricted, year), $"Searching{(includeRestricted ? " all" : string.Empty)} movies for \"{query}\"{(year > 0 ? $" at year {year}" : string.Empty)}").ConfigureAwait(false); + var total = firstPage.TotalResults; + if (total == 0) + return (results, total); + + var lastPage = firstPage.TotalPages; + var actualPageSize = firstPage.Results.Count; + var startIndex = (page - 1) * pageSize; + var startPage = (int)Math.Floor((decimal)startIndex / actualPageSize) + 1; + var endIndex = Math.Min(startIndex + pageSize, total); + var endPage = total == endIndex ? lastPage : Math.Min((int)Math.Floor((decimal)endIndex / actualPageSize) + (endIndex % actualPageSize > 0 ? 1 : 0), lastPage); + for (var i = startPage; i <= endPage; i++) + { + var actualPage = await _tmdbService.UseClient(c => c.SearchMovieAsync(query, i, includeRestricted, year), $"Searching{(includeRestricted ? " all" : string.Empty)} movies for \"{query}\"{(year > 0 ? $" at year {year}" : string.Empty)}").ConfigureAwait(false); + results.AddRange(actualPage.Results); + } + + var skipCount = startIndex - (startPage - 1) * actualPageSize; + var pagedResults = results.Skip(skipCount).Take(pageSize).ToList(); + + _logger.LogTrace( + "Got {Count} movies from {Results} total movies at {IndexRange} across {PageRange}.", + pagedResults.Count, + total, + startIndex == endIndex ? $"index {startIndex}" : $"indexes {startIndex}-{endIndex}", + startPage == endPage ? $"{startPage} actual page" : $"{startPage}-{endPage} actual pages" + ); + + return (pagedResults, total); + } + + private async Task<IReadOnlyList<TmdbAutoSearchResult>> AutoSearchForMovies(SVR_AniDB_Anime anime) + { + // Find the official title in the origin language, to compare it against + // the original language stored in the offline tmdb search dump. + var list = new List<TmdbAutoSearchResult>(); + var allTitles = anime.Titles + .Where(title => title.TitleType is TitleType.Main or TitleType.Official); + var mainTitle = allTitles.FirstOrDefault(x => x.TitleType is TitleType.Main) ?? allTitles.First(); + var language = mainTitle.Language switch + { + TitleLanguage.Romaji => TitleLanguage.Japanese, + TitleLanguage.Pinyin => TitleLanguage.ChineseSimplified, + TitleLanguage.KoreanTranscription => TitleLanguage.Korean, + TitleLanguage.ThaiTranscription => TitleLanguage.Thai, + _ => mainTitle.Language, + }; + var title = mainTitle.Title; + var officialTitle = language == mainTitle.Language ? mainTitle.Title : + allTitles.FirstOrDefault(title => title.Language == language)?.Title; + var englishTitle = allTitles.FirstOrDefault(title => title.Language == TitleLanguage.English)?.Title; + + // Try to establish a link for every movie (episode) in the movie + // collection (anime). + var episodes = anime.AniDBEpisodes + .Where(episode => episode.EpisodeType == (int)EpisodeType.Episode || episode.EpisodeType == (int)EpisodeType.Special) + .OrderBy(episode => episode.EpisodeType) + .ThenBy(episode => episode.EpisodeNumber) + .ToList(); + + // We only have one movie in the movie collection, so don't search for + // a sub-title. + var now = DateTime.Now; + if (episodes.Count is 1) + { + // Abort if the movie have not aired within the _maxDaysIntoTheFuture limit. + var airDate = anime.AirDate ?? episodes[0].GetAirDateAsDate() ?? null; + if (!airDate.HasValue || (airDate.Value > now && airDate.Value - now > _maxDaysIntoTheFuture)) + return []; + await AutoSearchForMovie(list, anime, episodes[0], officialTitle, englishTitle, title, airDate.Value.Year, anime.IsRestricted).ConfigureAwait(false); + return list; + } + + // Find the sub title for each movie in the movie collection, then + // search for a movie matching the combined title. + foreach (var episode in episodes) + { + var allEpisodeTitles = episode.GetTitles(); + var isCompleteMovie = allEpisodeTitles.Any(title => title.Title.Contains("Complete Movie", StringComparison.InvariantCultureIgnoreCase)); + if (isCompleteMovie) + { + var airDateForAnime = anime.AirDate ?? episodes[0].GetAirDateAsDate() ?? null; + if (!airDateForAnime.HasValue || (airDateForAnime.Value > now && airDateForAnime.Value - now > _maxDaysIntoTheFuture)) + continue; + await AutoSearchForMovie(list, anime, episode, officialTitle, englishTitle, title, airDateForAnime.Value.Year, anime.IsRestricted).ConfigureAwait(false); + continue; + } + + var airDateForEpisode = episode.GetAirDateAsDate() ?? anime.AirDate ?? null; + if (!airDateForEpisode.HasValue || (airDateForEpisode.Value > now && airDateForEpisode.Value - now > _maxDaysIntoTheFuture)) + continue; + + var officialSubTitle = allEpisodeTitles.FirstOrDefault(title => title.Language == language)?.Title ?? + allEpisodeTitles.FirstOrDefault(title => title.Language == mainTitle.Language)?.Title; + var englishSubTitle = allEpisodeTitles.FirstOrDefault(title => title.Language == TitleLanguage.English)?.Title; + var isGenericTitle = string.Equals(englishSubTitle, $"Movie {episode.EpisodeNumber}", StringComparison.InvariantCultureIgnoreCase); + var officialFullTitle = !string.IsNullOrEmpty(officialSubTitle) + ? isGenericTitle ? $"{officialTitle} {episode.EpisodeNumber}" : $"{officialTitle} {officialSubTitle}" : null; + var englishFullTitle = !string.IsNullOrEmpty(englishSubTitle) + ? isGenericTitle ? $"{englishTitle} {episode.EpisodeNumber}" : $"{englishTitle} {englishSubTitle}" : null; + var mainFullTitle = !string.IsNullOrEmpty(englishSubTitle) + ? isGenericTitle ? $"{title} {episode.EpisodeNumber}" : $"{title} {englishSubTitle}" : null; + + // ~~Stolen~~ _Borrowed_ from the Shokofin code-base since we don't want to try linking extras to movies. + if (episode.AbstractEpisodeType is EpisodeType.Special or EpisodeType.Other && !string.IsNullOrEmpty(englishSubTitle)) + { + // Interviews + if (englishSubTitle.Contains("interview", StringComparison.InvariantCultureIgnoreCase)) + continue; + + // Cinema/theatrical intro/outro + if ( + ( + (englishSubTitle.StartsWith("cinema ", StringComparison.InvariantCultureIgnoreCase) || englishSubTitle.StartsWith("theatrical ", StringComparison.InvariantCultureIgnoreCase)) && + (englishSubTitle.Contains("intro", StringComparison.InvariantCultureIgnoreCase) || englishSubTitle.Contains("outro", StringComparison.InvariantCultureIgnoreCase)) + ) || + englishSubTitle.Contains("manners movie", StringComparison.InvariantCultureIgnoreCase) + ) + continue; + + // Behind the Scenes + if (englishSubTitle.Contains("behind the scenes", StringComparison.InvariantCultureIgnoreCase) || + englishSubTitle.Contains("making of", StringComparison.InvariantCultureIgnoreCase) || + englishSubTitle.Contains("music in", StringComparison.InvariantCultureIgnoreCase) || + englishSubTitle.Contains("advance screening", StringComparison.InvariantCultureIgnoreCase) || + englishSubTitle.Contains("premiere", StringComparison.InvariantCultureIgnoreCase)) + continue; + } + + await AutoSearchForMovie(list, anime, episode, officialFullTitle, englishFullTitle, mainFullTitle, airDateForEpisode.Value.Year, anime.IsRestricted).ConfigureAwait(false); + } + + return list; + } + + private async Task<bool> AutoSearchForMovie(List<TmdbAutoSearchResult> list, SVR_AniDB_Anime anime, SVR_AniDB_Episode episode, string? officialTitle, string? englishTitle, string? mainTitle, int year, bool isRestricted) + { + TmdbAutoSearchResult? result = null; + if (!string.IsNullOrEmpty(officialTitle)) + result = await AutoSearchMovieUsingTitle(anime, episode, officialTitle, includeRestricted: isRestricted, year: year).ConfigureAwait(false); + + if (result is null && !string.IsNullOrEmpty(englishTitle)) + result = await AutoSearchMovieUsingTitle(anime, episode, englishTitle, includeRestricted: isRestricted, year: year).ConfigureAwait(false); + + if (result is null && !string.IsNullOrEmpty(mainTitle)) + result = await AutoSearchMovieUsingTitle(anime, episode, mainTitle, includeRestricted: isRestricted, year: year).ConfigureAwait(false); + + if (result is not null) + list.Add(result); + return result is not null; + } + + private async Task<TmdbAutoSearchResult?> AutoSearchMovieUsingTitle(SVR_AniDB_Anime anime, SVR_AniDB_Episode episode, string query, bool includeRestricted = false, int year = 0) + { + // Brute force attempt #1: With the original title and earliest known aired year. + var (results, totalCount) = await SearchMovies(query, includeRestricted: includeRestricted, year: year).ConfigureAwait(false); + var firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} movie results for search on {Query}, best match; {MovieName} ({ID})", totalCount, query, firstViableResult.OriginalTitle, firstViableResult.Id); + + return new(anime, episode, firstViableResult) { IsRemote = true }; + } + + // Brute force attempt #2: With the original title but without the earliest known aired year. + (results, totalCount) = await SearchMovies(query, includeRestricted: includeRestricted).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} movie results for search on {Query}, best match; {MovieName} ({ID})", totalCount, query, firstViableResult.OriginalTitle, firstViableResult.Id); + + return new(anime, episode, firstViableResult) { IsRemote = true }; + } + + // Brute force attempt #3-4: Same as above, but after stripping the title of common "sequel endings" + var strippedTitle = SequelSuffixRemovalRegex().Match(query) is { Success: true } regexResult + ? query[..^regexResult.Length].TrimEnd() : null; + if (!string.IsNullOrEmpty(strippedTitle)) + { + (results, totalCount) = await SearchMovies(strippedTitle, includeRestricted: includeRestricted, year: year).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} movie results for search on {Query}, best match; {MovieName} ({ID})", totalCount, strippedTitle, firstViableResult.OriginalTitle, firstViableResult.Id); + + return new(anime, episode, firstViableResult) { IsRemote = true }; + } + (results, totalCount) = await SearchMovies(strippedTitle, includeRestricted: includeRestricted).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} movie results for search on {Query}, best match; {MovieName} ({ID})", totalCount, strippedTitle, firstViableResult.OriginalTitle, firstViableResult.Id); + + return new(anime, episode, firstViableResult) { IsRemote = true }; + } + } + + return null; + } + + #endregion + + #region Show + + public async Task<(List<SearchTv> Page, int TotalCount)> SearchShows(string query, bool includeRestricted = false, int year = 0, int page = 1, int pageSize = 6) + { + var results = new List<SearchTv>(); + var firstPage = await _tmdbService.UseClient(c => c.SearchTvShowAsync(query, 1, includeRestricted, year), $"Searching{(includeRestricted ? " all" : "")} shows for \"{query}\"{(year > 0 ? $" at year {year}" : "")}").ConfigureAwait(false); + var total = firstPage.TotalResults; + if (total == 0) + return (results, total); + + var lastPage = firstPage.TotalPages; + var actualPageSize = firstPage.Results.Count; + var startIndex = (page - 1) * pageSize; + var startPage = (int)Math.Floor((decimal)startIndex / actualPageSize) + 1; + var endIndex = Math.Min(startIndex + pageSize, total); + var endPage = total == endIndex ? lastPage : Math.Min((int)Math.Floor((decimal)endIndex / actualPageSize) + (endIndex % actualPageSize > 0 ? 1 : 0), lastPage); + for (var i = startPage; i <= endPage; i++) + { + var actualPage = await _tmdbService.UseClient(c => c.SearchTvShowAsync(query, i, includeRestricted, year), $"Searching{(includeRestricted ? " all" : "")} shows for \"{query}\"{(year > 0 ? $" at year {year}" : "")}").ConfigureAwait(false); + results.AddRange(actualPage.Results); + } + + var skipCount = startIndex - (startPage - 1) * actualPageSize; + var pagedResults = results.Skip(skipCount).Take(pageSize).ToList(); + + _logger.LogTrace( + "Got {Count} shows from {Results} total shows at {IndexRange} across {PageRange}.", + pagedResults.Count, + total, + startIndex == endIndex ? $"index {startIndex}" : $"indexes {startIndex}-{endIndex}", + startPage == endPage ? $"{startPage} actual page" : $"{startPage}-{endPage} actual pages" + ); + + return (pagedResults, total); + } + + private async Task<IReadOnlyList<TmdbAutoSearchResult>> AutoSearchForShow(SVR_AniDB_Anime anime) + { + // TODO: Improve this logic to take tmdb seasons into account, and maybe also take better anidb series relations into account in cases where the tmdb show name and anidb series name are too different. + + // Get the first or second episode to get the aired date if the anime is missing a date. + var airDate = anime.AirDate; + if (!airDate.HasValue) + { + airDate = anime.AniDBEpisodes + .Where(episode => episode.EpisodeType == (int)EpisodeType.Episode) + .OrderBy(episode => episode.EpisodeType) + .ThenBy(episode => episode.EpisodeNumber) + .Take(2) + .LastOrDefault() + ?.GetAirDateAsDate(); + } + + // Abort if the show have not aired within the _maxDaysIntoTheFuture limit. + var now = DateTime.Now; + if (!airDate.HasValue || (airDate.Value > now && airDate.Value - now > _maxDaysIntoTheFuture)) + return []; + + // Find the official title in the origin language, to compare it against + // the original language stored in the offline tmdb search dump. + var allTitles = anime.Titles + .Where(title => title.TitleType is TitleType.Main or TitleType.Official); + var mainTitle = allTitles.FirstOrDefault(x => x.TitleType is TitleType.Main) ?? allTitles.First(); + var language = mainTitle.Language switch + { + TitleLanguage.Romaji => TitleLanguage.Japanese, + TitleLanguage.Pinyin => TitleLanguage.ChineseSimplified, + TitleLanguage.KoreanTranscription => TitleLanguage.Korean, + TitleLanguage.ThaiTranscription => TitleLanguage.Thai, + _ => mainTitle.Language, + }; + + var series = anime as ISeries; + var adjustedMainTitle = mainTitle.Title; + var currentDate = airDate.Value; + IReadOnlyList<IRelatedMetadata<ISeries>> currentRelations = anime.RelatedAnime; + while (currentRelations.Count > 0) + { + foreach (var prequelRelation in currentRelations.Where(relation => relation.RelationType == RelationType.Prequel)) + { + var prequelSeries = prequelRelation.Related; + if (prequelSeries?.AirDate is not { } prequelDate || prequelDate > currentDate) + continue; + + series = prequelSeries; + currentDate = prequelDate; + currentRelations = prequelSeries.RelatedSeries; + goto continuePrequelWhileLoop; + } + break; + continuePrequelWhileLoop: + continue; + } + + // First attempt the official title in the country of origin. + var originalTitle = language == mainTitle.Language + ? mainTitle.Title + : ( + series.ID == anime.AnimeID + ? allTitles.FirstOrDefault(title => title.TitleType == TitleType.Official && title.Language == language)?.Title + : series.Titles.FirstOrDefault(title => title.Type == TitleType.Official && title.Language == language)?.Title + ); + var match = !string.IsNullOrEmpty(originalTitle) + ? await AutoSearchForShowUsingTitle(anime, originalTitle, airDate.Value, series.Restricted, language == TitleLanguage.Japanese) + : null; + + // And if that failed, then try the official english title. + if (match is null) + { + var englishTitle = series.ID == anime.AnimeID + ? allTitles.FirstOrDefault(l => l.TitleType == TitleType.Official && l.Language == TitleLanguage.English)?.Title + : series.Titles.FirstOrDefault(l => l.Type == TitleType.Official && l.Language == TitleLanguage.English)?.Title; + if (!string.IsNullOrEmpty(englishTitle) && (string.IsNullOrEmpty(originalTitle) || !string.Equals(englishTitle, originalTitle, StringComparison.Ordinal))) + match = await AutoSearchForShowUsingTitle(anime, englishTitle, airDate.Value, series.Restricted, false); + } + + // And the last ditch attempt will be to use the main title. We won't try other languages. + match ??= await AutoSearchForShowUsingTitle(anime, mainTitle.Title, airDate.Value, series.Restricted, false); + + // Also add all locally known matches for the current anime and first prequel anime if available. + var existingXrefs = anime.TmdbShowCrossReferences.ToList(); + if (series.ID != anime.AnimeID && series is SVR_AniDB_Anime secondAnime && secondAnime.TmdbShowCrossReferences is { Count: > 0 } seriesXrefs) + existingXrefs.AddRange(seriesXrefs); + if (existingXrefs is { Count: > 0 }) + { + var remoteSeries = existingXrefs + .DistinctBy(x => x.TmdbShowID) + .Select(x => x.TmdbShow) + .WhereNotNull() + .Select(x => new TmdbAutoSearchResult( + anime, + new() + { + Id = x.Id, + OriginalName = x.OriginalTitle, + Name = x.EnglishTitle, + FirstAirDate = x.FirstAiredAt?.ToDateTime(), + BackdropPath = x.BackdropPath, + GenreIds = [], + MediaType = TMDbLib.Objects.General.MediaType.Tv, + OriginalLanguage = x.OriginalLanguageCode, + OriginCountry = x.TmdbCompanies.Select(x => x.CountryOfOrigin).Distinct().ToList(), + Overview = x.EnglishOverview, + PosterPath = x.PosterPath, + Popularity = x.UserRating, + VoteAverage = x.UserRating, + VoteCount = x.UserVotes, + } + ) + { + IsLocal = true, + }) + .ToList(); + if (match is not null) + remoteSeries.Insert(0, match); + + return remoteSeries + .GroupBy(x => (x.IsMovie, x.IsMovie ? x.TmdbMovie.Id : x.TmdbShow.Id)) + .Select(x => new TmdbAutoSearchResult(x.First()) { IsLocal = x.Any(y => y.IsLocal), IsRemote = x.Any(y => y.IsRemote) }) + .ToList(); + } + + return match is not null ? [match] : []; + } + + private async Task<TmdbAutoSearchResult?> AutoSearchForShowUsingTitle(SVR_AniDB_Anime anime, string originalTitle, DateTime airDate, bool restricted, bool isJapanese) + { + // Brute force attempt #1: With the original title and earliest known aired year. + var (results, totalFound) = await SearchShows(originalTitle, includeRestricted: restricted, year: airDate.Year).ConfigureAwait(false); + var firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} show results for search on {Query}, best match; {ShowName} ({ID})", totalFound, originalTitle, firstViableResult.OriginalName, firstViableResult.Id); + + return new(anime, firstViableResult) { IsRemote = true }; + } + + // Brute force attempt #2: Same as above, but after stripping the title of common "sequel endings" + var strippedTitle = SequelSuffixRemovalRegex().Match(originalTitle) is { Success: true } regexResult + ? originalTitle[..^regexResult.Length].TrimEnd() : null; + if (!string.IsNullOrEmpty(strippedTitle)) + { + (results, totalFound) = await SearchShows(strippedTitle, includeRestricted: restricted, year: airDate.Year).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} show results for search on {Query}, best match; {ShowName} ({ID})", totalFound, strippedTitle, firstViableResult.OriginalName, firstViableResult.Id); + + return new(anime, firstViableResult) { IsRemote = true }; + } + } + + // Brute force attempt #3: Same as above, but with stripped of any sub-titles. + var titleWithoutSubTitle = strippedTitle ?? originalTitle; + var columIndex = titleWithoutSubTitle.IndexOf(isJapanese ? ' ' : ':'); + if (columIndex > 0) + { + titleWithoutSubTitle = titleWithoutSubTitle[..columIndex]; + (results, totalFound) = await SearchShows(titleWithoutSubTitle, includeRestricted: restricted, year: airDate.Year).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} show results for search on {Query}, best match; {ShowName} ({ID})", totalFound, titleWithoutSubTitle, firstViableResult.OriginalName, firstViableResult.Id); + + return new(anime, firstViableResult) { IsRemote = true }; + } + } + + // Brute force attempt #4: With the original title but without the earliest known aired year. + (results, totalFound) = await SearchShows(originalTitle, includeRestricted: restricted).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} show results for search on {Query}, best match; {ShowName} ({ID})", totalFound, originalTitle, firstViableResult.OriginalName, firstViableResult.Id); + + return new(anime, firstViableResult) { IsRemote = true }; + } + + // Brute force attempt #5: Same as above, but after stripping the title of common "sequel endings" + if (!string.IsNullOrEmpty(strippedTitle)) + { + (results, totalFound) = await SearchShows(strippedTitle, includeRestricted: restricted).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} show results for search on {Query}, best match; {ShowName} ({ID})", totalFound, strippedTitle, firstViableResult.OriginalName, firstViableResult.Id); + + return new(anime, firstViableResult) { IsRemote = true }; + } + } + + // Brute force attempt #6: Same as above, but with stripped of any sub-titles. + titleWithoutSubTitle = strippedTitle ?? originalTitle; + columIndex = titleWithoutSubTitle.IndexOf(isJapanese ? ' ' : ':'); + if (columIndex > 0) + { + titleWithoutSubTitle = titleWithoutSubTitle[..columIndex]; + (results, totalFound) = await SearchShows(titleWithoutSubTitle, includeRestricted: restricted).ConfigureAwait(false); + firstViableResult = results.FirstOrDefault(result => IsAnimation(result.GetGenres())); + if (firstViableResult is not null) + { + _logger.LogTrace("Found {Count} show results for search on {Query}, best match; {ShowName} ({ID})", totalFound, titleWithoutSubTitle, firstViableResult.OriginalName, firstViableResult.Id); + + return new(anime, firstViableResult) { IsRemote = true }; + } + } + + return null; + } + + #endregion + + #region Helpers + + private bool IsAnimation(IReadOnlyList<string> genres) => genres.Contains("animation", StringComparer.OrdinalIgnoreCase); + + #endregion +} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/Search/TraktV2SearchShowResult.cs b/Shoko.Server/Providers/TraktTV/Contracts/Search/TraktV2SearchShowResult.cs index b87704405..755020ff4 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/Search/TraktV2SearchShowResult.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/Search/TraktV2SearchShowResult.cs @@ -6,32 +6,27 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract] public class TraktV2SearchShowResult { - [DataMember(Name = "type")] public string type { get; set; } + [DataMember(Name = "type")] + public string Type { get; set; } - [DataMember(Name = "score")] public float score { get; set; } + [DataMember(Name = "score")] + public string Score { get; set; } - [DataMember(Name = "show")] public TraktV2Show show { get; set; } + [DataMember(Name = "show")] + public TraktV2Show Show { get; set; } public override string ToString() - { - return string.Format("{0} - {1} - {2}", show.Title, show.Year, show.Overview); - } - - public string ShowURL => string.Format(TraktURIs.WebsiteShow, show.ids.slug); - + => string.Format("{0} - {1} - {2}", Show.Title, Show.Year, Show.Overview); public CL_TraktTVShowResponse ToContract() - { - var contract = new CL_TraktTVShowResponse + => new() { - title = show.Title, - year = show.Year.ToString(), - url = ShowURL, + title = Show.Title, + year = Show.Year.ToString(), + url = string.Format(TraktURIs.WebsiteShow, Show.IDs.TraktSlug), first_aired = string.Empty, country = string.Empty, - overview = show.Overview, - tvdb_id = show.ids.tvdb.ToString() + overview = Show.Overview, + tvdb_id = Show.IDs.TvdbID.ToString(), }; - return contract; - } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/Search/TraktV2SearchTvDBIDShowResult.cs b/Shoko.Server/Providers/TraktTV/Contracts/Search/TraktV2SearchTvDBIDShowResult.cs deleted file mode 100644 index a8873bf85..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/Search/TraktV2SearchTvDBIDShowResult.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract] -public class TraktV2SearchTvDBIDShowResult -{ - [DataMember(Name = "type")] public string type { get; set; } - - [DataMember(Name = "score")] public string score { get; set; } - - [DataMember(Name = "show")] public TraktV2Show show { get; set; } - - [DataMember(Name = "episode")] public TraktV2Episode episode { get; set; } - - public SearchIDType ResultType - { - get - { - if (type.Equals("show", StringComparison.InvariantCultureIgnoreCase)) - { - return SearchIDType.Show; - } - - return SearchIDType.Episode; - } - } -} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktDetailsContainer.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktDetailsContainer.cs index bb66c87e6..bae6496e7 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktDetailsContainer.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktDetailsContainer.cs @@ -3,6 +3,7 @@ using System.Linq; using NLog; using Shoko.Models.Server; +using Shoko.Server.Models.Trakt; using Shoko.Server.Repositories; namespace Shoko.Server.Providers.TraktTV; @@ -149,7 +150,7 @@ public Dictionary<int, int> DictTraktSeasonsSpecials } var ts = DateTime.Now - start; - //logger.Trace("Got TvDB Seasons in {0} ms", ts.TotalMilliseconds); + logger.Trace("Got TMDB Seasons in {0} ms", ts.TotalMilliseconds); } } catch (Exception ex) diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Comment.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Comment.cs deleted file mode 100644 index 4fad67307..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Comment.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract] -public class TraktV2Comment -{ - [DataMember(Name = "id")] public int id { get; set; } - - [DataMember(Name = "comment")] public string comment { get; set; } - - [DataMember(Name = "spoiler")] public bool spoiler { get; set; } - - [DataMember(Name = "review")] public bool review { get; set; } - - [DataMember(Name = "parent_id")] public int parent_id { get; set; } - - [DataMember(Name = "created_at")] public string created_at { get; set; } - - public DateTime? CreatedAtDate => TraktTVHelper.GetDateFromUTCString(created_at); - - [DataMember(Name = "replies")] public int replies { get; set; } - - [DataMember(Name = "likes")] public int? likes { get; set; } - - [DataMember(Name = "user_rating")] public int? user_rating { get; set; } - - [DataMember(Name = "user")] public TraktV2User user { get; set; } -} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2CommentShowPost.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2CommentShowPost.cs deleted file mode 100644 index 0e5c656bc..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2CommentShowPost.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract] -public class TraktV2CommentShowPost -{ - [DataMember(Name = "show")] public TraktV2ShowPost show { get; set; } - - [DataMember(Name = "comment")] public string comment { get; set; } - - [DataMember(Name = "spoiler")] public bool spoiler { get; set; } - - public void Init(string shoutText, bool isSpoiler, string traktSlug) - { - comment = shoutText; - spoiler = isSpoiler; - show = new TraktV2ShowPost { ids = new TraktV2ShowIdsPost { slug = traktSlug } }; - } -} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Episode.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Episode.cs index b1c1bad72..55729b20f 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Episode.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Episode.cs @@ -5,11 +5,15 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract] public class TraktV2Episode { - [DataMember(Name = "season")] public int season { get; set; } + [DataMember(Name = "season")] + public int SeasonNumber { get; set; } - [DataMember(Name = "number")] public int number { get; set; } + [DataMember(Name = "number")] + public int EpisodeNumber { get; set; } - [DataMember(Name = "title")] public string title { get; set; } + [DataMember(Name = "title")] + public string Title { get; set; } - [DataMember(Name = "ids")] public TraktV2EpisodeIds ids { get; set; } + [DataMember(Name = "ids")] + public TraktV2EpisodeIds IDs { get; set; } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Follower.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Follower.cs deleted file mode 100644 index 28706c891..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Follower.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract] -public class TraktV2Follower -{ - [DataMember(Name = "followed_at")] public string followed_at { get; set; } - - [DataMember(Name = "user")] public TraktV2User user { get; set; } -} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Ids.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Ids.cs index d63ba1a03..511051a6d 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Ids.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Ids.cs @@ -5,15 +5,21 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract(Name = "ids")] public class TraktV2Ids { - [DataMember(Name = "trakt")] public int trakt { get; set; } + [DataMember(Name = "trakt")] + public int TraktID { get; set; } - [DataMember(Name = "slug")] public string slug { get; set; } + [DataMember(Name = "slug")] + public string TraktSlug { get; set; } - [DataMember(Name = "tvdb")] public int? tvdb { get; set; } + [DataMember(Name = "tvdb")] + public int? TvdbID { get; set; } - [DataMember(Name = "imdb")] public string imdb { get; set; } + [DataMember(Name = "imdb")] + public string ImdbID { get; set; } - [DataMember(Name = "tmdb")] public int? tmdb { get; set; } + [DataMember(Name = "tmdb")] + public int? TmdbID { get; set; } - [DataMember(Name = "tvrage")] public int? tvrage { get; set; } + [DataMember(Name = "tvrage")] + public int? TvRageID { get; set; } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Logo.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Logo.cs deleted file mode 100644 index a0481a7f2..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Logo.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract(Name = "logo")] -public class TraktV2Logo -{ - public string full { get; set; } -} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Movie.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Movie.cs index cb8af046d..ea6924a37 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Movie.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Movie.cs @@ -5,19 +5,22 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract(Name = "movie")] public class TraktV2Movie { - [DataMember(Name = "title")] public string Title { get; set; } + [DataMember(Name = "title")] + public string Title { get; set; } - [DataMember(Name = "overview")] public string Overview { get; set; } + [DataMember(Name = "overview")] + public string Overview { get; set; } - [DataMember(Name = "year")] public int? Year { get; set; } + [DataMember(Name = "year")] + public int? Year { get; set; } + [DataMember(Name = "ids")] + public TraktV2Ids IDs { get; set; } - [DataMember(Name = "ids")] public TraktV2Ids ids { get; set; } - - public string ShowURL => string.Format(TraktURIs.WebsiteShow, ids.slug); + public string MovieURL => string.Format(TraktURIs.WebsiteMovie, IDs.TraktSlug); public override string ToString() { - return string.Format("TraktV2Show: {0}", Title); + return string.Format("TraktV2Movie: {0}", Title); } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleEpisode.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleEpisode.cs index ad4a8a6a7..1fd930dcb 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleEpisode.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleEpisode.cs @@ -5,18 +5,20 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract] internal class TraktV2ScrobbleEpisode { - [DataMember(Name = "episode")] public TraktV2Episode episode { get; set; } + [DataMember(Name = "episode")] + public TraktV2Episode Episode { get; set; } - [DataMember(Name = "progress")] public float progress { get; set; } + [DataMember(Name = "progress")] + public float Progress { get; set; } public void Init(float progressVal, int? traktId, string slugId, int season, int episodeNumber) { - progress = progressVal; - episode = new TraktV2Episode + Progress = progressVal; + Episode = new TraktV2Episode { - ids = new TraktV2EpisodeIds { trakt = traktId.ToString(), slug = slugId }, - season = season, - number = episodeNumber + IDs = new TraktV2EpisodeIds { trakt = traktId.ToString(), slug = slugId }, + SeasonNumber = season, + EpisodeNumber = episodeNumber }; } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleMovie.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleMovie.cs index 6824c43f0..c97ba32c6 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleMovie.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ScrobbleMovie.cs @@ -5,15 +5,17 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract] internal class TraktV2ScrobbleMovie { - [DataMember(Name = "movie")] public TraktV2Movie movie { get; set; } + [DataMember(Name = "movie")] + public TraktV2Movie Movie { get; set; } - [DataMember(Name = "progress")] public float progress { get; set; } + [DataMember(Name = "progress")] + public float Progress { get; set; } public void Init(float progressVal, string traktSlug, string traktId) { - progress = progressVal; - movie = new TraktV2Movie { ids = new TraktV2Ids { slug = traktSlug } }; + Progress = progressVal; + Movie = new TraktV2Movie { IDs = new TraktV2Ids { TraktSlug = traktSlug } }; int.TryParse(traktId, out var traktID); - movie.ids.trakt = traktID; + Movie.IDs.TraktID = traktID; } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Season.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Season.cs index 52a33d9fc..d2d30ff50 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Season.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Season.cs @@ -6,9 +6,12 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract] public class TraktV2Season { - [DataMember(Name = "number")] public int number { get; set; } + [DataMember(Name = "number")] + public int SeasonNumber { get; set; } - [DataMember(Name = "ids")] public TraktV2SeasonIds ids { get; set; } + [DataMember(Name = "ids")] + public TraktV2SeasonIds IDs { get; set; } - [DataMember(Name = "episodes")] public List<TraktV2Episode> episodes { get; set; } + [DataMember(Name = "episodes")] + public List<TraktV2Episode> Episodes { get; set; } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2SeasonIds.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2SeasonIds.cs index 5da501451..5531440ab 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2SeasonIds.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2SeasonIds.cs @@ -5,11 +5,15 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract(Name = "ids")] public class TraktV2SeasonIds { - [DataMember(Name = "trakt")] public int trakt { get; set; } + [DataMember(Name = "trakt")] + public int TraktID { get; set; } - [DataMember(Name = "tvdb")] public string tvdb { get; set; } + [DataMember(Name = "tvdb")] + public string TvdbID { get; set; } - [DataMember(Name = "tmdb")] public string tmdb { get; set; } + [DataMember(Name = "tmdb")] + public string TmdbID { get; set; } - [DataMember(Name = "tvrage")] public string tvrage { get; set; } + [DataMember(Name = "tvrage")] + public string TvRageID { get; set; } } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Show.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Show.cs index 69cbafed3..f48ff49b4 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Show.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Show.cs @@ -5,15 +5,19 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract(Name = "show")] public class TraktV2Show { - [DataMember(Name = "title")] public string Title { get; set; } + [DataMember(Name = "title")] + public string Title { get; set; } - [DataMember(Name = "overview")] public string Overview { get; set; } + [DataMember(Name = "overview")] + public string Overview { get; set; } - [DataMember(Name = "year")] public int? Year { get; set; } + [DataMember(Name = "year")] + public int? Year { get; set; } - [DataMember(Name = "ids")] public TraktV2Ids ids { get; set; } + [DataMember(Name = "ids")] + public TraktV2Ids IDs { get; set; } - public string ShowURL => string.Format(TraktURIs.WebsiteShow, ids.slug); + public string ShowURL => string.Format(TraktURIs.WebsiteShow, IDs.TraktSlug); public override string ToString() { diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ShowExtended.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ShowExtended.cs index 0e9b8c893..204685469 100644 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ShowExtended.cs +++ b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2ShowExtended.cs @@ -5,51 +5,70 @@ namespace Shoko.Server.Providers.TraktTV.Contracts; [DataContract(Name = "show")] public class TraktV2ShowExtended { - [DataMember(Name = "title")] public string title { get; set; } + [DataMember(Name = "title")] + public string Title { get; set; } - [DataMember(Name = "year")] public int year { get; set; } + [DataMember(Name = "year")] + public int Year { get; set; } - [DataMember(Name = "ids")] public TraktV2Ids ids { get; set; } + [DataMember(Name = "ids")] + public TraktV2Ids IDs { get; set; } - [DataMember(Name = "overview")] public string overview { get; set; } + [DataMember(Name = "overview")] + public string Overview { get; set; } - [DataMember(Name = "first_aired")] public string first_aired { get; set; } + [DataMember(Name = "first_aired")] + public string FirstAired { get; set; } - [DataMember(Name = "airs")] public TraktV2Airs airs { get; set; } + [DataMember(Name = "airs")] + public TraktV2Airs Airs { get; set; } - [DataMember(Name = "runtime")] public string runtime { get; set; } + [DataMember(Name = "runtime")] + public string Runtime { get; set; } - [DataMember(Name = "certification")] public string certification { get; set; } + [DataMember(Name = "certification")] + public string Certification { get; set; } - [DataMember(Name = "network")] public string network { get; set; } + [DataMember(Name = "network")] + public string Network { get; set; } - [DataMember(Name = "country")] public string country { get; set; } + [DataMember(Name = "country")] + public string Country { get; set; } - [DataMember(Name = "trailer")] public string trailer { get; set; } + [DataMember(Name = "trailer")] + public string Trailer { get; set; } - [DataMember(Name = "homepage")] public string homepage { get; set; } + [DataMember(Name = "homepage")] + public string Homepage { get; set; } - [DataMember(Name = "status")] public string status { get; set; } + [DataMember(Name = "status")] + public string Status { get; set; } - [DataMember(Name = "rating")] public float rating { get; set; } + [DataMember(Name = "rating")] + public float Rating { get; set; } - [DataMember(Name = "votes")] public int votes { get; set; } + [DataMember(Name = "votes")] + public int Votes { get; set; } - [DataMember(Name = "updated_at")] public string updated_at { get; set; } + [DataMember(Name = "updated_at")] + public string UpdatedAt { get; set; } - [DataMember(Name = "language")] public string language { get; set; } + [DataMember(Name = "language")] + public string Language { get; set; } [DataMember(Name = "available_translations")] - public string[] available_translations { get; set; } + public string[] AvailableTranslations { get; set; } - [DataMember(Name = "genres")] public string[] genres { get; set; } + [DataMember(Name = "genres")] + public string[] Genres { get; set; } - [DataMember(Name = "aired_episodes")] public int aired_episodes { get; set; } + [DataMember(Name = "aired_episodes")] + public int AiredEpisodes { get; set; } public override string ToString() { - return string.Format("{0} ({1})", title, year); + return string.Format("{0} ({1})", Title, Year); } - public string ShowURL => string.Format(TraktURIs.WebsiteShow, ids.slug); + public string URL => string.Format(TraktURIs.WebsiteShow, IDs.TraktSlug); } diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Thumb.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Thumb.cs deleted file mode 100644 index 3df82ddf3..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2Thumb.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract(Name = "thumb")] -public class TraktV2Thumb -{ - public string full { get; set; } -} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2User.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2User.cs deleted file mode 100644 index 557e24519..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2User.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract(Name = "user")] -public class TraktV2User -{ - [DataMember(Name = "username")] public string username { get; set; } - - [DataMember(Name = "_private")] public bool _private { get; set; } - - [DataMember(Name = "name")] public string name { get; set; } - - [DataMember(Name = "vip")] public bool vip { get; set; } - - [DataMember(Name = "vip_ep")] public bool vip_ep { get; set; } -} diff --git a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2UserEpisodeHistory.cs b/Shoko.Server/Providers/TraktTV/Contracts/TraktV2UserEpisodeHistory.cs deleted file mode 100644 index 6b3941634..000000000 --- a/Shoko.Server/Providers/TraktTV/Contracts/TraktV2UserEpisodeHistory.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Runtime.Serialization; - -namespace Shoko.Server.Providers.TraktTV.Contracts; - -[DataContract] -public class TraktV2UserEpisodeHistory -{ - [DataMember(Name = "watched_at")] public string watched_at { get; set; } - - [DataMember(Name = "action")] public string action { get; set; } // scrobble / checkin / watch - - [DataMember(Name = "episode")] public TraktV2Episode episode { get; set; } - - [DataMember(Name = "show")] public TraktV2Show show { get; set; } -} diff --git a/Shoko.Server/Providers/TraktTV/TraktConstants.cs b/Shoko.Server/Providers/TraktTV/TraktConstants.cs index 6df49b669..7a9b97918 100644 --- a/Shoko.Server/Providers/TraktTV/TraktConstants.cs +++ b/Shoko.Server/Providers/TraktTV/TraktConstants.cs @@ -2,44 +2,10 @@ public static class TraktConstants { - public const int PaginationLimit = 10; - - // Production public const string ClientID = "a20707fa9666bea4acd86bc6ea2123bd6ffdbe996b4927cfdba96f4d3fca3042"; public const string ClientSecret = "7ef5eec766070fa0b34a4a4a2fea2ad0ddbe9bb1bc1e8eb621551c52fb288739"; public const string BaseAPIURL = @"https://api.trakt.tv"; public const string BaseWebsiteURL = @"https://trakt.tv"; - public const string PINAuth = BaseWebsiteURL + @"/pin/5309"; - - // Staging - //public const string ClientID = "5f6cb4edf31210042e5f2ab2eaa9e5d0e87116936aabde763cd4c885fea4fd76"; - //public const string ClientSecret = "d023b70cc0e8c5e18026c71f4dcdf8ca98e376288eaf9c3e1869d1b15c969d3b"; - //public const string BaseAPIURL = @"http://api.staging.trakt.tv"; // staging - //public const string BaseWebsiteURL = @"https://trakt.tv"; - //public const string PINAuth = BaseWebsiteURL + @"/pin/600"; -} - -public static class TraktSearchType -{ - // movie , show , episode , person , list - public const string movie = "movie"; - public const string show = "show"; - public const string episode = "episode"; - public const string person = "person"; - public const string list = "list"; -} - -// trakt-movie , trakt-show , trakt-episode , imdb , tmdb , tvdb , tvrage -public static class TraktSearchIDType -{ - // movie , show , episode , person , list - public const string traktmovie = "trakt-movie"; - public const string traktshow = "trakt-show"; - public const string traktepisode = "trakt-episode"; - public const string imdb = "imdb"; - public const string tmdb = "tmdb"; - public const string tvdb = "tvdb"; - public const string tvrage = "tvrage"; } public enum TraktSyncType @@ -63,12 +29,6 @@ public enum ScrobblePlayingType episode = 2 } -public enum SearchIDType -{ - Show = 1, - Episode = 2 -} - public static class TraktStatusCodes { // http://docs.trakt.apiary.io/#introduction/status-codes diff --git a/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs b/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs index fd2a50304..af05d8daf 100644 --- a/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs +++ b/Shoko.Server/Providers/TraktTV/TraktTVHelper.cs @@ -10,12 +10,12 @@ using NHibernate; using Quartz; using Shoko.Commons.Extensions; -using Shoko.Models.Client; using Shoko.Models.Enums; using Shoko.Models.Server; using Shoko.Server.Databases; using Shoko.Server.Extensions; using Shoko.Server.Models; +using Shoko.Server.Models.Trakt; using Shoko.Server.Providers.TraktTV.Contracts; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; @@ -24,6 +24,7 @@ using Shoko.Server.Settings; using Shoko.Server.Utilities; +#pragma warning disable SYSLIB0014 namespace Shoko.Server.Providers.TraktTV; public class TraktTVHelper @@ -115,7 +116,8 @@ private int SendData(string uri, string json, string verb, Dictionary<string, st if (webEx.Status == WebExceptionStatus.ProtocolError) { if (webEx.Response is HttpWebResponse response) - if (response.ResponseUri.AbsoluteUri != TraktURIs.OAuthDeviceToken && response.StatusCode == HttpStatusCode.BadRequest) { + if (response.ResponseUri.AbsoluteUri != TraktURIs.OAuthDeviceToken && response.StatusCode == HttpStatusCode.BadRequest) + { { _logger.LogError(webEx, "Error in SendData: {StatusCode}", (int)response.StatusCode); ret = (int)response.StatusCode; @@ -297,12 +299,12 @@ public void RefreshAuthToken() /* * Trakt Auth Flow - * + * * 1. Generate codes. Your app calls /oauth/device/code to generate new codes. Save this entire response for later use. * 2. Display the code. Display the user_code and instruct the user to visit the verification_url on their computer or mobile device. - * 3. Poll for authorization. Poll the /oauth/device/token method to see if the user successfully authorizes your app. + * 3. Poll for authorization. Poll the /oauth/device/token method to see if the user successfully authorizes your app. * Use the device_code and poll at the interval (in seconds) to check if the user has authorized your app. - * Use expires_in to stop polling after that many seconds, and gracefully instruct the user to restart the process. + * Use expires_in to stop polling after that many seconds, and gracefully instruct the user to restart the process. * It is important to poll at the correct interval and also stop polling when expired. * Status Codes * This method will send various HTTP status codes that you should handle accordingly. @@ -314,8 +316,8 @@ public void RefreshAuthToken() * 410 Expired - the tokens have expired, restart the process * 418 Denied - user explicitly denied this code * 429 Slow Down - your app is polling too quickly - * 4. Successful authorization. - * When you receive a 200 success response, save the access_token so your app can authenticate the user in methods that require it. + * 4. Successful authorization. + * When you receive a 200 success response, save the access_token so your app can authenticate the user in methods that require it. * The access_token is valid for 3 months. */ @@ -403,7 +405,7 @@ private void TraktAutoDeviceTokenWorker(TraktAuthDeviceCodeToken deviceCode) break; case TraktStatusCodes.Awaiting_Auth: // Signaling the user that auth is still pending - _logger.LogInformation (response, "Authorization for Shoko pending, please enter the code displayed by clicking the link"); + _logger.LogInformation(response, "Authorization for Shoko pending, please enter the code displayed by clicking the link"); break; case TraktStatusCodes.Token_Expired: // Signaling the user that Token has expired and restart is needed @@ -523,7 +525,8 @@ public void RemoveLinkAniDBTrakt(int animeID, EpisodeType aniEpType, int aniEpNu public void ScanForMatches() { - if (!_settingsProvider.GetSettings().TraktTv.Enabled) + var settings = _settingsProvider.GetSettings(); + if (!settings.TraktTv.Enabled || !settings.TraktTv.AutoLink) { return; } @@ -649,11 +652,11 @@ public void ScanForMatches() if (dictTraktSeasons != null && dictTraktSeasons.TryGetValue(xrefBase.TraktSeasonNumber, out var traktSeason)) { int episodeNumber; - + if (xrefBase.TraktStartEpisodeNumber == xrefBase.AniDBStartEpisodeNumber) { // The Trakt and AniDB start episode numbers match - episodeNumber = (traktSeason - xrefBase.TraktStartEpisodeNumber ) + ep.EpisodeNumber; + episodeNumber = traktSeason - xrefBase.TraktStartEpisodeNumber + ep.EpisodeNumber; } else { @@ -662,7 +665,7 @@ public void ScanForMatches() (ep.EpisodeNumber + xrefBase.TraktStartEpisodeNumber - 2) - (xrefBase.AniDBStartEpisodeNumber - 1); } - + if (dictTraktEpisodes.TryGetValue(episodeNumber, out var traktep)) { traktID = xrefBase.TraktID; @@ -727,11 +730,11 @@ public void ScanForMatches() if (dictTraktSeasons != null && dictTraktSeasons.TryGetValue(xrefBase.TraktSeasonNumber, out var traktSeason)) { int episodeNumber; - + if (xrefBase.TraktStartEpisodeNumber == xrefBase.AniDBStartEpisodeNumber) - { + { // The Trakt and AniDB start episode numbers match - episodeNumber = (traktSeason - xrefBase.TraktStartEpisodeNumber ) + ep.EpisodeNumber; + episodeNumber = traktSeason - xrefBase.TraktStartEpisodeNumber + ep.EpisodeNumber; } else { @@ -740,7 +743,7 @@ public void ScanForMatches() (ep.EpisodeNumber + xrefBase.TraktStartEpisodeNumber - 2) - (xrefBase.AniDBStartEpisodeNumber - 1); } - + if (dictTraktEpisodes != null && dictTraktEpisodes.TryGetValue(episodeNumber, out var traktep)) { traktID = xrefBase.TraktID; @@ -780,63 +783,6 @@ public void UpdateAllInfo(string traktID) #region Send Data to Trakt - public CL_Response<bool> PostCommentShow(string traktSlug, string commentText, bool isSpoiler) - { - var ret = new CL_Response<bool>(); - try - { - var settings = _settingsProvider.GetSettings(); - if (!settings.TraktTv.Enabled) - { - ret.ErrorMessage = "Trakt has not been enabled"; - ret.Result = false; - return ret; - } - - if (string.IsNullOrEmpty(settings.TraktTv.AuthToken)) - { - ret.ErrorMessage = "Trakt has not been authorized"; - ret.Result = false; - return ret; - } - - if (string.IsNullOrEmpty(commentText)) - { - ret.ErrorMessage = "Please enter text for your comment"; - ret.Result = false; - return ret; - } - - var comment = new TraktV2CommentShowPost(); - comment.Init(commentText, isSpoiler, traktSlug); - - var json = JSONHelper.Serialize(comment); - - - var retData = string.Empty; - TraktTVRateLimiter.Instance.EnsureRate(); - var response = SendData(TraktURIs.PostComment, json, "POST", BuildRequestHeaders(), ref retData); - if (response == TraktStatusCodes.Success || response == TraktStatusCodes.Success_Post || - response == TraktStatusCodes.Success_Delete) - { - ret.ErrorMessage = "Success"; - ret.Result = true; - return ret; - } - - ret.ErrorMessage = $"{response} Error - {retData}"; - ret.Result = false; - return ret; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TraktTVHelper.PostCommentShow"); - ret.ErrorMessage = ex.Message; - ret.Result = false; - return ret; - } - } - private DateTime GetEpisodeDateForSync(SVR_AnimeEpisode ep, TraktSyncType syncType) { DateTime epDate; @@ -1088,90 +1034,64 @@ public int Scrobble(ScrobblePlayingType scrobbleType, string animeEpisodeID, Scr public List<TraktV2SearchShowResult> SearchShowV2(string criteria) { - var results = new List<TraktV2SearchShowResult>(); - var settings = _settingsProvider.GetSettings(); if (!settings.TraktTv.Enabled || string.IsNullOrEmpty(settings.TraktTv.AuthToken)) - { - return results; - } + return []; try { - // replace spaces with a + symbo - //criteria = criteria.Replace(' ', '+'); - // Search for a series - var url = string.Format(TraktURIs.Search, criteria, TraktSearchType.show); + var url = string.Format(TraktURIs.SearchByQuery, "show", WebUtility.UrlEncode(criteria)); _logger.LogTrace("Search Trakt Show: {URL}", url); // Search for a series var json = GetFromTrakt(url); - if (string.IsNullOrEmpty(json)) - { - return new List<TraktV2SearchShowResult>(); - } + return []; var result = json.FromJSONArray<TraktV2SearchShowResult>(); if (result == null) - { - return null; - } + return []; return new List<TraktV2SearchShowResult>(result); - - // save this data for later use - //foreach (TraktTVShow tvshow in results) - // SaveExtendedShowInfo(tvshow); } catch (Exception ex) { _logger.LogError(ex, "Error in Trakt SearchSeries"); } - return null; + return []; } - public List<TraktV2SearchTvDBIDShowResult> SearchShowByIDV2(string idType, string id) + public List<TraktV2SearchShowResult> SearchShowByTmdbId(int id) { - var results = new List<TraktV2SearchTvDBIDShowResult>(); - var settings = _settingsProvider.GetSettings(); if (!settings.TraktTv.Enabled || string.IsNullOrEmpty(settings.TraktTv.AuthToken)) - { - return results; - } + return []; try { // Search for a series - var url = string.Format(TraktURIs.SearchByID, idType, id); + var url = string.Format(TraktURIs.SearchByID, "tmdb", id, "show"); _logger.LogTrace("Search Trakt Show: {Url}", url); // Search for a series var json = GetFromTrakt(url); - if (string.IsNullOrEmpty(json)) - { - return new List<TraktV2SearchTvDBIDShowResult>(); - } + return []; //var result2 = json.FromJSONArray<Class1>(); - var result = json.FromJSONArray<TraktV2SearchTvDBIDShowResult>(); + var result = json.FromJSONArray<TraktV2SearchShowResult>(); if (result == null) - { - return null; - } + return []; - return new List<TraktV2SearchTvDBIDShowResult>(result); + return result.ToList(); } catch (Exception ex) { _logger.LogError(ex, "Error in SearchSeries"); + return []; } - - return null; } @@ -1242,7 +1162,7 @@ private void SaveExtendedShowInfoV2(TraktV2ShowExtended tvshow, List<TraktV2Seas try { // save this data to the DB for use later - var show = RepoFactory.Trakt_Show.GetByTraktSlug(tvshow.ids.slug) ?? new Trakt_Show(); + var show = RepoFactory.Trakt_Show.GetByTraktSlug(tvshow.IDs.TraktSlug) ?? new Trakt_Show(); show.Populate(tvshow); RepoFactory.Trakt_Show.Save(show); @@ -1255,10 +1175,10 @@ private void SaveExtendedShowInfoV2(TraktV2ShowExtended tvshow, List<TraktV2Seas foreach (var epTemp in RepoFactory.Trakt_Episode.GetByShowID(show.Trakt_ShowID)) { TraktV2Episode ep = null; - var sea = seasons.FirstOrDefault(x => x.number == epTemp.Season); + var sea = seasons.FirstOrDefault(x => x.SeasonNumber == epTemp.Season); if (sea != null) { - ep = sea.episodes.FirstOrDefault(x => x.number == epTemp.EpisodeNumber); + ep = sea.Episodes.FirstOrDefault(x => x.EpisodeNumber == epTemp.EpisodeNumber); } // if the episode is null, it means it doesn't exist on Trakt, so we should delete it @@ -1271,32 +1191,32 @@ private void SaveExtendedShowInfoV2(TraktV2ShowExtended tvshow, List<TraktV2Seas foreach (var sea in seasons) { - var season = RepoFactory.Trakt_Season.GetByShowIDAndSeason(show.Trakt_ShowID, sea.number) ?? + var season = RepoFactory.Trakt_Season.GetByShowIDAndSeason(show.Trakt_ShowID, sea.SeasonNumber) ?? new Trakt_Season(); - season.Season = sea.number; - season.URL = string.Format(TraktURIs.WebsiteSeason, show.TraktID, sea.number); + season.Season = sea.SeasonNumber; + season.URL = string.Format(TraktURIs.WebsiteSeason, show.TraktID, sea.SeasonNumber); season.Trakt_ShowID = show.Trakt_ShowID; RepoFactory.Trakt_Season.Save(season); - if (sea.episodes == null) + if (sea.Episodes == null) { continue; } - foreach (var ep in sea.episodes) + foreach (var ep in sea.Episodes) { var episode = RepoFactory.Trakt_Episode.GetByShowIDSeasonAndEpisode( - show.Trakt_ShowID, ep.season, - ep.number) ?? new Trakt_Episode(); + show.Trakt_ShowID, ep.SeasonNumber, + ep.EpisodeNumber) ?? new Trakt_Episode(); - episode.TraktID = ep.ids.TraktID; - episode.EpisodeNumber = ep.number; + episode.TraktID = ep.IDs.TraktID; + episode.EpisodeNumber = ep.EpisodeNumber; episode.Overview = string.Empty; // this is now part of a separate API call for V2, we get this info from TvDB anyway - episode.Season = ep.season; - episode.Title = ep.title; - episode.URL = string.Format(TraktURIs.WebsiteEpisode, show.TraktID, ep.season, ep.number); + episode.Season = ep.SeasonNumber; + episode.Title = ep.Title; + episode.URL = string.Format(TraktURIs.WebsiteEpisode, show.TraktID, ep.SeasonNumber, ep.EpisodeNumber); episode.Trakt_ShowID = show.Trakt_ShowID; RepoFactory.Trakt_Episode.Save(episode); } @@ -1497,7 +1417,7 @@ public void SyncCollectionToTrakt() counter++; _logger.LogTrace("Syncing check - local collection: {Counter} / {Count} - {Name}", counter, allSeries.Count, - series.SeriesName); + series.PreferredTitle); var anime = RepoFactory.AniDB_Anime.GetByAnimeID(series.AniDB_ID); if (anime == null) @@ -1576,7 +1496,7 @@ public void SyncCollectionToTrakt() // check if we have this series locally var xrefs = - RepoFactory.CrossRef_AniDB_TraktV2.GetByTraktID(col.show.ids.slug); + RepoFactory.CrossRef_AniDB_TraktV2.GetByTraktID(col.show.IDs.TraktSlug); if (xrefs.Count <= 0) { @@ -1661,7 +1581,7 @@ public void SyncCollectionToTrakt() // check if we have this series locally var xrefs = - RepoFactory.CrossRef_AniDB_TraktV2.GetByTraktID(wtch.show.ids.slug); + RepoFactory.CrossRef_AniDB_TraktV2.GetByTraktID(wtch.show.IDs.TraktSlug); if (xrefs.Count <= 0) { @@ -1797,7 +1717,7 @@ public bool CheckTraktValidity(string slug, bool removeDBEntries) return false; } - show = RepoFactory.Trakt_Show.GetByTraktSlug(tempShow.ids.slug); + show = RepoFactory.Trakt_Show.GetByTraktSlug(tempShow.IDs.TraktSlug); } // note - getting extended show info also updates it as well @@ -1861,7 +1781,7 @@ private EpisodeSyncDetails ReconSyncTraktEpisode(SVR_AnimeSeries ser, SVR_AnimeE // get the current collected records for this series on Trakt TraktV2CollectedEpisode epTraktCol = null; - var col = collected.FirstOrDefault(x => x.show.ids.slug == traktShowID); + var col = collected.FirstOrDefault(x => x.show.IDs.TraktSlug == traktShowID); if (col != null) { var sea = col.seasons.FirstOrDefault(x => x.number == season); @@ -1875,7 +1795,7 @@ private EpisodeSyncDetails ReconSyncTraktEpisode(SVR_AnimeSeries ser, SVR_AnimeE // get the current watched records for this series on Trakt TraktV2WatchedEpisode epTraktWatched = null; - var wtc = watched.FirstOrDefault(x => x.show.ids.slug == traktShowID); + var wtc = watched.FirstOrDefault(x => x.show.IDs.TraktSlug == traktShowID); if (wtc != null) { var sea = wtc.seasons.FirstOrDefault(x => x.number == season); diff --git a/Shoko.Server/Providers/TraktTV/TraktURIs.cs b/Shoko.Server/Providers/TraktTV/TraktURIs.cs index e2427c0bd..3b3617f7b 100644 --- a/Shoko.Server/Providers/TraktTV/TraktURIs.cs +++ b/Shoko.Server/Providers/TraktTV/TraktURIs.cs @@ -7,7 +7,6 @@ public static class TraktURIs public const string OAuthDeviceCode = TraktConstants.BaseAPIURL + @"/oauth/device/code"; public const string OAuthDeviceToken = TraktConstants.BaseAPIURL + @"/oauth/device/token"; - // Website links // http://docs.trakt.apiary.io/#introduction/website-media-links public const string WebsiteShow = TraktConstants.BaseWebsiteURL + @"/shows/{0}"; @@ -20,16 +19,13 @@ public static class TraktURIs public const string WebsiteEpisode = TraktConstants.BaseWebsiteURL + @"/shows/{0}/seasons/{1}/episodes/{2}"; // /shows/:slug/seasons/:num/episodes/:num - public const string WebsitePerson = TraktConstants.BaseWebsiteURL + @"/people/{0}"; // /people/:slug - public const string WebsiteComment = TraktConstants.BaseWebsiteURL + @"/comments/{0}"; // /comments/:id - //types // movie , show , episode , person , list - public const string Search = TraktConstants.BaseAPIURL + @"/search?query={0}&type={1}"; + public const string SearchByQuery = TraktConstants.BaseAPIURL + @"/search?fields=title&type={1}&query={0}"; // /search?fields=title&type=:type&query=:query // search criteria / search type - // trakt-movie , trakt-show , trakt-episode , imdb , tmdb , tvdb , tvrage - public const string SearchByID = TraktConstants.BaseAPIURL + @"/search?id_type={0}&id={1}"; // id type / id + // trakt-movie , trakt-show , trakt-episode , imdb , tmdb + public const string SearchByID = TraktConstants.BaseAPIURL + @"/search/{0}/{1}?type={2}"; // /search/:provider/:id?type=:type // http://docs.trakt.apiary.io/#reference/shows/summary/get-a-single-show // {0} trakt ID, trakt slug, or IMDB ID Example: game-of-thrones @@ -39,18 +35,6 @@ public static class TraktURIs // {0} trakt ID, trakt slug, or IMDB ID Example: game-of-thrones public const string ShowSeasons = TraktConstants.BaseAPIURL + @"/shows/{0}/seasons?extended=episodes"; - // get comments for a show - // http://docs.trakt.apiary.io/#reference/shows/watched-progress/get-all-show-comments - public const string ShowComments = TraktConstants.BaseAPIURL + @"/shows/{0}/comments?page={1}&limit={2}"; - - // get friends - // http://docs.trakt.apiary.io/#reference/users/followers/get-friends - public const string GetUserFriends = TraktConstants.BaseAPIURL + @"/users/me/friends"; - - // get friends watched history - // http://docs.trakt.apiary.io/#reference/users/history/get-watched-history - public const string GetUserHistory = TraktConstants.BaseAPIURL + @"/users/{0}/history/episodes"; - // sync collection (add to collection) // useds for movies, series, episodes // http://docs.trakt.apiary.io/#reference/sync/add-to-collection/add-items-to-collection @@ -84,30 +68,6 @@ public static class TraktURIs // http://docs.trakt.apiary.io/#reference/sync/get-collection/get-collection public const string GetCollectedShows = TraktConstants.BaseAPIURL + @"/sync/collection/shows"; - //public const string RatedMovies = @"http://api-v2launch.trakt.tv/sync/ratings/movies"; - //public const string RatedShows = @"http://api-v2launch.trakt.tv/sync/ratings/shows"; - //public const string RatedEpisodes = @"http://api-v2launch.trakt.tv/sync/ratings/episodes"; - //public const string RatedSeasons = @"http://api-v2launch.trakt.tv/sync/ratings/seasons"; - - //public const string WatchedMovies = @"http://api-v2launch.trakt.tv/sync/watched/movies"; - //public const string WatchedShows = @"http://api-v2launch.trakt.tv/sync/watched/shows"; - - //public const string CollectedMovies = @"http://api-v2launch.trakt.tv/sync/collection/movies"; - //public const string CollectedShows = @"http://api-v2launch.trakt.tv/sync/collection/shows"; - - //public const string WatchlistMovies = @"http://api-v2launch.trakt.tv/sync/watchlist/movies"; - //public const string WatchlistShows = @"http://api-v2launch.trakt.tv/sync/watchlist/shows"; - //public const string WatchlistEpisodes = @"http://api-v2launch.trakt.tv/sync/watchlist/episodes"; - //public const string WatchlistSeasons = @"http://api-v2launch.trakt.tv/sync/watchlist/seasons"; - - //public const string SyncRatings = @"http://api-v2launch.trakt.tv/sync/ratings"; - //public const string SyncWatchlist = @"http://api-v2launch.trakt.tv/sync/watchlist"; - //public const string SyncWatched = @"http://api-v2launch.trakt.tv/sync/history"; - //public const string SyncWatchedRemove = "https://api-v2launch.trakt.tv/sync/history/remove"; - //public const string SyncCollectionRemove = "https://api-v2launch.trakt.tv/sync/collection/remove"; - //public const string SyncRatingsRemove = "https://api-v2launch.trakt.tv/sync/ratings/remove"; - //public const string SyncWatchlistRemove = "https://api-v2launch.trakt.tv/sync/watchlist/remove"; - public const string SetScrobbleStart = TraktConstants.BaseAPIURL + @"/scrobble/start"; public const string SetScrobblePause = TraktConstants.BaseAPIURL + @"/scrobble/pause"; public const string SetScrobbleStop = TraktConstants.BaseAPIURL + @"/scrobble/stop"; diff --git a/Shoko.Server/Providers/TvDB/TvDBApiHelper.cs b/Shoko.Server/Providers/TvDB/TvDBApiHelper.cs deleted file mode 100644 index 6e7a12941..000000000 --- a/Shoko.Server/Providers/TvDB/TvDBApiHelper.cs +++ /dev/null @@ -1,1185 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Quartz; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Models.TvDB; -using Shoko.Plugin.Abstractions.Services; -using Shoko.Plugin.Abstractions.Extensions; -using Shoko.Server.Extensions; -using Shoko.Server.Models; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling; -using Shoko.Server.Scheduling.Jobs.Actions; -using Shoko.Server.Scheduling.Jobs.Trakt; -using Shoko.Server.Scheduling.Jobs.TvDB; -using Shoko.Server.Server; -using Shoko.Server.Settings; -using TvDbSharper; -using TvDbSharper.Dto; - -using SentrySdk = Sentry.SentrySdk; - -namespace Shoko.Server.Providers.TvDB; - -public class TvDBApiHelper -{ - private readonly ITvDbClient _client; - private readonly ILogger<TvDBApiHelper> _logger; - private readonly JobFactory _jobFactory; - private readonly ISchedulerFactory _schedulerFactory; - private readonly ISettingsProvider _settingsProvider; - private readonly IConnectivityService _connectivityService; - - public TvDBApiHelper(ILogger<TvDBApiHelper> logger, ISettingsProvider settingsProvider, IConnectivityService connectivityService, ISchedulerFactory schedulerFactory, JobFactory jobFactory) - { - _logger = logger; - _settingsProvider = settingsProvider; - _connectivityService = connectivityService; - _schedulerFactory = schedulerFactory; - _jobFactory = jobFactory; - _client = new TvDbClient(); - _client.BaseUrl = "https://api-beta.thetvdb.com"; - } - - private string CurrentServerTime - { - get - { - var epoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Local); - var span = DateTime.Now - epoch; - return ((long)span.TotalSeconds).ToString(CultureInfo.InvariantCulture); - } - } - - private async Task CheckAuthorizationAsync() - { - try - { - _client.AcceptedLanguage = _settingsProvider.GetSettings().TvDB.Language; - if (string.IsNullOrEmpty(_client.Authentication.Token)) - { - TvDBRateLimiter.Instance.EnsureRate(); - await _client.Authentication.AuthenticateAsync(Constants.TvDB.apiKey); - if (string.IsNullOrEmpty(_client.Authentication.Token)) - { - throw new TvDbServerException("Authentication Failed", 200); - } - } - } - catch (Exception e) - { - _logger.LogError(e, "Error in TvDBAuth"); - throw; - } - } - - public async Task<TvDB_Series> GetSeriesInfoOnlineAsync(int seriesID, bool forceRefresh) - { - try - { - var tvSeries = RepoFactory.TvDB_Series.GetByTvDBID(seriesID); - if (tvSeries != null && !forceRefresh) - { - return tvSeries; - } - - await CheckAuthorizationAsync(); - - TvDBRateLimiter.Instance.EnsureRate(); - var response = await _client.Series.GetAsync(seriesID); - var series = response.Data; - - tvSeries ??= new TvDB_Series(); - - tvSeries.PopulateFromSeriesInfo(series); - RepoFactory.TvDB_Series.Save(tvSeries); - - return tvSeries; - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetSeriesInfoOnlineAsync(seriesID, forceRefresh); - } - } - // suppress 404 and move on - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return null; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TvDBApiHelper.GetSeriesInfoOnline"); - SentrySdk.CaptureException(ex); - } - - return null; - } - - public async Task<List<TVDB_Series_Search_Response>> SearchSeriesAsync(string criteria) - { - var results = new List<TVDB_Series_Search_Response>(); - - try - { - await CheckAuthorizationAsync(); - - // Search for a series - _logger.LogTrace("Search TvDB Series: {Criteria}", criteria); - - TvDBRateLimiter.Instance.EnsureRate(); - criteria = criteria.Replace("+", " "); - var response = await _client.Search.SearchSeriesByNameAsync(criteria); - var series = response?.Data; - if (series == null) - { - return results; - } - - foreach (var item in series) - { - var searchResult = new TVDB_Series_Search_Response(); - searchResult.Populate(item); - results.Add(searchResult); - } - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await SearchSeriesAsync(criteria); - } - // suppress 404 and move on - } - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return results; - } - - _logger.LogError(exception, - "TvDB returned an error code: {StatusCode}\\n {Message}\\n when searching for {Criteria}", - exception.StatusCode, exception.Message, criteria - ); - SentrySdk.CaptureException(exception); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in SearchSeries"); - SentrySdk.CaptureException(ex); - } - - return results; - } - - public async Task LinkAniDBTvDB(int animeID, int tvDBID, bool additiveLink, bool isAutomatic = false) - { - if (!additiveLink) - { - // remove all current links - _logger.LogInformation("Removing All TvDB Links for: {AnimeID}", animeID); - RemoveAllAniDBTvDBLinks(animeID, false); - } - - // check if we have this information locally - // if not download it now - var tvSeries = RepoFactory.TvDB_Series.GetByTvDBID(tvDBID); - var scheduler = await _schedulerFactory.GetScheduler(); - - if (tvSeries != null || !_connectivityService.NetworkAvailability.HasInternet()) - { - // download and update series info, episode info and episode images - // will also download fanart, posters and wide banners - await scheduler.StartJob<GetTvDBSeriesJob>(c => c.TvDBSeriesID = tvDBID); - } - else - { - tvSeries = GetSeriesInfoOnlineAsync(tvDBID, true).Result; - } - - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByAniDBAndTvDBID(animeID, tvDBID) ?? - new CrossRef_AniDB_TvDB(); - - xref.AniDBID = animeID; - - xref.TvDBID = tvDBID; - - xref.CrossRefSource = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; - - RepoFactory.CrossRef_AniDB_TvDB.Save(xref); - - _logger.LogInformation( - "Adding TvDB Link: AniDB(ID:{AnimeID}) -> TvDB(ID:{TvDbid})", animeID, tvDBID - ); - - var settings = _settingsProvider.GetSettings(); - var series = RepoFactory.AnimeSeries.GetByAnimeID(animeID); - if (settings.TraktTv.Enabled && - !string.IsNullOrEmpty(settings.TraktTv.AuthToken) && - !series.IsTraktAutoMatchingDisabled) - { - // check for Trakt associations - var trakt = RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(animeID); - if (trakt.Count != 0) - { - foreach (var a in trakt) - { - RepoFactory.CrossRef_AniDB_TraktV2.Delete(a); - } - } - - await scheduler.StartJob<SearchTraktSeriesJob>(c => c.AnimeID = animeID); - } - } - - private void RemoveAllAniDBTvDBLinks(int animeID, bool updateStats = true) - { - // check for Trakt associations - var trakt = RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeID(animeID); - if (trakt.Count != 0) - { - foreach (var a in trakt) - { - RepoFactory.CrossRef_AniDB_TraktV2.Delete(a); - } - } - - var xrefs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(animeID); - if (xrefs == null || xrefs.Count == 0) - { - return; - } - - foreach (var xref in xrefs) - { - RepoFactory.CrossRef_AniDB_TvDB.Delete(xref); - } - - if (updateStats) - { - _jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = animeID).Process().GetAwaiter().GetResult(); - } - } - - public async Task<List<TvDB_Language>> GetLanguagesAsync() - { - var languages = new List<TvDB_Language>(); - - try - { - await CheckAuthorizationAsync(); - - TvDBRateLimiter.Instance.EnsureRate(); - var response = await _client.Languages.GetAllAsync(); - var apiLanguages = response.Data; - - if (apiLanguages.Length <= 0) - { - return languages; - } - - foreach (var item in apiLanguages) - { - var lan = new TvDB_Language - { - Id = item.Id, EnglishName = item.EnglishName, Name = item.Name, Abbreviation = item.Abbreviation - }; - languages.Add(lan); - } - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetLanguagesAsync(); - } - // suppress 404 and move on - } - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return languages; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TVDBHelper.GetSeriesBannersOnline"); - SentrySdk.CaptureException(ex); - } - - return languages; - } - - public async Task DownloadAutomaticImages(int seriesID, bool forceDownload) - { - var summary = await GetSeriesImagesCountsAsync(seriesID); - if (summary == null) return; - - var settings = _settingsProvider.GetSettings(); - if (summary.Fanart > 0 && settings.TvDB.AutoFanart) await DownloadAutomaticImages(await GetFanartOnlineAsync(seriesID), seriesID, forceDownload); - if (summary.Poster > 0 && settings.TvDB.AutoPosters) await DownloadAutomaticImages(await GetPosterOnlineAsync(seriesID), seriesID, forceDownload); - if (summary.Season > 0 && settings.TvDB.AutoWideBanners) await DownloadAutomaticImages(await GetBannerOnlineAsync(seriesID), seriesID, forceDownload); - } - - private async Task<ImagesSummary> GetSeriesImagesCountsAsync(int seriesID) - { - try - { - await CheckAuthorizationAsync(); - - TvDBRateLimiter.Instance.EnsureRate(); - var response = await _client.Series.GetImagesSummaryAsync(seriesID); - return response.Data; - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetSeriesImagesCountsAsync(seriesID); - } - // suppress 404 and move on - } - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return null; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - - return null; - } - - private async Task<Image[]> GetSeriesImagesAsync(int seriesID, KeyType type) - { - await CheckAuthorizationAsync(); - - var query = new ImagesQuery { KeyType = type }; - TvDBRateLimiter.Instance.EnsureRate(); - try - { - var response = await _client.Series.GetImagesAsync(seriesID, query); - return response.Data; - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetSeriesImagesAsync(seriesID, type); - } - // suppress 404 and move on - } - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return new Image[] { }; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - catch - { - // ignore - } - - return new Image[] { }; - } - - private async Task<List<TvDB_ImageFanart>> GetFanartOnlineAsync(int seriesID) - { - var validIDs = new List<int>(); - var tvImages = new List<TvDB_ImageFanart>(); - try - { - var images = await GetSeriesImagesAsync(seriesID, KeyType.Fanart); - - var count = 0; - var settings = _settingsProvider.GetSettings(); - foreach (var image in images) - { - var id = image.Id; - if (id == 0) - { - continue; - } - - if (count >= settings.TvDB.AutoFanartAmount) - { - break; - } - - var img = RepoFactory.TvDB_ImageFanart.GetByTvDBID(id) ?? new TvDB_ImageFanart { Enabled = 1 }; - - img.Populate(seriesID, image); - img.Language = _client.AcceptedLanguage; - RepoFactory.TvDB_ImageFanart.Save(img); - tvImages.Add(img); - validIDs.Add(id); - count++; - } - - // delete any images from the database which are no longer valid - foreach (var img in RepoFactory.TvDB_ImageFanart.GetBySeriesID(seriesID)) - { - if (!validIDs.Contains(img.Id)) - { - RepoFactory.TvDB_ImageFanart.Delete(img); - } - } - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetFanartOnlineAsync(seriesID); - } - // suppress 404 and move on - } - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return tvImages; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TVDBApiHelper.GetSeriesFanartOnlineAsync"); - SentrySdk.CaptureException(ex); - } - - return tvImages; - } - - private async Task<List<TvDB_ImagePoster>> GetPosterOnlineAsync(int seriesID) - { - var validIDs = new List<int>(); - var tvImages = new List<TvDB_ImagePoster>(); - - try - { - var posters = await GetSeriesImagesAsync(seriesID, KeyType.Poster); - var season = await GetSeriesImagesAsync(seriesID, KeyType.Season); - - var images = posters.Concat(season).ToArray(); - - var count = 0; - var settings = _settingsProvider.GetSettings(); - foreach (var image in images) - { - var id = image.Id; - if (id == 0) - { - continue; - } - - if (count >= settings.TvDB.AutoPostersAmount) - { - break; - } - - var img = RepoFactory.TvDB_ImagePoster.GetByTvDBID(id) ?? new TvDB_ImagePoster { Enabled = 1 }; - - img.Populate(seriesID, image); - img.Language = _client.AcceptedLanguage; - RepoFactory.TvDB_ImagePoster.Save(img); - validIDs.Add(id); - tvImages.Add(img); - count++; - } - - // delete any images from the database which are no longer valid - foreach (var img in RepoFactory.TvDB_ImagePoster.GetBySeriesID(seriesID)) - { - if (!validIDs.Contains(img.Id)) - { - RepoFactory.TvDB_ImagePoster.Delete(img.TvDB_ImagePosterID); - } - } - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetPosterOnlineAsync(seriesID); - } - // suppress 404 and move on - } - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return tvImages; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TVDBApiHelper.GetPosterOnlineAsync"); - SentrySdk.CaptureException(ex); - } - - return tvImages; - } - - private async Task<List<TvDB_ImageWideBanner>> GetBannerOnlineAsync(int seriesID) - { - var validIDs = new List<int>(); - var tvImages = new List<TvDB_ImageWideBanner>(); - - try - { - var season = await GetSeriesImagesAsync(seriesID, KeyType.Seasonwide); - var series = await GetSeriesImagesAsync(seriesID, KeyType.Series); - - var images = season.Concat(series).ToArray(); - - var count = 0; - var settings = _settingsProvider.GetSettings(); - foreach (var image in images) - { - var id = image.Id; - if (id == 0) - { - continue; - } - - if (count >= settings.TvDB.AutoWideBannersAmount) - { - break; - } - - var img = RepoFactory.TvDB_ImageWideBanner.GetByTvDBID(id) ?? new TvDB_ImageWideBanner { Enabled = 1 }; - - img.Populate(seriesID, image); - img.Language = _client.AcceptedLanguage; - RepoFactory.TvDB_ImageWideBanner.Save(img); - validIDs.Add(id); - tvImages.Add(img); - count++; - } - - // delete any images from the database which are no longer valid - foreach (var img in RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(seriesID)) - { - if (!validIDs.Contains(img.Id)) - { - RepoFactory.TvDB_ImageWideBanner.Delete(img.TvDB_ImageWideBannerID); - } - } - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetBannerOnlineAsync(seriesID); - } - // suppress 404 and move on - } - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return tvImages; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TVDBApiHelper.GetPosterOnlineAsync"); - SentrySdk.CaptureException(ex); - } - - return tvImages; - } - - private async Task DownloadAutomaticImages(List<TvDB_ImageFanart> images, int seriesID, bool forceDownload) - { - var settings = _settingsProvider.GetSettings(); - var scheduler = await _schedulerFactory.GetScheduler(); - if (!settings.TvDB.AutoFanart) return; - // find out how many images we already have locally - var imageCount = RepoFactory.TvDB_ImageFanart.GetBySeriesID(seriesID).Count(fanart => - !string.IsNullOrEmpty(fanart.GetFullImagePath()) && File.Exists(fanart.GetFullImagePath())); - - foreach (var img in images) - { - if (imageCount < settings.TvDB.AutoFanartAmount && !string.IsNullOrEmpty(img.GetFullImagePath())) - { - var fileExists = File.Exists(img.GetFullImagePath()); - if (fileExists && !forceDownload) continue; - - await scheduler.StartJob<DownloadTvDBImageJob>(c => - { - c.Anime = RepoFactory.TvDB_Series.GetByTvDBID(seriesID)?.SeriesName; - c.ImageID = img.TvDB_ImageFanartID; - c.ImageType = ImageEntityType.TvDB_FanArt; - c.ForceDownload = forceDownload; - } - ); - imageCount++; - } - else - { - //The TvDB_AutoFanartAmount point to download less images than its available - // we should clean those image that we didn't download because those dont exists in local repo - // first we check if file was downloaded - if (string.IsNullOrEmpty(img.GetFullImagePath()) || !File.Exists(img.GetFullImagePath())) - { - RepoFactory.TvDB_ImageFanart.Delete(img.TvDB_ImageFanartID); - } - } - } - } - - private async Task DownloadAutomaticImages(List<TvDB_ImagePoster> images, int seriesID, bool forceDownload) - { - var settings = _settingsProvider.GetSettings(); - if (!settings.TvDB.AutoPosters) return; - // find out how many images we already have locally - var imageCount = RepoFactory.TvDB_ImagePoster.GetBySeriesID(seriesID).Count(fanart => - !string.IsNullOrEmpty(fanart.GetFullImagePath()) && File.Exists(fanart.GetFullImagePath())); - - var scheduler = await _schedulerFactory.GetScheduler(); - foreach (var img in images) - { - if (imageCount < settings.TvDB.AutoPostersAmount && !string.IsNullOrEmpty(img.GetFullImagePath())) - { - var fileExists = File.Exists(img.GetFullImagePath()); - if (fileExists && !forceDownload) continue; - - await scheduler.StartJob<DownloadTvDBImageJob>(c => - { - c.Anime = RepoFactory.TvDB_Series.GetByTvDBID(seriesID)?.SeriesName; - c.ImageID = img.TvDB_ImagePosterID; - c.ImageType = ImageEntityType.TvDB_Cover; - c.ForceDownload = forceDownload; - } - ); - imageCount++; - } - else - { - //The TvDB_AutoFanartAmount point to download less images than its available - // we should clean those image that we didn't download because those dont exists in local repo - // first we check if file was downloaded - if (string.IsNullOrEmpty(img.GetFullImagePath()) || !File.Exists(img.GetFullImagePath())) - { - RepoFactory.TvDB_ImagePoster.Delete(img); - } - } - } - } - - private async Task DownloadAutomaticImages(List<TvDB_ImageWideBanner> images, int seriesID, bool forceDownload) - { - var settings = _settingsProvider.GetSettings(); - // find out how many images we already have locally - if (!settings.TvDB.AutoWideBanners) return; - var imageCount = RepoFactory.TvDB_ImageWideBanner.GetBySeriesID(seriesID).Count(banner => - !string.IsNullOrEmpty(banner.GetFullImagePath()) && File.Exists(banner.GetFullImagePath())); - - var scheduler = await _schedulerFactory.GetScheduler(); - foreach (var img in images) - { - if (imageCount < settings.TvDB.AutoWideBannersAmount && !string.IsNullOrEmpty(img.GetFullImagePath())) - { - var fileExists = File.Exists(img.GetFullImagePath()); - if (fileExists && !forceDownload) continue; - await scheduler.StartJob<DownloadTvDBImageJob>(c => - { - c.Anime = RepoFactory.TvDB_Series.GetByTvDBID(seriesID)?.SeriesName; - c.ImageID = img.TvDB_ImageWideBannerID; - c.ImageType = ImageEntityType.TvDB_Banner; - c.ForceDownload = forceDownload; - } - ); - imageCount++; - } - else - { - // The TvDB_AutoFanartAmount point to download less images than its available - // we should clean those image that we didn't download because those dont exists in local repo - // first we check if file was downloaded - if (string.IsNullOrEmpty(img.GetFullImagePath()) || !File.Exists(img.GetFullImagePath())) - RepoFactory.TvDB_ImageWideBanner.Delete(img); - } - } - } - - private async Task<List<EpisodeRecord>> GetEpisodesOnlineAsync(int seriesID) - { - var apiEpisodes = new List<EpisodeRecord>(); - try - { - await CheckAuthorizationAsync(); - - var tasks = new List<Task<TvDbResponse<EpisodeRecord[]>>>(); - TvDBRateLimiter.Instance.EnsureRate(); - var firstResponse = await _client.Series.GetEpisodesAsync(seriesID, 1); - _logger.LogTrace( - "First Page: First: {First} Next: {Next} Previous: {Previous} Last: {Last}", - firstResponse?.Links?.First?.ToString() ?? "NULL", firstResponse?.Links?.Next?.ToString() ?? "NULL", - firstResponse?.Links?.Prev?.ToString() ?? "NULL", firstResponse?.Links?.Last?.ToString() ?? "NULL" - ); - - for (var i = 2; i <= (firstResponse?.Links?.Last ?? 1); i++) - { - _logger.LogTrace("Adding Task: {I}", i); - TvDBRateLimiter.Instance.EnsureRate(); - tasks.Add(_client.Series.GetEpisodesAsync(seriesID, i)); - } - - var results = await Task.WhenAll(tasks); - var lastresponse = results.Length == 0 ? firstResponse : results.Last(); - _logger.LogTrace("Last Page: First: {First} Next: {Next} Previous: {Previous} Last: {Last}", - lastresponse?.Links?.First?.ToString() ?? "NULL", lastresponse?.Links?.Next?.ToString() ?? "NULL", - lastresponse?.Links?.Prev?.ToString() ?? "NULL", lastresponse?.Links?.Last?.ToString() ?? "NULL"); - _logger.LogTrace("Last Count: {Last}", lastresponse?.Data.Length.ToString() ?? "NULL"); - apiEpisodes = firstResponse?.Data?.Concat(results.SelectMany(x => x.Data)).ToList(); - } - catch (TvDbServerException exception) - { - if (exception.StatusCode == (int)HttpStatusCode.Unauthorized) - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetEpisodesOnlineAsync(seriesID); - } - } - // suppress 404 and move on - else if (exception.StatusCode == (int)HttpStatusCode.NotFound) - { - return apiEpisodes; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - SentrySdk.CaptureException(exception); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TvDBApiHelper.GetEpisodesOnlineAsync"); - SentrySdk.CaptureException(ex); - } - - return apiEpisodes; - } - - public async Task<TvDB_Series> UpdateSeriesInfoAndImages(int seriesID, bool forceRefresh, bool downloadImages) - { - try - { - // update the series info - var scheduler = await _schedulerFactory.GetScheduler(); - var tvSeries = await GetSeriesInfoOnlineAsync(seriesID, forceRefresh); - if (tvSeries == null) - return null; - - if (downloadImages) - { - await DownloadAutomaticImages(seriesID, forceRefresh); - } - - var episodeItems = await GetEpisodesOnlineAsync(seriesID); - _logger.LogTrace("Found {Count} Episode nodes", episodeItems.Count); - - var existingEpIds = new List<int>(); - foreach (var item in episodeItems) - { - if (!existingEpIds.Contains(item.Id)) - { - existingEpIds.Add(item.Id); - } - - var ep = RepoFactory.TvDB_Episode.GetByTvDBID(item.Id) ?? new TvDB_Episode(); - ep.Populate(item); - RepoFactory.TvDB_Episode.Save(ep); - - if (!downloadImages) - { - continue; - } - - if (string.IsNullOrEmpty(ep.Filename)) - { - continue; - } - - var fileExists = File.Exists(ep.GetFullImagePath()); - if (fileExists && !forceRefresh) - { - continue; - } - - await scheduler.StartJob<DownloadTvDBImageJob>(c => - { - c.Anime = tvSeries.SeriesName; - c.ImageID = ep.TvDB_EpisodeID; - c.ImageType = ImageEntityType.TvDB_Episode; - c.ForceDownload = forceRefresh; - } - ); - } - - // get all the existing tvdb episodes, to see if any have been deleted - var allEps = RepoFactory.TvDB_Episode.GetBySeriesID(seriesID); - foreach (var oldEp in allEps) - { - if (!existingEpIds.Contains(oldEp.Id)) - { - RepoFactory.TvDB_Episode.Delete(oldEp.TvDB_EpisodeID); - } - } - - // Updating stats as it will not happen with the episodes - await Task.WhenAll(RepoFactory.CrossRef_AniDB_TvDB.GetByTvDBID(seriesID).Select(a => a.AniDBID).Distinct() - .Select(animeID => _jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = animeID).Process())); - - return tvSeries; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TVDBHelper.GetEpisodes"); - SentrySdk.CaptureException(ex); - return null; - } - } - - public void LinkAniDBTvDBEpisode(int aniDBID, int tvDBID) - { - var xref = - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAniDBAndTvDBEpisodeIDs(aniDBID, tvDBID) ?? - new CrossRef_AniDB_TvDB_Episode_Override(); - - xref.AniDBEpisodeID = aniDBID; - xref.TvDBEpisodeID = tvDBID; - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.Save(xref); - - var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(aniDBID); - - _jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = ep.AniDB_Episode.AnimeID).Process().GetAwaiter().GetResult(); - RepoFactory.AnimeEpisode.Save(ep); - - _logger.LogTrace("Changed tvdb episode association: {AniDbid}", aniDBID); - } - - // Removes all TVDB information from a series, bringing it back to a blank state. - public void RemoveLinkAniDBTvDB(int animeID, int tvDBID) - { - var xref = RepoFactory.CrossRef_AniDB_TvDB.GetByAniDBAndTvDBID(animeID, tvDBID); - if (xref == null) - { - return; - } - - RepoFactory.CrossRef_AniDB_TvDB.Delete(xref); - RepoFactory.CrossRef_AniDB_TvDB_Episode.Delete( - RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAnimeID(animeID)); - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.Delete( - RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAnimeID(animeID)); - - // Disable auto-matching when we remove an existing match for the series. - var series = RepoFactory.AnimeSeries.GetByAnimeID(animeID); - if (series != null) - { - series.IsTvDBAutoMatchingDisabled = true; - RepoFactory.AnimeSeries.Save(series, false, true); - } - - _jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = animeID).Process().GetAwaiter().GetResult(); - } - - public async Task ScanForMatches() - { - var settings = _settingsProvider.GetSettings(); - if (!settings.TvDB.AutoLink) - return; - - IReadOnlyList<SVR_AnimeSeries> allSeries = RepoFactory.CrossRef_AniDB_TvDB.GetSeriesWithoutLinks(); - - var scheduler = await _schedulerFactory.GetScheduler(); - foreach (var ser in allSeries) - { - if (ser.IsTvDBAutoMatchingDisabled) - continue; - - var anime = ser.AniDB_Anime; - if (anime == null) - continue; - - _logger.LogTrace("Found anime without TvDB association: {MaintTitle}", anime.MainTitle); - await scheduler.StartJob<SearchTvDBSeriesJob>(c => c.AnimeID = ser.AniDB_ID); - } - } - - public async Task UpdateAllInfo(bool force) - { - var scheduler = await _schedulerFactory.GetScheduler(); - var allCrossRefs = RepoFactory.CrossRef_AniDB_TvDB.GetAll(); - foreach (var xref in allCrossRefs) - { - await scheduler.StartJob<GetTvDBSeriesJob>(c => - { - c.TvDBSeriesID = xref.TvDBID; - c.ForceRefresh = force; - } - ); - } - } - - private List<int> GetUpdatedSeriesList(long serverTime) - { - return GetUpdatedSeriesListAsync(serverTime).Result; - } - - private async Task<List<int>> GetUpdatedSeriesListAsync(long lasttimeseconds) - { - var seriesList = new List<int>(); - try - { - // Unix timestamp is seconds past epoch - var lastUpdateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); - lastUpdateTime = lastUpdateTime.AddSeconds(lasttimeseconds).ToLocalTime(); - - // api limits this to a week at a time, so split it up - var spans = new List<(DateTime, DateTime)>(); - if (lastUpdateTime.AddDays(7) < DateTime.Now) - { - var time = lastUpdateTime; - while (time < DateTime.Now) - { - var nextTime = time.AddDays(7); - if (nextTime > DateTime.Now) - { - nextTime = DateTime.Now; - } - - spans.Add((time, nextTime)); - time = time.AddDays(7); - } - } - else - { - spans.Add((lastUpdateTime, DateTime.Now)); - } - - var i = 1; - var count = spans.Count; - foreach (var span in spans) - { - TvDBRateLimiter.Instance.EnsureRate(); - // this may take a while if you don't keep shoko running, so log info - _logger.LogInformation("Getting updates from TvDB, part {I} of {Count}", i, count); - i++; - var response = await _client.Updates.GetAsync(span.Item1, span.Item2); - - var updates = response?.Data; - if (updates == null) - { - continue; - } - - seriesList.AddRange(updates.Where(item => item != null).Select(item => item.Id)); - } - - return seriesList; - } - catch (TvDbServerException exception) - { - switch (exception.StatusCode) - { - case (int)HttpStatusCode.Unauthorized: - { - _client.Authentication.Token = null; - await CheckAuthorizationAsync(); - if (!string.IsNullOrEmpty(_client.Authentication.Token)) - { - return await GetUpdatedSeriesListAsync(lasttimeseconds); - } - - // suppress 404 and move on - break; - } - case (int)HttpStatusCode.NotFound: return seriesList; - } - - _logger.LogError("TvDB returned an error code: {StatusCode}\\n {Message}", - exception.StatusCode, exception.Message - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in GetUpdatedSeriesList"); - } - - return seriesList; - } - - // ReSharper disable once RedundantAssignment - public string IncrementalTvDBUpdate(ref List<int> tvDBIDs, ref bool tvDBOnline) - { - // check if we have record of doing an automated update for the TvDB previously - // if we have then we have kept a record of the server time and can do a delta update - // otherwise we need to do a full update and keep a record of the time - - var allTvDBIDs = new List<int>(); - tvDBIDs ??= new List<int>(); - tvDBOnline = true; - - try - { - // record the tvdb server time when we started - // we record the time now instead of after we finish, to include any possible misses - var currentTvDBServerTime = CurrentServerTime; - if (currentTvDBServerTime.Length == 0) - { - tvDBOnline = false; - return currentTvDBServerTime; - } - - foreach (var ser in RepoFactory.AnimeSeries.GetAll()) - { - var xrefs = ser.TvDBXrefs; - if (xrefs == null) - { - continue; - } - - foreach (var xref in xrefs) - { - if (!allTvDBIDs.Contains(xref.TvDBID)) - { - allTvDBIDs.Add(xref.TvDBID); - } - } - } - - // get the time we last did a TvDB update - // if this is the first time it will be null - // update the anidb info ever 24 hours - - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.TvDBInfo); - - var lastServerTime = string.Empty; - if (sched != null) - { - var ts = DateTime.Now - sched.LastUpdate; - _logger.LogTrace("Last tvdb info update was {TotalHours} hours ago", ts.TotalHours); - if (!string.IsNullOrEmpty(sched.UpdateDetails)) - { - lastServerTime = sched.UpdateDetails; - } - - // the UpdateDetails field for this type will actually contain the last server time from - // TheTvDB that a full update was performed - } - - - // get a list of updates from TvDB since that time - if (lastServerTime.Length > 0) - { - if (!long.TryParse(lastServerTime, out var lasttimeseconds)) - { - lasttimeseconds = -1; - } - - if (lasttimeseconds < 0) - { - tvDBIDs = allTvDBIDs; - return CurrentServerTime; - } - - var seriesList = GetUpdatedSeriesList(lasttimeseconds); - _logger.LogTrace("{Count} series have been updated since last download", seriesList.Count); - _logger.LogTrace("{Count} TvDB series locally", allTvDBIDs.Count); - - foreach (var id in seriesList) - { - if (allTvDBIDs.Contains(id)) - { - tvDBIDs.Add(id); - } - } - - _logger.LogTrace("{Count} TvDB local series have been updated since last download", tvDBIDs.Count); - } - else - { - // use the full list - tvDBIDs = allTvDBIDs; - } - - return CurrentServerTime; - } - catch (Exception ex) - { - _logger.LogError(ex, "IncrementalTvDBUpdate"); - return string.Empty; - } - } -} diff --git a/Shoko.Server/Providers/TvDB/TvDBDetails.cs b/Shoko.Server/Providers/TvDB/TvDBDetails.cs deleted file mode 100644 index 577bafd30..000000000 --- a/Shoko.Server/Providers/TvDB/TvDBDetails.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using Shoko.Models.Server; -using Shoko.Server.Repositories; - -namespace Shoko.Models.TvDB; - -public class TvDBDetails -{ - private static Logger logger = LogManager.GetCurrentClassLogger(); - - public int TvDBID { get; set; } - - public TvDBDetails(int tvDBID) - { - TvDBID = tvDBID; - - PopulateTvDBEpisodes(); - } - - private Dictionary<int, TvDB_Episode> dictTvDBEpisodes; - - public Dictionary<int, TvDB_Episode> DictTvDBEpisodes - { - get - { - if (dictTvDBEpisodes == null) - { - try - { - if (TvDBEpisodes != null) - { - var start = DateTime.Now; - - dictTvDBEpisodes = new Dictionary<int, TvDB_Episode>(); - // create a dictionary of absolute episode numbers for tvdb episodes - // sort by season and episode number - // ignore season 0, which is used for specials - var eps = TvDBEpisodes; - - - var i = 1; - foreach (var ep in eps) - { - dictTvDBEpisodes[i] = ep; - i++; - } - - var ts = DateTime.Now - start; - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - return dictTvDBEpisodes; - } - } - - private Dictionary<int, int> dictTvDBSeasons; - - public Dictionary<int, int> DictTvDBSeasons - { - get - { - if (dictTvDBSeasons == null) - { - try - { - if (TvDBEpisodes != null) - { - var start = DateTime.Now; - - dictTvDBSeasons = new Dictionary<int, int>(); - // create a dictionary of season numbers and the first episode for that season - - var eps = TvDBEpisodes; - var i = 1; - var lastSeason = -999; - foreach (var ep in eps) - { - if (ep.SeasonNumber != lastSeason) - { - dictTvDBSeasons[ep.SeasonNumber] = i; - } - - lastSeason = ep.SeasonNumber; - i++; - } - - var ts = DateTime.Now - start; - //logger.Trace("Got TvDB Seasons in {0} ms", ts.TotalMilliseconds); - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - return dictTvDBSeasons; - } - } - - private Dictionary<int, int> dictTvDBSeasonsSpecials; - - public Dictionary<int, int> DictTvDBSeasonsSpecials - { - get - { - if (dictTvDBSeasonsSpecials == null) - { - try - { - if (TvDBEpisodes != null) - { - var start = DateTime.Now; - - dictTvDBSeasonsSpecials = new Dictionary<int, int>(); - // create a dictionary of season numbers and the first episode for that season - - var eps = TvDBEpisodes; - var i = 1; - var lastSeason = -999; - foreach (var ep in eps) - { - if (ep.SeasonNumber > 0) - { - continue; - } - - var thisSeason = 0; - if (ep.AirsBeforeSeason.HasValue) - { - thisSeason = ep.AirsBeforeSeason.Value; - } - - if (ep.AirsAfterSeason.HasValue) - { - thisSeason = ep.AirsAfterSeason.Value; - } - - if (thisSeason != lastSeason) - { - dictTvDBSeasonsSpecials[thisSeason] = i; - } - - lastSeason = thisSeason; - i++; - } - - var ts = DateTime.Now - start; - //logger.Trace("Got TvDB Seasons in {0} ms", ts.TotalMilliseconds); - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - return dictTvDBSeasonsSpecials; - } - } - - private void PopulateTvDBEpisodes() - { - try - { - tvDBEpisodes = RepoFactory.TvDB_Episode.GetBySeriesID(TvDBID) - .OrderBy(a => a.SeasonNumber) - .ThenBy(a => a.EpisodeNumber) - .ToList(); - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - private List<TvDB_Episode> tvDBEpisodes; - - public List<TvDB_Episode> TvDBEpisodes - { - get - { - if (tvDBEpisodes == null) - { - PopulateTvDBEpisodes(); - } - - return tvDBEpisodes; - } - } -} diff --git a/Shoko.Server/Providers/TvDB/TvDBLinkingHelper.cs b/Shoko.Server/Providers/TvDB/TvDBLinkingHelper.cs deleted file mode 100644 index f2256d38a..000000000 --- a/Shoko.Server/Providers/TvDB/TvDBLinkingHelper.cs +++ /dev/null @@ -1,1338 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using Shoko.Commons.Extensions; -using Shoko.Commons.Utils; -using Shoko.Models.Azure; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Extensions; -using Shoko.Server.Models; -using Shoko.Server.Repositories; -using Shoko.Server.Utilities; - -namespace Shoko.Server; - -public static class TvDBLinkingHelper -{ - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - - public static void GenerateTvDBEpisodeMatches(int animeID, bool skipMatchClearing = false) - { - var start = DateTime.Now; - // wipe old links except User Verified - if (!skipMatchClearing) - { - RepoFactory.CrossRef_AniDB_TvDB_Episode.DeleteAllUnverifiedLinksForAnime(animeID); - } - - var tvxrefs = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(animeID); - var tvdbID = tvxrefs.FirstOrDefault()?.TvDBID ?? 0; - - var matches = GetTvDBEpisodeMatches(animeID, tvdbID); - - var tosave = new List<CrossRef_AniDB_TvDB_Episode>(); - foreach (var match in matches) - { - if (match.AniDB == null || match.TvDB == null) - { - continue; - } - - var xref = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBAndTvDBEpisodeIDs(match.AniDB.EpisodeID, - match.TvDB.Id); - // Don't touch User Verified links - if (xref?.MatchRating == MatchRating.UserVerified) - { - continue; - } - - // check for duplicates only if we skip clearing the links - if (skipMatchClearing) - { - xref = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAniDBAndTvDBEpisodeIDs(match.AniDB.EpisodeID, - match.TvDB.Id); - if (xref != null) - { - if (xref.MatchRating != match.Rating) - { - xref.MatchRating = match.Rating; - tosave.Add(xref); - } - - continue; - } - } - - if (xref == null) - { - xref = new CrossRef_AniDB_TvDB_Episode(); - } - - xref.AniDBEpisodeID = match.AniDB.EpisodeID; - xref.TvDBEpisodeID = match.TvDB.Id; - xref.MatchRating = match.Rating; - - tosave.Add(xref); - } - - TimeSpan ts; - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID)?.MainTitle ?? animeID.ToString(); - - if (tosave.Count == 0) - { - ts = DateTime.Now - start; - logger.Trace($"Updated TvDB Matches for {anime} in {ts.TotalMilliseconds}ms"); - return; - } - - tosave.Batch(50).ForEach(RepoFactory.CrossRef_AniDB_TvDB_Episode.Save); - ts = DateTime.Now - start; - logger.Trace($"Updated TvDB Matches for {anime} in {ts.TotalMilliseconds}ms"); - } - - public static List<CrossRef_AniDB_TvDB_Episode> GetMatchPreview(int animeID, int tvdbID) - { - var matches = GetTvDBEpisodeMatches(animeID, tvdbID); - return matches.Where(a => a.AniDB != null && a.TvDB != null).OrderBy(a => a.AniDB.EpisodeType) - .ThenBy(a => a.AniDB.EpisodeNumber).Select(match => new CrossRef_AniDB_TvDB_Episode - { - AniDBEpisodeID = match.AniDB.EpisodeID, TvDBEpisodeID = match.TvDB.Id, MatchRating = match.Rating - }).ToList(); - } - - public static List<CrossRef_AniDB_TvDB_Episode> GetMatchPreviewWithOverrides(int animeID, int tvdbID) - { - var matches = GetMatchPreview(animeID, tvdbID); - var overrides = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAnimeID(animeID); - var result = new List<CrossRef_AniDB_TvDB_Episode>(); - foreach (var match in matches) - { - var match_override = overrides.FirstOrDefault(a => a.AniDBEpisodeID == match.AniDBEpisodeID); - if (match_override == null) - { - result.Add(match); - } - else - { - var new_match = new CrossRef_AniDB_TvDB_Episode - { - AniDBEpisodeID = match_override.AniDBEpisodeID, - TvDBEpisodeID = match_override.TvDBEpisodeID, - MatchRating = MatchRating.UserVerified - }; - result.Add(new_match); - } - } - - return result; - } - - public static List<(SVR_AniDB_Episode AniDB, TvDB_Episode TvDB, MatchRating Rating)> GetTvDBEpisodeMatches( - int animeID, int tvdbID) - { - /* These all apply to normal episodes mainly. - * It will fail for specials (BD will cause most to have the same air date). - * - * We will keep a running score to determine how accurate we believe the link is. Lower is better - * Ideal score is 1 to 1 match with perfect air dates - * - * if the episodes are 1-1: - * Try to match air date. - * if no match: - * match to the next episode after the previous match (starting at one) - * if two episodes air on the same day: - * match in order of episode, grouped by air date - * - * if the episodes are not 1-1: - * try to match air date. - * group episodes in order by air date - * - * if two episodes air on the same day on both sides: - * split them as equally as possible, these will likely need manually overriden - * if no match: - * if all episodes belong to the same season: - * split episodes equally and increment from previous match. these will almost definitely need overriden - * else: - * increment and hope for the best... - */ - - // Get All AniDB and TvDB episodes for a series, normal and specials done separately - // Due to fun season splitting on TvDB, - // we need extra logic to determine if a series is one or more seasons - - // Get TvDB first, if we can't get the episodes, then there's no valid link - if (tvdbID == 0) - { - return new List<(SVR_AniDB_Episode AniDB, TvDB_Episode TvDB, MatchRating Rating)>(); - } - - var tveps = RepoFactory.TvDB_Episode.GetBySeriesID(tvdbID); - var tvepsNormal = tveps.Where(a => a.SeasonNumber != 0).OrderBy(a => a.SeasonNumber) - .ThenBy(a => a.EpisodeNumber).ToList(); - var tvepsSpecial = - tveps.Where(a => a.SeasonNumber == 0).OrderBy(a => a.EpisodeNumber).ToList(); - - // Get AniDB - var anieps = RepoFactory.AniDB_Episode.GetByAnimeID(animeID); - var aniepsNormal = anieps.Where(a => a.EpisodeType == (int)EpisodeType.Episode) - .OrderBy(a => a.EpisodeNumber).ToList(); - - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - - var matches = - new List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)>(); - - // 5 is arbitrary. Can be adjusted later - var isOVASeries = anime?.AnimeType is (int)AnimeType.OVA or (int)AnimeType.Web or (int)AnimeType.TVSpecial && - aniepsNormal.Count > 5; - - // Try to match OVAs - if (!isOVASeries && - anime?.AnimeType is (int)AnimeType.OVA or (int)AnimeType.Movie or (int)AnimeType.TVSpecial && - aniepsNormal.Count > 0 && tvepsSpecial.Count > 0) - { - TryToMatchSpeicalsToTvDB(aniepsNormal, tvepsSpecial, ref matches); - } - - // Only try to match normal episodes if this is a series - if (anime?.AnimeType != (int)AnimeType.Movie && aniepsNormal.Count > 0 && tvepsNormal.Count > 0) - { - TryToMatchNormalEpisodesToTvDB(aniepsNormal, tvepsNormal, anime?.EndDate == null, ref matches); - } - - // Specials. We aren't going to try too hard here. - // We'll try by titles and dates, but we'll rely mostly on overrides - var aniepsSpecial = anieps.Where(a => a.EpisodeType == (int)EpisodeType.Special) - .OrderBy(a => a.EpisodeNumber).ToList(); - - if (aniepsSpecial.Count > 0 && tvepsSpecial.Count > 0) - { - TryToMatchSpeicalsToTvDB(aniepsSpecial, tvepsSpecial, ref matches); - } - - logger.Debug("Matching Anime: " + (anime?.PreferredTitle ?? "EMPTY") + " TvID: " + tvdbID + " Type: " + - (anime?.AnimeType.ToString() ?? "None")); - logger.Debug("Anime Ep Count: " + aniepsNormal.Count + " Specials: " + aniepsSpecial.Count); - logger.Debug("TvDB Ep Count: " + tvepsNormal.Count + " Specials: " + tvepsSpecial.Count); - logger.Debug("Match Count: " + matches.Count); - if (matches.Count == 0) - { - //Special Exception, sometimes tvdb matches series as anidb movies or viceversa - if ((anime?.AnimeType == (int)AnimeType.OVA || anime?.AnimeType == (int)AnimeType.Movie || - anime?.AnimeType == (int)AnimeType.TVSpecial) && aniepsSpecial.Count > 0) - { - TryToMatchNormalEpisodesToTvDB(aniepsNormal, tvepsNormal, anime?.EndDate == null, ref matches); - } - } - - if (matches.Count == 0) - { - //Special Exception (PATLABOR 1990) //Anime marked as an OVA in AniDb, and used as normal season in tvdb - if ((anime?.AnimeType == (int)AnimeType.OVA || anime?.AnimeType == (int)AnimeType.Movie || - anime?.AnimeType == (int)AnimeType.TVSpecial) && aniepsSpecial.Count > 0) - { - TryToMatchNormalEpisodesToTvDB(aniepsSpecial, tvepsNormal, anime?.EndDate == null, ref matches); - } - } - - return matches; - } - - #region internal processing - - private static void TryToMatchNormalEpisodesToTvDB(List<SVR_AniDB_Episode> aniepsNormal, - List<TvDB_Episode> tvepsNormal, bool isAiring, ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches) - { - // determine 1-1 - var one2one = aniepsNormal.Count == tvepsNormal.Count; - - var seasonLookup = - tvepsNormal.GroupBy(a => a.SeasonNumber).OrderBy(a => a.Key).ToList(); - - // Exclude shows with numbered titles from title matching. - // Those are always in order, so go by dates and fill in the rest - var hasNumberedTitles = HasNumberedTitles(aniepsNormal) || HasNumberedTitles(tvepsNormal); - - // we will declare this in outer scope to avoid calculating it more than once. - List<IGrouping<int, SVR_AniDB_Episode>> airdategroupings = null; - var firstgroupingcount = 0; - var isregular = false; - - if (!one2one) - { - // we'll need to split seasons and see if the series spans multiple or matches a specific season - var temp = new List<TvDB_Episode>(); - TryToMatchSeasonsByAirDates(aniepsNormal, seasonLookup, isAiring, ref temp); - - one2one = aniepsNormal.Count == temp.Count; - - if (!one2one) - { - // Saiki K => regular matching detection (5 to 1) - // We'll group by week, and we'll cheat by using ISO6801 calendar, - // as it ensures that the week is not split on the end of the year - airdategroupings = aniepsNormal.Where(a => a.GetAirDateAsDate() != null).GroupBy(a => - a.GetAirDateAsDate().Value.ToIso8601WeekNumber()) - .OrderBy(a => a.Key).ToList(); - var airdatecounts = airdategroupings - .Select(a => a.Count()).ToList(); - - if (airdatecounts.Count > 0) - { - // pre-screened episodes skew the data beyond an acceptable margin of error. Remove them from AVG - if (airdatecounts.Count > 1 && airdatecounts.Max() == airdatecounts[0]) - { - airdatecounts.RemoveAt(0); - } - - var average = airdatecounts.Average(); - - firstgroupingcount = airdatecounts.First(); - - var epsilon = (double)firstgroupingcount * firstgroupingcount / aniepsNormal.Count; - isregular = Math.Sqrt((firstgroupingcount - average) * (firstgroupingcount - average)) <= - epsilon; - var weekly1to1 = isregular && firstgroupingcount == 1; - if (isregular && firstgroupingcount != 1) - { - // one2one can only be false here, but we're saying it for clarity - one2one = false; - // skip the next step, since we are pretty confident in the season matching here - // since we are skipping ahead, set tvepsNormal - if (temp.Count > 0) - { - tvepsNormal = temp; - } - - goto matchepisodes; - } - - // no need for else, the goto skips ahead - // Airing series won't match in most cases - if (weekly1to1 && isAiring) - { - // TODO we may need to check the TvDB side for splitting episodes, but they don't do it often - one2one = true; - } - } - } - - // if temp is empty or the air dates matched everything, then try to recalculate, as there was likely a problem - if (!one2one && !hasNumberedTitles && (!temp.Any() || temp.Count == tvepsNormal.Count)) - { - TryToMatchSeasonsByEpisodeTitles(aniepsNormal, seasonLookup, ref temp); - - one2one = aniepsNormal.Count == tvepsNormal.Count; - } - - if (temp.Count > 0) - { - tvepsNormal = temp; - } - } - - matchepisodes: - - // It's one to one, possibly spanning multiple seasons - if (one2one) - { - if (!hasNumberedTitles) - { - // Sometimes, the dates are wrong and titles are exact - TryToMatchEpisodes1To1ByTitle(ref aniepsNormal, ref tvepsNormal, ref matches, false); - TryToMatchEpisodes1To1ByAirDate(ref aniepsNormal, ref tvepsNormal, ref matches); - TryToMatchEpisodes1To1ByTitle(ref aniepsNormal, ref tvepsNormal, ref matches, true); - CorrectMatchRatings(ref matches); - } - else - { - // We have numbered titles. There are exceptions to every rule, but numbered eps are assumed missing data, so it can't be "correct" - TryToMatchEpisodes1To1ByAirDate(ref aniepsNormal, ref tvepsNormal, ref matches); - } - - FillUnmatchedEpisodes1To1(ref aniepsNormal, ref tvepsNormal, ref matches); - } - else - { - // Not 1 to 1, this can be messy, and will probably need some overrides - - // if this is sucessful, then all episodes will be matched - if (TryToMatchRegularlyDistributedEpisodes(ref aniepsNormal, ref tvepsNormal, ref matches, isregular, - firstgroupingcount)) - { - return; - } - - // the rest won't be pretty - // Try to match exact titles. This may get really messy, but hopefully the exact matching will prevent issues - TryToMatchEpisodesManyTo1ByTitle(ref aniepsNormal, ref tvepsNormal, ref matches); - // try to match air dates. Don't remove eps from the list of possible matches - TryToMatchEpisodesManyTo1ByAirDate(ref aniepsNormal, ref tvepsNormal, ref matches); - - // Correct Matches - CorrectMatchRatings(ref matches); - - // Fill in the rest and pray to Molag Bal for vengence on thy enemies - FillUnmatchedEpisodes1To1(ref aniepsNormal, ref tvepsNormal, ref matches); - } - } - - private static void TryToMatchSpeicalsToTvDB(List<SVR_AniDB_Episode> aniepsSpecial, List<TvDB_Episode> tvepsSpecial, - ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches) - { - // Specials are almost never going to be one to one. We'll assume they are and let the user fix them - // Air Dates are less accurate for specials (BD/DVD release makes them all the same). Try Titles first - TryToMatchEpisodes1To1ByTitle(ref aniepsSpecial, ref tvepsSpecial, ref matches, true); - TryToMatchEpisodes1To1ByAirDate(ref aniepsSpecial, ref tvepsSpecial, ref matches); - FillUnmatchedEpisodes1To1(ref aniepsSpecial, ref tvepsSpecial, ref matches); - CorrectMatchRatings(ref matches); - } - - private static readonly char[] separators = " /.,<>?;':\"\\!@#$%^&*()-=_+|`~".ToCharArray(); - - public static bool IsTitleNumberedAndConsecutive(string title1, string title2) - { - // Return if it's Episode {number} ex Nodame Cantibile's "Lesson 1" or Air Gear's "Trick 1" - // This will fail if you use not English TvDB or the title is "First Episode" - - if (string.IsNullOrEmpty(title1) || string.IsNullOrEmpty(title2)) - { - return false; - } - - var parts1 = title1.Split(separators, StringSplitOptions.RemoveEmptyEntries); - if (parts1.Length == 0) - { - return false; - } - - var end1 = parts1[parts1.Length - 1]; - if (!double.TryParse(end1, out var endNumber1)) - { - return false; - } - - var parts2 = title2.Split(separators, StringSplitOptions.RemoveEmptyEntries); - if (parts2.Length == 0) - { - return false; - } - - var end2 = parts2[parts2.Length - 1]; - if (!double.TryParse(end2, out var endNumber2)) - { - return false; - } - - // There are cases with .5 episodes, so count it as consecutive if there is no more than 1 distance - // We only care about the range surrounding 1, so it's fine to leave it squared - var distSq = (endNumber2 - endNumber1) * (endNumber2 - endNumber1); - // Double precision fun - return distSq < 1.0001D; - } - - public static bool HasNumberedTitles(List<SVR_AniDB_Episode> eps) - { - return eps.Zip(eps.Skip(1), Tuple.Create).All(a => - IsTitleNumberedAndConsecutive(a.Item1.DefaultTitle, a.Item2.DefaultTitle)); - } - - public static bool HasNumberedTitles(List<TvDB_Episode> eps) - { - return eps.Zip(eps.Skip(1), Tuple.Create).All(a => - IsTitleNumberedAndConsecutive(a.Item1.EpisodeName, a.Item2.EpisodeName)); - } - - private static void TryToMatchSeasonsByAirDates(List<SVR_AniDB_Episode> aniepsNormal, - List<IGrouping<int, TvDB_Episode>> seasonLookup, bool isAiring, ref List<TvDB_Episode> temp) - { - /* - * My brain ceased complex thought, so a diagram to picture it or something - * This should cover any circumstance that we encounter - * - * anidb s1----------------------e1 s3-----------------------e3 - * s2--------------------e2 - * - * - * d.gray-man s1---------------------------------------------w1 s2-----------------------w2 - * - * - * ^ calc'd s1'-------------------e1' s3'---------------------e3' - * s2'--------------------e2' - * - * Aldoah.Zero - * tvdb long ss-----------------------------------------------------------------------se - * - * Most series - * tvdb short ss1-----------------se1 ss3---------------------sse3 - * ss2-----------------se2 - * - * - * tvdb mixed ss1-----------------------------------------se1 ss2---------------------se2 - */ - - // Compare the start and end of the series to each season - // This should be almost always accurate - // Pre-screenings of several months will break it - if (seasonLookup.Count == 0) - { - return; - } - - var start = aniepsNormal.Min(a => a.GetAirDateAsDate() ?? DateTime.MaxValue); - if (start == DateTime.MaxValue) - { - return; - } - - start = start.AddDays(-5); - - var endTvDB = seasonLookup.Max(b => - b.Where(c => c.AirDate != null).Select(c => c.AirDate.Value).OrderBy(a => a).LastOrDefault()); - if (endTvDB == default) - { - return; - } - - // luckily AniDB always has more Air Date info than TvDB - var end = aniepsNormal.Max(a => - a.GetAirDateAsDate() ?? endTvDB); - if (isAiring) - { - end = endTvDB; - } - - end = end.AddDays(5); - - // cache the relations, but don't always fetch them - List<SVR_AniDB_Anime> prequelAnimes = null; - List<SVR_AniDB_Anime> sequelAnimes = null; - - foreach (var season in seasonLookup) - { - var epsInSeason = season.OrderBy(a => a.EpisodeNumber).ToList(); - var seasonStart = epsInSeason.FirstOrDefault()?.AirDate; - if (seasonStart == null) - { - continue; - } - - var seasonEnd = epsInSeason.LastOrDefault(a => a.AirDate != null)?.AirDate; - - // no need to check seasonEnd, worst case, it's equal to seasonStart - - // It is extremely unlikely that a TvDB season begins before a series, while including it - if (seasonStart < start || seasonEnd > end) - { - // We save the original count for checking against. If it hasn't changed, then we escaped nulls or nothing matched - var originalEpCount = epsInSeason.Count; - - // tvdb season starts before, but ends after it starts - if (seasonStart < start && seasonEnd > start) - { - // This handles exceptions like Aldnoah.Zero, where TvDB lists one season, while AniDB splits them - // This usually happens when a show airs in Fall and continues into Winter - // This handles the second half of Aldnoah.Zero (has a prequel) - // Check relations for prequels, then filter if the air dates match - if (prequelAnimes == null) - { - // only check the relations if they have the same TvDB Series ID - var relations = RepoFactory.AniDB_Anime_Relation.GetByAnimeID(aniepsNormal[0].AnimeID) - .Where(a => a?.RelationType == "Prequel" && RepoFactory.CrossRef_AniDB_TvDB - .GetByAnimeID(a.RelatedAnimeID).Any(b => - season.Select(c => c.SeriesID).Contains(b.TvDBID))).ToList(); - - var allPrequels = new List<SVR_AniDB_Anime_Relation>(); - allPrequels.AddRange(relations); - var visitedNodes = new HashSet<int> { aniepsNormal[0].AnimeID }; - - GetAllRelationsByTypeRecursive(relations, ref visitedNodes, ref allPrequels, "Prequel"); - - prequelAnimes = allPrequels - .Select(a => RepoFactory.AniDB_Anime.GetByAnimeID(a.RelatedAnimeID)) - .Where(a => a != null).OrderBy(a => a.AnimeID).ToList(); - } - - // we check if the season matches any of the prequels - // since it's a prequel, we'll assume it's finished airing - foreach (var prequelAnime in prequelAnimes) - { - var prequelEps = prequelAnime.AniDBEpisodes - .Where(a => a.EpisodeType == (int)EpisodeType.Episode).OrderBy(a => a.EpisodeNumber) - .ToList(); - - // We'll use ISO6801 for season matching - var match = - prequelEps.Zip(epsInSeason, - (aniep, tvep) => - aniep.GetAirDateAsDate()?.ToIso8601WeekNumber() == - tvep.AirDate?.ToIso8601WeekNumber() && - aniep.GetAirDateAsDate()?.Year == tvep.AirDate?.Year).Count(a => a) >= - prequelEps.Count * 2D / 3D; - - if (!match) - { - continue; - } - - for (var i = 0; i < prequelEps.Count; i++) - { - if (epsInSeason.Count == 0) - { - break; - } - - epsInSeason.RemoveAt(0); - } - - if (epsInSeason.Count == 0) - { - break; - } - } - - if (epsInSeason.Count == 0) - { - continue; - } - } - - - // season ended after series ended, but started before it ended - if (seasonStart < end && seasonEnd > end) - { - // This handles the first half of Aldnoah.Zero - // Check relations for sequels, then filter if the air dates match - if (sequelAnimes == null) - { - // only check the relations if they have the same TvDB Series ID - var relations = RepoFactory.AniDB_Anime_Relation.GetByAnimeID(aniepsNormal[0].AnimeID) - .Where(a => a?.RelationType == "Sequel" && RepoFactory.CrossRef_AniDB_TvDB - .GetByAnimeID(a.RelatedAnimeID).Any(b => - season.Select(c => c.SeriesID).Contains(b.TvDBID))).ToList(); - - var allSequels = new List<SVR_AniDB_Anime_Relation>(); - allSequels.AddRange(relations); - var visitedNodes = new HashSet<int> { aniepsNormal[0].AnimeID }; - - GetAllRelationsByTypeRecursive(relations, ref visitedNodes, ref allSequels, "Sequel"); - - sequelAnimes = allSequels - .Select(a => RepoFactory.AniDB_Anime.GetByAnimeID(a.RelatedAnimeID)) - .Where(a => a != null).OrderByDescending(a => a.AnimeID).ToList(); - } - - // we check if the season matches any of the sequels - foreach (var sequelAnime in sequelAnimes) - { - var sequelEps = sequelAnime.AniDBEpisodes - .Where(a => a.EpisodeType == (int)EpisodeType.Episode).OrderBy(a => a.EpisodeNumber) - .ToList(); - - // We'll use ISO6801 for season matching - var epsInSeasonOffset = epsInSeason.Skip(temp.Count).ToList(); - var epsilon = Math.Min(epsInSeasonOffset.Count, sequelEps.Count) * 2D / 3D; - var match = - sequelEps.Zip(epsInSeasonOffset, - (aniep, tvep) => - aniep.GetAirDateAsDate()?.ToIso8601WeekNumber() == - tvep.AirDate?.ToIso8601WeekNumber() && - aniep.GetAirDateAsDate()?.Year == tvep.AirDate?.Year).Count(a => a) >= - epsilon; - if (!match) - { - continue; - } - - for (var i = 0; i < epsInSeasonOffset.Count; i++) - { - if (epsInSeason.Count == 0) - { - break; - } - - epsInSeason.RemoveAt(epsInSeason.Count - 1); - } - - if (epsInSeason.Count == 0) - { - break; - } - } - - if (epsInSeason.Count == 0) - { - continue; - } - } - - // Nothing has changed, so no matches - if (epsInSeason.Count == originalEpCount) - { - continue; - } - } - - temp.AddRange(epsInSeason); - } - } - - private static void GetAllRelationsByTypeRecursive(List<SVR_AniDB_Anime_Relation> allRelations, - ref HashSet<int> visitedNodes, ref List<SVR_AniDB_Anime_Relation> resultRelations, string type) - { - foreach (var relation in allRelations) - { - if (visitedNodes.Contains(relation.RelatedAnimeID)) - { - continue; - } - - var sequels = RepoFactory.AniDB_Anime_Relation.GetByAnimeID(relation.RelatedAnimeID) - .Where(a => a?.RelationType == type).ToList(); - if (sequels.Count == 0) - { - return; - } - - GetAllRelationsByTypeRecursive(sequels, ref visitedNodes, ref resultRelations, type); - visitedNodes.Add(relation.RelatedAnimeID); - resultRelations.AddRange(sequels); - } - } - - private static void TryToMatchSeasonsByEpisodeTitles(List<SVR_AniDB_Episode> aniepsNormal, - List<IGrouping<int, TvDB_Episode>> seasonLookup, ref List<TvDB_Episode> temp) - { - // Will try to compare the Titles for the first and last episodes of the series - // This is very inacurrate, but may fix the situations with pre-screenings - - // first ep - var aniepstart = aniepsNormal.FirstOrDefault(); - var anistart = aniepstart?.DefaultTitle; - if (string.IsNullOrEmpty(anistart)) - { - return; - } - - // last ep - var aniepend = aniepsNormal.FirstOrDefault(); - var aniend = aniepend?.DefaultTitle; - if (string.IsNullOrEmpty(aniend)) - { - return; - } - - foreach (var season in seasonLookup) - { - var epsInSeason = season.OrderBy(a => a.EpisodeNumber).ToList(); - - var tvstart = epsInSeason.FirstOrDefault()?.EpisodeName; - if (string.IsNullOrEmpty(tvstart)) - { - continue; - } - - // fuzzy match - if (anistart.FuzzyMatch(tvstart)) - { - temp.AddRange(epsInSeason); - continue; - } - - var tvend = epsInSeason.LastOrDefault()?.EpisodeName; - if (string.IsNullOrEmpty(tvend)) - { - continue; - } - - // fuzzy match - if (aniend.FuzzyMatch(tvend)) - { - temp.AddRange(epsInSeason); - } - } - } - - private static void TryToMatchEpisodes1To1ByAirDate(ref List<SVR_AniDB_Episode> aniepsNormal, - ref List<TvDB_Episode> tvepsNormal, - ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches) - { - foreach (var aniep in aniepsNormal.ToList()) - { - var aniair = aniep.GetAirDateAsDate(); - if (aniair == null) - { - continue; - } - - foreach (var tvep in tvepsNormal) - { - var tvair = tvep.AirDate; - if (tvair == null) - { - continue; - } - - // check if the dates are within reason - if (!aniair.Value.IsWithinErrorMargin(tvair.Value, TimeSpan.FromDays(1.5))) - { - continue; - } - - // Add them to the matches and remove them from the lists to process - matches.Add((aniep, tvep, MatchRating.Good)); - tvepsNormal.Remove(tvep); - aniepsNormal.Remove(aniep); - break; - } - } - } - - private static void TryToMatchEpisodes1To1ByTitle(ref List<SVR_AniDB_Episode> aniepsNormal, - ref List<TvDB_Episode> tvepsNormal, - ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches, bool fuzzy) - { - foreach (var aniep in aniepsNormal.ToList()) - { - var anititle = aniep.DefaultTitle; - if (string.IsNullOrEmpty(anititle)) - { - continue; - } - - foreach (var tvep in tvepsNormal) - { - var tvtitle = tvep.EpisodeName; - if (string.IsNullOrEmpty(tvtitle)) - { - continue; - } - - // fuzzy match - if (fuzzy) - { - if (!anititle.FuzzyMatch(tvtitle)) - { - continue; - } - } - else - { - if (!anititle.Equals(tvtitle, StringComparison.InvariantCultureIgnoreCase)) - { - continue; - } - } - - // Add them to the matches and remove them from the lists to process - matches.Add((aniep, tvep, fuzzy ? MatchRating.Bad : MatchRating.Mkay)); - tvepsNormal.Remove(tvep); - aniepsNormal.Remove(aniep); - break; - } - } - } - - private static void CorrectMatchRatings(ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches) - { - for (var index = 0; index < matches.Count; index++) - { - var match = matches[index]; - if (match.Item1 == null || match.Item2 == null) - { - matches[index] = (match.Item1, match.Item2, MatchRating.SarahJessicaParker); - continue; - } - - var aniair = match.Item1.GetAirDateAsDate(); - var tvair = match.Item2.AirDate; - var datesMatch = aniair != null && tvair != null; - - if (datesMatch) - { - datesMatch = aniair.Value.IsWithinErrorMargin(tvair.Value, TimeSpan.FromDays(1.5)); - } - - if (!datesMatch) - { - continue; - } - - // if the dates match, then they would have filled with Good, so the fuzzy search is only being done once - - var aniTitle = match.Item1.DefaultTitle; - var tvTitle = match.Item2.EpisodeName; - // this method returns false if either is null - var titlesMatch = aniTitle.FuzzyMatch(tvTitle); - - matches[index] = titlesMatch - ? (match.Item1, match.Item2, MatchRating.Good) - : (match.Item1, match.Item2, MatchRating.Mkay); - } - } - - private static void FillUnmatchedEpisodes1To1(ref List<SVR_AniDB_Episode> aniepsNormal, - ref List<TvDB_Episode> tvepsNormal, - ref List<(SVR_AniDB_Episode AniDB, TvDB_Episode TvDB, MatchRating Match)> matches) - { - if (aniepsNormal.Count == 0) - { - return; - } - // Find the missing episodes, and if there is a remaining episode to fill it with, then do it - - // special handling for if the first episodes are missing. - // This will happen often since many shows are pre-screened - // Find the first linked episode, and work backwards - - // Aggregate throws on an empty list.... Why doesn't it just return default like everything else... - if (matches.Count > 0) - { - if (aniepsNormal.Min(a => a.EpisodeNumber == 1)) - { - var minaniep = matches.Aggregate((a, b) => a.AniDB.EpisodeNumber < b.AniDB.EpisodeNumber ? a : b); - var mintvep = minaniep.TvDB; - foreach (var aniep in aniepsNormal.Where(a => a.EpisodeNumber < minaniep.AniDB.EpisodeNumber) - .OrderByDescending(a => a.EpisodeNumber).ToList()) - { - (var season, var epnumber) = mintvep.GetPreviousEpisode(); - var tvep = tvepsNormal.FirstOrDefault(a => - a.SeasonNumber == season && a.EpisodeNumber == epnumber); - // Give up if it's not found - if (tvep == null) - { - break; - } - - matches.Add((aniep, tvep, MatchRating.Bad)); - aniepsNormal.Remove(aniep); - tvepsNormal.Remove(tvep); - } - } - - foreach (var aniDbEpisode in aniepsNormal.OrderBy(a => a.EpisodeNumber).ToList()) - { - var aniEpNumber = aniDbEpisode.EpisodeNumber; - - // Find the episode that was the last linked episode before this number - var previouseps = matches.Where(a => a.AniDB.EpisodeNumber < aniEpNumber).ToList(); - if (previouseps.Count == 0) - { - break; - } - - var previousep = - previouseps.Aggregate((a, b) => a.AniDB.EpisodeNumber > b.AniDB.EpisodeNumber ? a : b); - // Now we need to figure out what the next episode is - (var nextSeason, var nextEpisode) = previousep.TvDB.GetNextEpisode(); - if (nextSeason == 0 || nextEpisode == 0) - { - continue; - } - - var nextEp = - tvepsNormal.FirstOrDefault(a => a.SeasonNumber == nextSeason && a.EpisodeNumber == nextEpisode); - if (nextEp == null) - { - continue; - } - - // add the mapping and remove it from the possible listings - matches.Add((aniDbEpisode, nextEp, MatchRating.Ugly)); - aniepsNormal.Remove(aniDbEpisode); - tvepsNormal.Remove(nextEp); - } - } - - // just map whatever is left to something.... It's almost certainly wrong - foreach (var aniep in aniepsNormal.ToList()) - { - var tvep = tvepsNormal.FirstOrDefault(); - if (tvep == null) - { - break; - } - - matches.Add((aniep, tvep, MatchRating.SarahJessicaParker)); - aniepsNormal.Remove(aniep); - tvepsNormal.Remove(tvep); - } - } - - private static bool TryToMatchRegularlyDistributedEpisodes(ref List<SVR_AniDB_Episode> aniepsNormal, - ref List<TvDB_Episode> tvepsNormal, ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches, - bool isregular, int firstgroupingcount) - { - // first use the checks from earlier to see if it's regularly distributed - if (!isregular) - { - return false; - } - - // since it's regular, then counts will all be equal give or take an episode in one - // we'll treat it as {firstgroupingcount} to one - // In this case, Saiki K was 5 to 1 - var tvDBEpisodeRatio = aniepsNormal.Count / firstgroupingcount; - - // last check to ensure that it is firstgroupingcount to 1 - if (tvepsNormal.Count != tvDBEpisodeRatio) - { - return false; - } - - var count = 0; - TvDB_Episode ep = null; - foreach (var aniep in aniepsNormal.ToList()) - { - if (count % firstgroupingcount == 0) - { - ep = tvepsNormal.FirstOrDefault(); - tvepsNormal.Remove(ep); - } - - if (ep == null) - { - break; - } - - // It goes against the initial rules for Good rating, but this is a very specific case - matches.Add((aniep, ep, MatchRating.Mkay)); - aniepsNormal.Remove(aniep); - count++; - } - - return true; - } - - private static void TryToMatchEpisodesManyTo1ByTitle(ref List<SVR_AniDB_Episode> aniepsNormal, - ref List<TvDB_Episode> tvepsNormal, - ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches) - { - foreach (var aniep in aniepsNormal.ToList()) - { - var anititle = aniep.DefaultTitle; - if (string.IsNullOrEmpty(anititle)) - { - continue; - } - - foreach (var tvep in tvepsNormal) - { - var tvtitle = tvep.EpisodeName; - if (string.IsNullOrEmpty(tvtitle)) - { - continue; - } - - if (!anititle.RemoveDiacritics().FilterLetters().Equals(tvtitle.RemoveDiacritics().FilterLetters(), - StringComparison.InvariantCultureIgnoreCase)) - { - continue; - } - - // Add them to the matches and remove them from the lists to process - matches.Add((aniep, tvep, MatchRating.Mkay)); - aniepsNormal.Remove(aniep); - break; - } - } - } - - private static void TryToMatchEpisodesManyTo1ByAirDate(ref List<SVR_AniDB_Episode> aniepsNormal, - ref List<TvDB_Episode> tvepsNormal, ref List<(SVR_AniDB_Episode, TvDB_Episode, MatchRating)> matches) - { - foreach (var aniep in aniepsNormal.ToList()) - { - var aniair = aniep.GetAirDateAsDate(); - if (aniair == null) - { - continue; - } - - foreach (var tvep in tvepsNormal) - { - var tvair = tvep.AirDate; - if (tvair == null) - { - continue; - } - - // check if the dates are within reason - if (!aniair.Value.IsWithinErrorMargin(tvair.Value, TimeSpan.FromDays(1.5))) - { - continue; - } - - // Add them to the matches and remove them from the lists to process - matches.Add((aniep, tvep, MatchRating.Mkay)); - aniepsNormal.Remove(aniep); - break; - } - } - } - - public static List<CrossRef_AniDB_TvDB_Episode_Override> GetSpecialsOverridesFromLegacy( - List<Azure_CrossRef_AniDB_TvDB> links) - { - var list = links.Select(a => (a.AnimeID, a.AniDBStartEpisodeType, a.AniDBStartEpisodeNumber, a.TvDBID, - a.TvDBSeasonNumber, a.TvDBStartEpisodeNumber)).ToList(); - return GetSpecialsOverridesFromLegacy(list); - } - - public static List<CrossRef_AniDB_TvDB_Episode_Override> GetSpecialsOverridesFromLegacy( - List<CrossRef_AniDB_TvDBV2> links) - { - var list = links.Select(a => (a.AnimeID, a.AniDBStartEpisodeType, a.AniDBStartEpisodeNumber, a.TvDBID, - a.TvDBSeasonNumber, a.TvDBStartEpisodeNumber)).ToList(); - return GetSpecialsOverridesFromLegacy(list); - } - - private static List<CrossRef_AniDB_TvDB_Episode_Override> GetSpecialsOverridesFromLegacy( - List<(int AnimeID, int AniDBStartType, int AniDBStartNumber, int TvDBID, int TvDBSeason, int TvDBStartNumber - )> links) - { - if (links.Count == 0) - { - return new List<CrossRef_AniDB_TvDB_Episode_Override>(); - } - - // First, sort by AniDB type and number - // Descending start type will list specials first, just because TvDB S0 == Specials - var xrefs = links.OrderByDescending(a => a.AniDBStartType).ThenBy(a => a.AniDBStartNumber).ToList(); - // No support for more than one series link in Legacy - var AnimeID = xrefs.FirstOrDefault().AnimeID; - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); - if (anime == null) - { - return new List<CrossRef_AniDB_TvDB_Episode_Override>(); - } - - // Check if we have default links - if (links.Count == 1) - { - var onlyLink = links.FirstOrDefault(); - if (onlyLink.AniDBStartNumber == 1 && - onlyLink.AniDBStartType == (int)EpisodeType.Special && - onlyLink.TvDBSeason == 0 && onlyLink.TvDBStartNumber == 1) - { - return new List<CrossRef_AniDB_TvDB_Episode_Override>(); - } - - if (onlyLink.AniDBStartNumber == 1 && - onlyLink.AniDBStartType == (int)EpisodeType.Episode && - onlyLink.TvDBSeason == 1 && onlyLink.TvDBStartNumber == 1) - { - return new List<CrossRef_AniDB_TvDB_Episode_Override>(); - } - } - - // we can do everything in one loop, since we've already matched - var episodes = RepoFactory.AniDB_Episode.GetByAnimeID(AnimeID) - .Where(a => a.EpisodeType == (int)EpisodeType.Special || a.EpisodeType == (int)EpisodeType.Episode) - .OrderBy(a => a.EpisodeNumber).ToList(); - - RemoveDefaultLinks(episodes, ref xrefs); - - var output = new List<CrossRef_AniDB_TvDB_Episode_Override>(); - - foreach (var episode in episodes) - { - var xref = GetXRefForEpisode(episode.EpisodeType, episode.EpisodeNumber, xrefs); - // we are dealing with tuples, so we can only return default, which will set everything to 0 - if (xref.AniDBStartType == 0) - { - continue; // 0 is invalid - } - - // Get TvDB ep - var tvep = RepoFactory.TvDB_Episode.GetBySeriesIDSeasonNumberAndEpisode(xref.TvDBID, xref.TvDBSeason, - xref.TvDBStartNumber); - if (tvep == null) - { - continue; - } - - // due to AniDB not matching up (season BS), we take the delta, and then iterate next TvDB episode - var delta = episode.EpisodeNumber - xref.AniDBStartNumber; - - if (delta > 0) - { - for (var j = 0; j < delta; j++) - { - // continue outer loop - if (tvep == null) - { - goto label0; - } - - var nextep = tvep.GetNextEpisode(); - if (nextep.episodeNumber == 0) - { - goto label0; - } - - tvep = RepoFactory.TvDB_Episode.GetBySeriesIDSeasonNumberAndEpisode(xref.TvDBID, nextep.season, - nextep.episodeNumber); - } - } - - if (tvep == null) - { - continue; - } - - // this is a separate variable just to make debugging easier - var newxref = new CrossRef_AniDB_TvDB_Episode_Override - { - AniDBEpisodeID = episode.EpisodeID, TvDBEpisodeID = tvep.Id - }; - output.Add(newxref); - - label0: ; - } - - return output; - } - - private static void RemoveDefaultLinks(List<SVR_AniDB_Episode> episodes, - ref List<(int AnimeID, int AniDBStartType, int AniDBStartNumber, int TvDBID, int TvDBSeason, int TvDBStartNumber - )> xrefs) - { - // generate default links - // check to see if they match - // if so, remove them - var new_xrefs = - new List<(int AnimeID, int AniDBStartType, int AniDBStartNumber, int TvDBID, int TvDBSeason, int - TvDBStartNumber)>(); - var season = -1; - foreach (var episode in episodes) - { - var xref = GetXRefForEpisode(episode.EpisodeType, episode.EpisodeNumber, xrefs); - // we are dealing with tuples, so we can only return default, which will set everything to 0 - if (xref.AniDBStartType == 0) - { - continue; // 0 is invalid - } - - // Get TvDB ep - var tvep = RepoFactory.TvDB_Episode.GetBySeriesIDSeasonNumberAndEpisode(xref.TvDBID, xref.TvDBSeason, - xref.TvDBStartNumber); - if (tvep == null) - { - continue; - } - - // due to AniDB not matching up (season BS), we take the delta, and then iterate next TvDB episode - var delta = episode.EpisodeNumber - xref.AniDBStartNumber; - - if (delta > 0) - { - for (var j = 0; j < delta; j++) - { - // continue outer loop - if (tvep == null) - { - goto label0; - } - - var nextep = tvep.GetNextEpisode(); - if (nextep.episodeNumber == 0) - { - goto label0; - } - - tvep = RepoFactory.TvDB_Episode.GetBySeriesIDSeasonNumberAndEpisode(xref.TvDBID, nextep.season, - nextep.episodeNumber); - } - } - - if (tvep == null) - { - continue; - } - - if (tvep.SeasonNumber != season) - { - if (tvep.SeasonNumber == 0) - { - goto label1; - } - - new_xrefs.Add((episode.AnimeID, episode.EpisodeType, episode.EpisodeNumber, xref.TvDBID, - tvep.SeasonNumber, tvep.EpisodeNumber)); - season = tvep.SeasonNumber; - } - - label0: ; - } - - label1: - if (!new_xrefs.SequenceEqual(xrefs)) - { - return; - } - - xrefs.Clear(); - } - - private static (int AnimeID, int AniDBStartType, int AniDBStartNumber, int TvDBID, int TvDBSeason, int - TvDBStartNumber) GetXRefForEpisode(int type, int number, - List<(int AnimeID, int AniDBStartType, int AniDBStartNumber, int TvDBID, int TvDBSeason, int - TvDBStartNumber)> xrefs) - { - // only use the AniDBStartType that is relevant - xrefs = xrefs.Where(a => a.AniDBStartType == type).ToList(); - if (xrefs.Count == 0) - { - return default; - } - - // assume that it defaults to starting at S1E1 when not stated - var first = xrefs[0]; - if (first.AniDBStartNumber > number) - { - var tvdbSeason = type == (int)EpisodeType.Episode ? 1 : 0; - return (first.AnimeID, type, 1, first.TvDBID, tvdbSeason, 1); - } - - // loop the rest - for (var i = 0; i < xrefs.Count; i++) - { - var xref = xrefs[i]; - // if it's last, then return - if (i + 1 == xrefs.Count) - { - return xref; - } - - // get the next one to check if it matches better - var next = xrefs[i + 1]; - if (next.AniDBStartNumber <= number) - { - continue; - } - - if (xref.AniDBStartNumber <= number) - { - return xref; - } - } - - return default; - } - - private static int ToIso8601WeekNumber(this DateTime date) - { - var thursday = date.AddDays(3 - date.DayOfWeek.DayOffset()); - return (thursday.DayOfYear - 1) / 7 + 1; - } - - private static int DayOffset(this DayOfWeek weekDay) - { - return ((int)weekDay + 6) % 7; - } - - #endregion -} diff --git a/Shoko.Server/Providers/TvDB/TvDBRateLimiter.cs b/Shoko.Server/Providers/TvDB/TvDBRateLimiter.cs deleted file mode 100644 index b3fddb986..000000000 --- a/Shoko.Server/Providers/TvDB/TvDBRateLimiter.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Diagnostics; -using System.Threading; -using NLog; - -namespace Shoko.Server.Providers.TvDB; - -public sealed class TvDBRateLimiter -{ - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - private static readonly TvDBRateLimiter instance = new(); - - private static int ShortDelay = 100; - private static int LongDelay = 500; - - // Switch to longer delay after 1 hour - private static long shortPeriod = 60 * 60 * 1000; - - // Switch to shorter delay after 30 minutes of inactivity - private static long resetPeriod = 30 * 60 * 1000; - - private static Stopwatch _requestWatch = new(); - - private static Stopwatch _activeTimeWatch = new(); - - public Guid InstanceID { get; private set; } - - // Explicit static constructor to tell C# compiler - // not to mark type as beforefieldinit - static TvDBRateLimiter() - { - _requestWatch.Start(); - _activeTimeWatch.Start(); - } - - public static TvDBRateLimiter Instance => instance; - - private TvDBRateLimiter() - { - InstanceID = Guid.NewGuid(); - } - - public void ResetRate() - { - var elapsedTime = _activeTimeWatch.ElapsedMilliseconds; - _activeTimeWatch.Restart(); - logger.Trace($"TvDBRateLimiter#{InstanceID}: Rate is reset. Active time was {elapsedTime} ms."); - } - - public void EnsureRate() - { - lock (instance) - { - var delay = _requestWatch.ElapsedMilliseconds; - - if (delay > resetPeriod) - { - ResetRate(); - } - - var currentDelay = _activeTimeWatch.ElapsedMilliseconds > shortPeriod ? LongDelay : ShortDelay; - - if (delay > currentDelay) - { - logger.Trace($"TvDBRateLimiter#{InstanceID}: Time since last request is {delay} ms, not throttling."); - _requestWatch.Restart(); - return; - } - - logger.Trace( - $"TvDBRateLimiter#{InstanceID}: Time since last request is {delay} ms, throttling for {currentDelay} ms."); - Thread.Sleep(currentDelay); - - logger.Trace($"TvDBRateLimiter#{InstanceID}: Sending TvDB command."); - _requestWatch.Restart(); - } - } -} diff --git a/Shoko.Server/Providers/TvDB/TvDBSummary.cs b/Shoko.Server/Providers/TvDB/TvDBSummary.cs deleted file mode 100644 index 87e448569..000000000 --- a/Shoko.Server/Providers/TvDB/TvDBSummary.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System; -using System.Collections.Generic; -using NLog; -using Shoko.Models.Server; -using Shoko.Server.Repositories; - -namespace Shoko.Models.TvDB; - -public class TvDBSummary -{ - private static Logger logger = LogManager.GetCurrentClassLogger(); - - public int AnimeID { get; set; } - - // TvDB ID - public Dictionary<int, TvDBDetails> TvDetails = new(); - - // All the TvDB cross refs for this anime - private List<CrossRef_AniDB_TvDB> crossRefTvDB; - - public List<CrossRef_AniDB_TvDB> CrossRefTvDB - { - get - { - if (crossRefTvDB == null) - { - PopulateCrossRefs(); - } - - return crossRefTvDB; - } - } - - private void PopulateCrossRefs() - { - try - { - crossRefTvDB = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(AnimeID); - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - // All the episode overrides for this anime - private List<CrossRef_AniDB_TvDB_Episode_Override> crossRefTvDBEpisodes; - - public List<CrossRef_AniDB_TvDB_Episode_Override> CrossRefTvDBEpisodes - { - get - { - if (crossRefTvDBEpisodes == null) - { - PopulateCrossRefsEpisodes(); - } - - return crossRefTvDBEpisodes; - } - } - - private Dictionary<int, int> dictTvDBCrossRefEpisodes; - - public Dictionary<int, int> DictTvDBCrossRefEpisodes - { - get - { - if (dictTvDBCrossRefEpisodes == null) - { - dictTvDBCrossRefEpisodes = new Dictionary<int, int>(); - foreach (var xrefEp in CrossRefTvDBEpisodes) - { - dictTvDBCrossRefEpisodes[xrefEp.AniDBEpisodeID] = xrefEp.TvDBEpisodeID; - } - } - - return dictTvDBCrossRefEpisodes; - } - } - - // All the episodes regardless of which cross ref they come from - private Dictionary<int, TvDB_Episode> dictTvDBEpisodes; - - public Dictionary<int, TvDB_Episode> DictTvDBEpisodes - { - get - { - if (dictTvDBEpisodes == null) - { - PopulateDictTvDBEpisodes(); - } - - return dictTvDBEpisodes; - } - } - - private void PopulateDictTvDBEpisodes() - { - try - { - dictTvDBEpisodes = new Dictionary<int, TvDB_Episode>(); - foreach (var det in TvDetails.Values) - { - if (det != null) - { - // create a dictionary of absolute episode numbers for tvdb episodes - // sort by season and episode number - // ignore season 0, which is used for specials - var eps = det.TvDBEpisodes; - - var i = 1; - foreach (var ep in eps) - { - dictTvDBEpisodes[i] = ep; - i++; - } - } - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - private void PopulateCrossRefsEpisodes() - { - try - { - crossRefTvDBEpisodes = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAnimeID(AnimeID); - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - public void Populate(int animeID) - { - AnimeID = animeID; - - try - { - PopulateCrossRefs(); - PopulateCrossRefsEpisodes(); - PopulateTvDBDetails(); - PopulateDictTvDBEpisodes(); - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - private void PopulateTvDBDetails() - { - if (CrossRefTvDB == null) - { - return; - } - - foreach (var xref in CrossRefTvDB) - { - var det = new TvDBDetails(xref.TvDBID); - TvDetails[xref.TvDBID] = det; - } - } -} diff --git a/Shoko.Server/Renamer/AutoMoveRequest.cs b/Shoko.Server/Renamer/AutoMoveRequest.cs deleted file mode 100644 index 8e04aeff6..000000000 --- a/Shoko.Server/Renamer/AutoMoveRequest.cs +++ /dev/null @@ -1,21 +0,0 @@ - -#nullable enable -namespace Shoko.Server.Renamer; - -/// <summary> -/// Represents a request to automatically move a file. -/// </summary> -public record AutoMoveRequest : AutoRenameRequest -{ - - /// <summary> - /// Indicates whether empty directories should be deleted after - /// relocating the file. - /// </summary> - public bool DeleteEmptyDirectories { get; set; } = true; - - /// <summary> - /// Do the move operation. - /// </summary> - public bool Move { get; set; } = true; -} diff --git a/Shoko.Server/Renamer/AutoRelocateRequest.cs b/Shoko.Server/Renamer/AutoRelocateRequest.cs index 9bcb68ae8..c5f9ba8f3 100644 --- a/Shoko.Server/Renamer/AutoRelocateRequest.cs +++ b/Shoko.Server/Renamer/AutoRelocateRequest.cs @@ -1,8 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using Shoko.Server.Models; #nullable enable namespace Shoko.Server.Renamer; /// <summary> -/// Represents a request to automatically relocate (move and rename) a file. +/// Represents a request to automatically rename a file. /// </summary> -public record AutoRelocateRequest : AutoMoveRequest { } +public record AutoRelocateRequest +{ + /// <summary> + /// Indicates whether the result should be a preview of the + /// relocation. + /// </summary> + [MemberNotNullWhen(false, nameof(Renamer))] + public bool Preview { get; set; } + + /// <summary> + /// The name of the renamer to use. If not provided, the default will be used. + /// If <see cref="Preview"/> is set to true, this will be ignored. + /// </summary> + public RenamerConfig? Renamer { get; set; } = null; + + /// <summary> + /// Do the rename operation. + /// </summary> + public bool Rename { get; set; } = true; + + /// <summary> + /// Indicates whether empty directories should be deleted after + /// relocating the file. + /// </summary> + public bool DeleteEmptyDirectories { get; set; } = true; + + /// <summary> + /// Indicates that we can relocate a video file that lives inside a + /// drop destination import folder that's not also a drop source. + /// </summary> + public bool AllowRelocationInsideDestination { get; set; } = true; + + /// <summary> + /// Do the move operation. + /// </summary> + public bool Move { get; set; } = true; +} diff --git a/Shoko.Server/Renamer/AutoRenameRequest.cs b/Shoko.Server/Renamer/AutoRenameRequest.cs deleted file mode 100644 index f50801356..000000000 --- a/Shoko.Server/Renamer/AutoRenameRequest.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -#nullable enable -namespace Shoko.Server.Renamer; - -/// <summary> -/// Represents a request to automatically rename a file. -/// </summary> -public record AutoRenameRequest -{ - /// <summary> - /// Indicates whether the result should be a preview of the - /// relocation. - /// </summary> - public bool Preview { get; set; } = false; - - /// <summary> - /// Indicates that the request contains a body. It should not be allowed to - /// run if <see cref="Preview"/> is not set to true. - /// </summary> - [MemberNotNullWhen(true, nameof(RenamerName))] - [MemberNotNullWhen(false, nameof(ScriptID))] - public bool ContainsBody => !string.IsNullOrEmpty(RenamerName) && !ScriptID.HasValue; - - /// <summary> - /// The id of the renaming script to use. Omit to use the - /// default script or the provided <see cref="RenamerName"/> and/or - /// <see cref="ScriptBody"/>. - /// </summary> - public int? ScriptID { get; set; } = null; - - /// <summary> - /// The name of the renamer to use for previewing changes. Trying to use - /// this without <see cref="Preview"/> set to true will result in an error. - /// </summary> - public string? RenamerName { get; set; } = null; - - /// <summary> - /// The script body to use with the renamer for previewing changes. Will be - /// ignored if <see cref="RenamerName"/> is not set. - /// </summary> - public string? ScriptBody { get; set; } = null; - - /// <summary> - /// Do the rename operation. - /// </summary> - public bool Rename { get; set; } = true; -} diff --git a/Shoko.Server/Renamer/DirectRelocationRequest.cs b/Shoko.Server/Renamer/DirectRelocationRequest.cs index 170ebfb67..29ca21f93 100644 --- a/Shoko.Server/Renamer/DirectRelocationRequest.cs +++ b/Shoko.Server/Renamer/DirectRelocationRequest.cs @@ -1,4 +1,4 @@ -using Shoko.Server.Models; +using Shoko.Plugin.Abstractions.DataModels; #nullable enable namespace Shoko.Server.Renamer; @@ -11,7 +11,7 @@ public record DirectRelocateRequest /// <summary> /// The import folder where the file should be relocated to. /// </summary> - public SVR_ImportFolder? ImportFolder = null; + public IImportFolder? ImportFolder = null; /// <summary> /// The relative path from the <see cref="ImportFolder"/> where the file @@ -24,4 +24,10 @@ public record DirectRelocateRequest /// relocating the file. /// </summary> public bool DeleteEmptyDirectories = true; + + /// <summary> + /// Indicates that we can relocate a video file that lives inside a + /// drop destination import folder that's not also a drop source. + /// </summary> + public bool AllowRelocationInsideDestination { get; set; } = true; } diff --git a/Shoko.Server/Renamer/GroupAwareSortingRenamer.cs b/Shoko.Server/Renamer/GroupAwareSortingRenamer.cs deleted file mode 100644 index a6dff89c4..000000000 --- a/Shoko.Server/Renamer/GroupAwareSortingRenamer.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Microsoft.Extensions.DependencyInjection; -using Shoko.Plugin.Abstractions; -using Shoko.Plugin.Abstractions.Attributes; -using Shoko.Plugin.Abstractions.DataModels; -using Shoko.Server.Server; -using Shoko.Server.Utilities; - -namespace Shoko.Server.Renamer; - -[Renamer(RENAMER_ID, Description = "Group Aware Sorter")] -public class GroupAwareRenamer : IRenamer -{ - internal const string RENAMER_ID = nameof(GroupAwareRenamer); - - // Defer to whatever else - public string GetFilename(RenameEventArgs args) - { - // Terrible hack to make it forcefully return Legacy Renamer - var legacy = (IRenamer)ActivatorUtilities.CreateInstance(Utils.ServiceContainer, typeof(LegacyRenamer)); - return legacy.GetFilename(args); - } - - public (IImportFolder destination, string subfolder) GetDestination(MoveEventArgs args) - { - if (args?.EpisodeInfo == null) - { - throw new ArgumentException("File is unrecognized. Not Moving"); - } - - // get the series - var series = args.AnimeInfo?.FirstOrDefault(); - - if (series == null) - { - throw new ArgumentException("Series cannot be found for file"); - } - - // replace the invalid characters - var name = series.PreferredTitle.ReplaceInvalidPathCharacters(); - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentException("Series Name is null or empty"); - } - - var group = args.GroupInfo?.FirstOrDefault(); - if (group == null) - { - throw new ArgumentException("Group could not be found for file"); - } - - string path; - if (group.Series.Count == 1) - { - path = name; - } - else - { - var groupName = Utils.ReplaceInvalidFolderNameCharacters(group.Name); - path = Path.Combine(groupName, name); - } - - var destFolder = series.Restricted switch - { - true => args.AvailableFolders.FirstOrDefault(a => - a.Path.Contains("Hentai", StringComparison.InvariantCultureIgnoreCase) && - ValidDestinationFolder(a)) ?? args.AvailableFolders.FirstOrDefault(ValidDestinationFolder), - false => args.AvailableFolders.FirstOrDefault(a => - !a.Path.Contains("Hentai", StringComparison.InvariantCultureIgnoreCase) && - ValidDestinationFolder(a)) - }; - - return (destFolder, path); - } - - private static bool ValidDestinationFolder(IImportFolder dest) - { - return dest.DropFolderType.HasFlag(DropFolderType.Destination); - } -} diff --git a/Shoko.Server/Renamer/RelocaitonResult.cs b/Shoko.Server/Renamer/RelocationResult.cs similarity index 93% rename from Shoko.Server/Renamer/RelocaitonResult.cs rename to Shoko.Server/Renamer/RelocationResult.cs index df2decd83..ff320d5de 100644 --- a/Shoko.Server/Renamer/RelocaitonResult.cs +++ b/Shoko.Server/Renamer/RelocationResult.cs @@ -1,7 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; -using Shoko.Server.Models; +using Shoko.Plugin.Abstractions.DataModels; #nullable enable namespace Shoko.Server.Renamer; @@ -59,7 +59,7 @@ public record RelocationResult /// The destination import folder if the relocation result were /// successful. /// </summary> - public SVR_ImportFolder? ImportFolder { get; set; } = null; + public IImportFolder? ImportFolder { get; set; } = null; /// <summary> /// The relative path from the <see cref="ImportFolder"/> to where @@ -73,5 +73,5 @@ public record RelocationResult /// </summary> /// <returns>The combined path.</returns> internal string? AbsolutePath - => Success && !string.IsNullOrEmpty(RelativePath) ? Path.Combine(ImportFolder.ImportFolderLocation, RelativePath) : null; + => Success && !string.IsNullOrEmpty(RelativePath) ? Path.Combine(ImportFolder?.Path ?? string.Empty, RelativePath) : null; } diff --git a/Shoko.Server/Renamer/RelocationService.cs b/Shoko.Server/Renamer/RelocationService.cs new file mode 100644 index 000000000..c4cd98bbf --- /dev/null +++ b/Shoko.Server/Renamer/RelocationService.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Events; +using Shoko.Plugin.Abstractions.Services; +using Shoko.Server.Settings; + +#nullable enable +namespace Shoko.Server.Renamer; + +public class RelocationService : IRelocationService +{ + private readonly ISettingsProvider _settingsProvider; + private readonly ILogger<RelocationService> _logger; + + public RelocationService(ISettingsProvider settingsProvider, ILogger<RelocationService> logger) + { + _settingsProvider = settingsProvider; + _logger = logger; + } + + public IImportFolder? GetFirstDestinationWithSpace(RelocationEventArgs args) + { + if (_settingsProvider.GetSettings().Import.SkipDiskSpaceChecks) + return args.AvailableFolders.FirstOrDefault(fldr => fldr.DropFolderType.HasFlag(DropFolderType.Destination)); + + return args.AvailableFolders.Where(fldr => fldr.DropFolderType.HasFlag(DropFolderType.Destination) && Directory.Exists(fldr.Path)) + .FirstOrDefault(fldr => ImportFolderHasSpace(fldr, args.File)); + } + + public bool ImportFolderHasSpace(IImportFolder folder, IVideoFile file) + { + return folder.ID == file.ImportFolderID || folder.AvailableFreeSpace >= file.Size; + } +} diff --git a/Shoko.Server/Renamer/RenameFileHelper.cs b/Shoko.Server/Renamer/RenameFileHelper.cs deleted file mode 100644 index ecd243c02..000000000 --- a/Shoko.Server/Renamer/RenameFileHelper.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using Microsoft.Extensions.DependencyInjection; -using NLog; -using Shoko.Commons.Extensions; -using Shoko.Plugin.Abstractions; -using Shoko.Plugin.Abstractions.DataModels; -using Shoko.Server.Models; -using Shoko.Server.Repositories; -using Shoko.Plugin.Abstractions.Attributes; -using Shoko.Server.Utilities; - -#nullable enable -namespace Shoko.Server.Renamer; - -public static class RenameFileHelper -{ - private static readonly Logger s_logger = LogManager.GetCurrentClassLogger(); - - private static readonly Dictionary<string, (Type type, string description, string version)> s_internalRenamers = []; - - public static IReadOnlyDictionary<string, (Type type, string description, string version)> Renamers => s_internalRenamers; - - private static RenameScriptImpl GetRenameScript(int? scriptID) - { - var script = (scriptID.HasValue && scriptID.Value is > 0 ? RepoFactory.RenameScript.GetByID(scriptID.Value) : null) ?? RepoFactory.RenameScript.GetDefaultScript(); - if (script is null) - return new() { Script = string.Empty, Type = string.Empty, ExtraData = string.Empty }; - return new() { Script = script.Script, Type = script.RenamerType, ExtraData = script.ExtraData }; - } - - private static RenameScriptImpl GetRenameScriptWithFallback(int? scriptID) - { - var script = (scriptID.HasValue && scriptID.Value is > 0 ? RepoFactory.RenameScript.GetByID(scriptID.Value) : null) ?? RepoFactory.RenameScript.GetDefaultOrFirst(); - if (script is null) - return new() { Script = string.Empty, Type = string.Empty, ExtraData = string.Empty }; - return new() { Script = script.Script, Type = script.RenamerType, ExtraData = script.ExtraData }; - } - - public static string? GetFilename(SVR_VideoLocal_Place place, int? scriptID) - => GetFilename(place, GetRenameScript(scriptID)); - - public static string? GetFilename(SVR_VideoLocal_Place place, RenameScriptImpl script) - { - var videoLocal = place.VideoLocal ?? - throw new NullReferenceException(nameof(place.VideoLocal)); - var xrefs = videoLocal.EpisodeCrossRefs; - var episodes = xrefs - .Select(x => x.AniDBEpisode) - .WhereNotNull() - .ToList(); - - // We don't have all the data yet, so don't try to rename yet. - if (xrefs.Count != episodes.Count) - return $"*Error: Not enough data to do renaming for the recognized file. Missing metadata for {xrefs.Count - episodes.Count} episodes. Aborting."; - - var renamers = GetPluginRenamersSorted(script.Type, xrefs.Count is 0); - // We don't have a renamer we can use for the file. - if (renamers.Count is 0) - return $"*Error: No renamers configured for {(xrefs.Count is 0 ? "unrecognized" : "all")} files. Aborting."; - - var anime = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var groups = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AnimeSeries) - .WhereNotNull() - .DistinctBy(a => a.AnimeGroupID) - .Select(a => a.AnimeGroup) - .WhereNotNull() - .ToList(); - var availableFolders = RepoFactory.ImportFolder.GetAll() - .Cast<IImportFolder>() - .Where(a => a.DropFolderType != DropFolderType.Excluded) - .ToList(); - var args = new RenameEventArgs(script, availableFolders, place, videoLocal, episodes, anime, groups); - foreach (var renamer in renamers) - { - try - { - // get filename from plugin - var res = renamer.GetFilename(args); - // if the plugin said to cancel, then do so - if (args.Cancel) - return $"*Error: Operation canceled by renamer {renamer.GetType().Name}."; - - // if the renamer returned no name, then defer to the next renamer. - if (string.IsNullOrEmpty(res)) - continue; - - return res; - } - catch (Exception e) - { - if (!Utils.SettingsProvider.GetSettings().Plugins.DeferOnError || args.Cancel) - { - throw; - } - - s_logger.Warn(e, $"Renamer {renamer.GetType().Name} threw an error while trying to determine a new file name, deferring to next renamer. File: \"{place.FullServerPath}\" Error message: \"{e.Message}\""); - } - } - - return null; - } - - public static (SVR_ImportFolder? importFolder, string? fileName) GetDestination(SVR_VideoLocal_Place place, int? scriptID) - => GetDestination(place, GetRenameScriptWithFallback(scriptID)); - - public static (SVR_ImportFolder? importFolder, string? fileName) GetDestination(SVR_VideoLocal_Place place, RenameScriptImpl script) - { - var videoLocal = place.VideoLocal ?? - throw new NullReferenceException(nameof(place.VideoLocal)); - var xrefs = videoLocal.EpisodeCrossRefs; - var episodes = xrefs - .Select(x => x.AniDBEpisode) - .WhereNotNull() - .ToList(); - - // We don't have all the data yet, so don't try to rename yet. - if (xrefs.Count != episodes.Count) - return (null, $"*Error: Not enough data to do renaming for the recognized file. Missing metadata for {xrefs.Count - episodes.Count} episodes."); - - var renamers = GetPluginRenamersSorted(script.Type, xrefs.Count is 0); - // We don't have a renamer we can use for the file. - if (renamers.Count is 0) - return (null, $"*Error: No renamers configured for {(xrefs.Count is 0 ? "unrecognized" : "all")} files. Aborting."); - - var anime = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var groups = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AnimeSeries) - .WhereNotNull() - .DistinctBy(a => a.AnimeGroupID) - .Select(a => a.AnimeGroup) - .WhereNotNull() - .ToList(); - var availableFolders = RepoFactory.ImportFolder.GetAll() - .Cast<IImportFolder>() - .Where(a => a.DropFolderType != DropFolderType.Excluded) - .ToList(); - var args = new MoveEventArgs(script, availableFolders, place, videoLocal, episodes, anime, groups); - foreach (var renamer in renamers) - { - try - { - // get destination from renamer - var (destFolder, destPath) = renamer.GetDestination(args); - // if the renamer has said to cancel, then return null - if (args.Cancel) - return (null, $"*Error: Operation canceled by renamer {renamer.GetType().Name}."); - - // if no path was specified, then defer - if (string.IsNullOrEmpty(destPath) || destFolder is null) - continue; - - if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar) - destPath = destPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - - destPath = RemoveFilename(place.FilePath, destPath); - - var importFolder = RepoFactory.ImportFolder.GetByImportLocation(destFolder.Path); - if (importFolder is null) - { - s_logger.Warn($"Renamer returned a Destination Import Folder, but it could not be found. The offending plugin was \"{renamer.GetType().GetAssemblyName()}\" with renamer \"{renamer.GetType().Name}\""); - continue; - } - - return (importFolder, destPath); - } - catch (Exception e) - { - if (!Utils.SettingsProvider.GetSettings().Plugins.DeferOnError || args.Cancel) - throw; - - s_logger.Warn($"Renamer: {renamer.GetType().Name} threw an error while finding a destination, deferring to next renamer. Path: \"{place.FullServerPath}\" Error message: \"{e.Message}\""); - } - } - - return (null, null); - } - - private static string RemoveFilename(string filePath, string destPath) - { - var name = Path.DirectorySeparatorChar + Path.GetFileName(filePath); - var last = destPath.LastIndexOf(Path.DirectorySeparatorChar); - if (last <= -1 || last >= destPath.Length - 1) - return destPath; - - var end = destPath[last..]; - if (end.Equals(name, StringComparison.Ordinal)) - destPath = destPath[..last]; - - return destPath; - } - - internal static void FindRenamers(IList<Assembly> assemblies) - { - var allTypes = assemblies - .SelectMany(a => - { - try - { - return a.GetTypes(); - } - catch - { - return Type.EmptyTypes; - } - }) - .Where(a => a.GetInterfaces().Contains(typeof(IRenamer))) - .ToList(); - foreach (var implementation in allTypes) - { - var attributes = implementation.GetCustomAttributes<RenamerAttribute>(); - foreach (var (key, desc) in attributes.Select(a => (key: a.RenamerId, desc: a.Description))) - { - if (key is null) - continue; - - var version = Utils.GetApplicationVersion(implementation.Assembly); - if (Renamers.TryGetValue(key, out var value)) - { - s_logger.Warn($"[RENAMER] Warning Duplicate renamer key \"{key}\" of types {implementation}@{implementation.Assembly.Location} (v{version}) and {value}@{value.type.Assembly.Location} (v{value.version})"); - continue; - } - - s_logger.Info($"Added Renamer: {key} (v{version}) - {desc}"); - s_internalRenamers.Add(key, (implementation, desc, version)); - } - } - } - - private static List<IRenamer> GetPluginRenamersSorted(string? renamerName, bool isUnrecognized) - { - var settings = Utils.SettingsProvider.GetSettings(); - var renamers = GetEnabledRenamers(renamerName) - .OrderByDescending(a => renamerName == a.Key) - .ThenBy(a => settings.Plugins.RenamerPriorities.GetValueOrDefault(a.Key, int.MaxValue)) - .ThenBy(a => a.Key, StringComparer.InvariantCulture) - .Select(a => (IRenamer)ActivatorUtilities.CreateInstance(Utils.ServiceContainer, a.Value.type)); - if (isUnrecognized) - renamers = renamers.Where(renamer => renamer is IUnrecognizedRenamer); - return renamers.ToList(); - } - - private static IEnumerable<KeyValuePair<string, (Type type, string description, string version)>> GetEnabledRenamers(string? renamerName) - { - var settings = Utils.SettingsProvider.GetSettings(); - if (string.IsNullOrEmpty(renamerName)) return s_internalRenamers; - return s_internalRenamers - .Where(kvp => kvp.Key == renamerName && (!settings.Plugins.EnabledRenamers.TryGetValue(kvp.Key, out var isEnabled) || isEnabled)); - } -} diff --git a/Shoko.Server/Renamer/RenameFileService.cs b/Shoko.Server/Renamer/RenameFileService.cs new file mode 100644 index 000000000..522ce4486 --- /dev/null +++ b/Shoko.Server/Renamer/RenameFileService.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.Models; +using Shoko.Server.Repositories; +using Shoko.Plugin.Abstractions.Attributes; +using Shoko.Plugin.Abstractions.Events; +using Shoko.Server.Repositories.Direct; +using Shoko.Server.Utilities; + +using AbstractRelocationResult = Shoko.Plugin.Abstractions.Events.RelocationResult; +using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider; + +#nullable enable +namespace Shoko.Server.Renamer; + +public class RenameFileService +{ + private readonly ILogger<RenameFileService> _logger; + private readonly ISettingsProvider _settingsProvider; + private readonly RenamerConfigRepository _renamers; + private readonly Dictionary<Type, MethodInfo?> _settingsSetters = []; + private readonly Dictionary<Type, MethodInfo?> _genericGetNewPaths = []; + + public Dictionary<string, IBaseRenamer> RenamersByKey { get; } = []; + public Dictionary<Type, IBaseRenamer> RenamersByType { get; } = []; + public Dictionary<IBaseRenamer, bool> AllRenamers { get; } = []; + + public RenameFileService(ILogger<RenameFileService> logger, ISettingsProvider settingsProvider, RenamerConfigRepository renamers) + { + _logger = logger; + _settingsProvider = settingsProvider; + _renamers = renamers; + LoadRenamers(AppDomain.CurrentDomain.GetAssemblies()); + } + + public RelocationResult GetNewPath(SVR_VideoLocal_Place place, RenamerConfig? renamerConfig = null, bool? move = null, bool? rename = null, bool? allowRelocationInsideDestination = null) + { + var settings = _settingsProvider.GetSettings(); + var shouldMove = move ?? settings.Plugins.Renamer.MoveOnImport; + var shouldRename = rename ?? settings.Plugins.Renamer.RenameOnImport; + var shouldAllowRelocationInsideDestination = allowRelocationInsideDestination ?? settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport; + + // Make sure the import folder is reachable. + var importFolder = (IImportFolder?)place.ImportFolder; + if (importFolder is null) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Unable to find import folder for file with ID {place.VideoLocal}.", + }; + + // Don't relocate files not in a drop source or drop destination. + if (importFolder.DropFolderType is DropFolderType.Excluded) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = "Not relocating file as it is not in a drop source or drop destination.", + }; + + // Or if it's in a drop destination not also marked as a drop source and relocating inside destinations is disabled. + if (importFolder.DropFolderType is DropFolderType.Destination && !shouldAllowRelocationInsideDestination) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = "Not relocating file because it's in a drop destination not also marked as a drop source and relocating inside destinations is disabled.", + }; + + var videoLocal = place.VideoLocal ?? + throw new NullReferenceException(nameof(place.VideoLocal)); + var xrefs = videoLocal.EpisodeCrossRefs; + var episodes = xrefs + .Select(x => x.AnimeEpisode) + .WhereNotNull() + .ToList(); + + // We don't have all the data yet, so don't try to rename yet. + if (xrefs.Count != episodes.Count) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Not enough data to do renaming for the recognized file. Missing metadata for {xrefs.Count - episodes.Count} episodes.", + }; + + if (renamerConfig == null) + { + var defaultRenamerName = settings.Plugins.Renamer.DefaultRenamer; + if (string.IsNullOrWhiteSpace(defaultRenamerName)) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = "No default renamer configured and no renamer config given.", + }; + + var defaultRenamer = _renamers.GetByName(defaultRenamerName); + if (defaultRenamer == null) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = "The specified default renamer does not exist.", + }; + + renamerConfig = defaultRenamer; + } + + if (!RenamersByType.TryGetValue(renamerConfig.Type, out var renamer)) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"No renamers configured for \"{renamerConfig.Type}\".", + }; + + // Check if it's unrecognized. + if (xrefs.Count == 0 && !renamer.GetType().GetInterfaces().Any(a => a.IsGenericType && a.GetGenericTypeDefinition() == typeof(IUnrecognizedRenamer<>))) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = "Configured renamer does not support unrecognized files, and the file is unrecognized.", + }; + + var anime = xrefs + .DistinctBy(x => x.AnimeID) + .Select(x => x.AnimeSeries) + .WhereNotNull() + .ToList(); + var groups = xrefs + .DistinctBy(x => x.AnimeID) + .Select(x => x.AnimeSeries) + .WhereNotNull() + .DistinctBy(a => a.AnimeGroupID) + .Select(a => a.AnimeGroup) + .WhereNotNull() + .ToList(); + var availableFolders = RepoFactory.ImportFolder.GetAll() + .Cast<IImportFolder>() + .Where(a => a.DropFolderType != DropFolderType.Excluded) + .ToList(); + + RelocationEventArgs args; + var renamerInterface = renamer.GetType().GetInterfaces().FirstOrDefault(a => a.IsGenericType && a.GetGenericTypeDefinition() == typeof(IRenamer<>)); + if (renamerInterface is not null) + { + var settingsType = renamerInterface.GetGenericArguments()[0]; + if (settingsType != renamerConfig.Settings.GetType()) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Configured renamer has settings of type \"{settingsType}\" but the renamer config has settings of type \"{renamerConfig.Settings.GetType()}\".", + }; + + var argsType = typeof(RelocationEventArgs<>).MakeGenericType(settingsType); + args = (RelocationEventArgs)ActivatorUtilities.CreateInstance(Utils.ServiceContainer, argsType); + args.Series = anime; + args.File = place; + args.Episodes = episodes; + args.Groups = groups; + args.AvailableFolders = availableFolders; + args.MoveEnabled = shouldMove; + args.RenameEnabled = shouldRename; + + // Cached reflection. + if (!_settingsSetters.TryGetValue(argsType, out var settingsSetter)) + _settingsSetters.TryAdd(argsType, + settingsSetter = argsType.GetProperties(BindingFlags.Instance | BindingFlags.Public).FirstOrDefault(a => a.Name == "Settings")?.SetMethod); + if (settingsSetter == null) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Cannot find Settings setter on \"{renamerInterface}\".", + }; + settingsSetter.Invoke(args, [renamerConfig.Settings]); + + if (!_genericGetNewPaths.TryGetValue(renamerInterface, out var method)) + _genericGetNewPaths.TryAdd(renamerInterface, + method = renamerInterface.GetMethod(nameof(IRenamer.GetNewPath), BindingFlags.Instance | BindingFlags.Public)); + + if (method == null) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Cannot find \"GetNewPath\" method on \"{renamerInterface}\".", + }; + + return UnAbstractResult(place, GetNewPath((r, a) => (AbstractRelocationResult)method.Invoke(r, [a])!, renamer, args, shouldRename, shouldMove), shouldMove, shouldRename); + } + + args = new RelocationEventArgs + { + Series = anime, + File = place, + Episodes = episodes, + Groups = groups, + AvailableFolders = availableFolders, + MoveEnabled = shouldMove, + RenameEnabled = shouldRename, + }; + + return UnAbstractResult(place, GetNewPath((r, a) => ((IRenamer)r).GetNewPath(a), renamer, args, shouldRename, shouldMove), shouldMove, shouldRename); + } + + /// <summary> + /// Un-abstract the relocation result returned from the renamer, and convert it to something easier to work internally for us. + /// </summary> + /// <param name="place">Video file location.</param> + /// <param name="result">Abstract result returned from the renamed.</param> + /// <param name="shouldMove">Indicates that we should have moved.</param> + /// <param name="shouldRename">Indicates that we should have renamed.</param> + /// <returns>An non-abstract relocation result.</returns> + private static RelocationResult UnAbstractResult(SVR_VideoLocal_Place place, AbstractRelocationResult result, bool shouldMove, bool shouldRename) + { + if (result.Error is not null) + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = result.Error.Message, + Exception = result.Error.Exception, + }; + + var newImportFolder = shouldMove && !result.SkipMove ? result.DestinationImportFolder! : place.ImportFolder!; + var newFileName = shouldRename && !result.SkipRename ? result.FileName! : place.FileName; + var newRelativeDirectory = shouldMove && !result.SkipMove ? result.Path : Path.GetDirectoryName(place.FilePath); + var newRelativePath = !string.IsNullOrEmpty(newRelativeDirectory) && newRelativeDirectory.Length > 0 ? Path.Combine(newRelativeDirectory, newFileName) : newFileName; + var newFullPath = Path.Combine(newImportFolder.Path, newRelativePath); + return new() + { + Success = true, + ImportFolder = newImportFolder, + RelativePath = newRelativePath, + // TODO: Handle file-systems that are or aren't case sensitive. + Renamed = !string.Equals(place.FileName, result.FileName, StringComparison.OrdinalIgnoreCase), + Moved = !string.Equals(Path.GetDirectoryName(place.FullServerPath), Path.GetDirectoryName(newFullPath), StringComparison.OrdinalIgnoreCase), + }; + } + + /// <summary> + /// This is called with reflection, so the signature must match the above + /// </summary> + /// <param name="func"></param> + /// <param name="renamer"></param> + /// <param name="args"></param> + /// <param name="shouldRename"></param> + /// <param name="shouldMove"></param> + /// <returns></returns> + private AbstractRelocationResult GetNewPath(Func<IBaseRenamer, RelocationEventArgs, AbstractRelocationResult> func, IBaseRenamer renamer, RelocationEventArgs args, bool shouldRename, + bool shouldMove) + { + try + { + // get filename from plugin + var result = func(renamer, args); + if (result.Error is not null) return result; + + // if the plugin said to cancel, then do so + if (args.Cancel) + return new AbstractRelocationResult + { + Error = new RelocationError($"Operation canceled by renamer {renamer.GetType().Name}.") + }; + + if (shouldRename && !result.SkipRename && (string.IsNullOrWhiteSpace(result.FileName) || result.FileName.StartsWith("*Error:"))) + { + var errorMessage = !string.IsNullOrWhiteSpace(result.FileName) + ? result.FileName[7..].Trim() + : $"The renamer \"{renamer.GetType().Name}\" returned a null or empty value for the file name."; + _logger.LogError("An error occurred while trying to find a new file name for {FilePath}: {ErrorMessage}", args.File.Path, errorMessage); + return new() { Error = new RelocationError(errorMessage) }; + } + + // Normalize file name. + if (!string.IsNullOrEmpty(result.FileName)) + { + // Move path from file name to path if it's provided in the file name and not as the Path. + if (Path.GetDirectoryName(result.FileName) is { } dirName && string.IsNullOrEmpty(result.Path)) + result.Path = dirName; + + // Ensure file name only contains a name and no path. + result.FileName = Path.GetFileName(result.FileName).Trim(); + } + + // Replace alt separator with main separator. + result.Path = result.Path?.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).Trim(); + + // Ensure the path does not have a leading separator. + if (!string.IsNullOrEmpty(result.Path) && result.Path[0] == Path.DirectorySeparatorChar) + result.Path = result.Path[1..]; + + if (shouldMove && !result.SkipMove && (result.DestinationImportFolder is null || result.Path is null || result.Path.StartsWith("*Error:"))) + { + var errorMessage = !string.IsNullOrWhiteSpace(result.Path) && result.Path.StartsWith("*Error:") + ? result.Path[7..].Trim() + : $"The renamer \"{renamer.GetType().Name}\" could not find a valid destination."; + _logger.LogWarning("An error occurred while trying to find a destination for {FilePath}: {ErrorMessage}", args.File.Path, errorMessage); + return new() { Error = new RelocationError(errorMessage) }; + } + + return result; + } + catch (Exception e) + { + return new AbstractRelocationResult + { + Error = new RelocationError(e.Message, e) + }; + } + } + + private void LoadRenamers(IList<Assembly> assemblies) + { + var allTypes = assemblies + .SelectMany(a => + { + try + { + return a.GetTypes(); + } + catch + { + return Type.EmptyTypes; + } + }) + .Where(a => a.IsClass && a is { IsAbstract: false, IsGenericType: false } && a.GetInterfaces().Any(b => + (b.IsGenericType && b.GetGenericTypeDefinition() == typeof(IRenamer<>)) || b == typeof(IRenamer))) + .ToList(); + + var enabledSetting = _settingsProvider.GetSettings().Plugins.Renamer.EnabledRenamers; + foreach (var implementation in allTypes) + { + var attributes = implementation.GetCustomAttributes<RenamerIDAttribute>(); + if (!attributes.Any()) + _logger.LogWarning("Warning {ImplementationName} has no RenamerIDAttribute and cannot be loaded.", implementation.Name); + foreach (var id in attributes.Select(a => a.RenamerId)) + { + var version = implementation.Assembly.GetName().Version; + if (RenamersByKey.TryGetValue(id, out var value)) + { + var info = value.GetType(); + _logger.LogWarning("{Message}", $"Warning Duplicate renamer ID \"{id}\" of types {implementation}@{implementation.Assembly.Location} (v{version}) and {value}@{info.Assembly.Location} (v{info.Assembly.GetName().Version})"); + continue; + } + + IBaseRenamer renamer; + try + { + renamer = (IBaseRenamer)ActivatorUtilities.CreateInstance(Utils.ServiceContainer, implementation); + } + catch (Exception e) + { + _logger.LogWarning(e, "Could not create renamer of type: {Type}@{Location} (v{Version})", implementation, implementation.Assembly.Location, + version); + continue; + } + + if (!enabledSetting.TryGetValue(id, out var enabled) || enabled) + { + _logger.LogInformation("Added Renamer: {Id} (v{Version})", id, version); + RenamersByKey.Add(id, renamer); + RenamersByType.Add(implementation, renamer); + AllRenamers[renamer] = true; + } + else + AllRenamers[renamer] = false; + } + } + } +} diff --git a/Shoko.Server/Renamer/LegacyRenamer.cs b/Shoko.Server/Renamer/WebAOMRenamer.cs similarity index 77% rename from Shoko.Server/Renamer/LegacyRenamer.cs rename to Shoko.Server/Renamer/WebAOMRenamer.cs index 9780ec155..c558bff7a 100644 --- a/Shoko.Server/Renamer/LegacyRenamer.cs +++ b/Shoko.Server/Renamer/WebAOMRenamer.cs @@ -2,52 +2,90 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using NLog; +using Microsoft.Extensions.Logging; using Shoko.Commons.Extensions; -using Shoko.Models.Server; using Shoko.Plugin.Abstractions; using Shoko.Plugin.Abstractions.Attributes; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Events; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Models; using Shoko.Server.Repositories; using Shoko.Server.Server; using Shoko.Server.Utilities; using EpisodeType = Shoko.Models.Enums.EpisodeType; +using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider; namespace Shoko.Server.Renamer; -[Renamer(RENAMER_ID, Description = "Legacy")] -public class LegacyRenamer : IRenamer +[RenamerID(RENAMER_ID)] +public class WebAOMRenamer : IRenamer<WebAOMSettings> { - private const string RENAMER_ID = "Legacy"; - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private const string RENAMER_ID = "WebAOM"; + private readonly ILogger<WebAOMRenamer> _logger; + private readonly ISettingsProvider _settingsProvider; + private readonly IRelocationService _relocationService; - public string GetFilename(RenameEventArgs args) + public WebAOMRenamer(ILogger<WebAOMRenamer> logger, ISettingsProvider settingsProviderProvider, IRelocationService relocationService) { - if (args.Script == null) + _logger = logger; + _settingsProvider = settingsProviderProvider; + _relocationService = relocationService; + } + + public string Name => "WebAOM Renamer"; + public string Description => "The legacy renamer, based on WebAOM's renamer. You can find information for it at https://wiki.anidb.net/WebAOM#Scripting"; + + public Shoko.Plugin.Abstractions.Events.RelocationResult GetNewPath(RelocationEventArgs<WebAOMSettings> args) + { + var script = args.Settings.Script; + if (args.RenameEnabled && script == null) { - throw new Exception("*Error: No script available for renamer"); + return new Shoko.Plugin.Abstractions.Events.RelocationResult + { + Error = new RelocationError("No script available for renamer") + }; } - if (args.Script.Type != RENAMER_ID && args.Script.Type != GroupAwareRenamer.RENAMER_ID) + string newFilename = null; + var success = true; + (IImportFolder dest, string folder) destination = default; + Exception ex = null; + try { - return null; + if (args.RenameEnabled) (success, newFilename) = GetNewFileName(args, args.Settings, script); + if (args.MoveEnabled) + { + destination = GetDestinationFolder(args); + if (destination == default) success = false; + } + } + catch (Exception e) + { + success = false; + ex = e; } - return GetNewFileName(args, args.Script.Script); - } - - public (IImportFolder destination, string subfolder) GetDestination(MoveEventArgs args) - { - if (args.Script == null) + if (!success) { - throw new Exception("*Error: No script available for renamer"); + return new Shoko.Plugin.Abstractions.Events.RelocationResult + { + Error = ex == null ? null : new RelocationError(ex.Message, ex) + }; } - return GetDestinationFolder(args); + return new Shoko.Plugin.Abstractions.Events.RelocationResult + { + FileName = newFilename, + DestinationImportFolder = destination.dest, + Path = destination.folder + }; } - private static readonly char[] validTests = "AGFEHXRTYDSCIZJWUMN".ToCharArray(); + public bool SupportsMoving => true; + public bool SupportsRenaming => true; + + private readonly char[] validTests = "AGFEHXRTYDSCIZJWUMN".ToCharArray(); /* TESTS A int Anime id @@ -98,7 +136,7 @@ Y int Year /// <param name="test"></param> /// <param name="episodes"></param> /// <returns></returns> - private static bool EvaluateTestA(string test, List<SVR_AniDB_Episode> episodes) + private bool EvaluateTestA(string test, List<SVR_AniDB_Episode> episodes) { try { @@ -123,7 +161,7 @@ private static bool EvaluateTestA(string test, List<SVR_AniDB_Episode> episodes) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -134,7 +172,7 @@ private static bool EvaluateTestA(string test, List<SVR_AniDB_Episode> episodes) /// <param name="test"></param> /// <param name="aniFile"></param> /// <returns></returns> - private static bool EvaluateTestG(string test, SVR_AniDB_File aniFile) + private bool EvaluateTestG(string test, SVR_AniDB_File aniFile) { try { @@ -165,7 +203,7 @@ private static bool EvaluateTestG(string test, SVR_AniDB_File aniFile) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -178,7 +216,7 @@ private static bool EvaluateTestG(string test, SVR_AniDB_File aniFile) /// <param name="aniFile"></param> /// <param name="episodes"></param> /// <returns></returns> - private static bool EvaluateTestM(string test, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes) + private bool EvaluateTestM(string test, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes) { try { @@ -204,7 +242,7 @@ private static bool EvaluateTestM(string test, SVR_AniDB_File aniFile, List<SVR_ } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -217,7 +255,7 @@ private static bool EvaluateTestM(string test, SVR_AniDB_File aniFile, List<SVR_ /// <param name="aniFile"></param> /// <param name="episodes"></param> /// <returns></returns> - private static bool EvaluateTestN(string test, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes) + private bool EvaluateTestN(string test, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes) { try { @@ -234,7 +272,7 @@ private static bool EvaluateTestN(string test, SVR_AniDB_File aniFile, List<SVR_ } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -245,7 +283,7 @@ private static bool EvaluateTestN(string test, SVR_AniDB_File aniFile, List<SVR_ /// <param name="test"></param> /// <param name="aniFile"></param> /// <returns></returns> - private static bool EvaluateTestD(string test, SVR_AniDB_File aniFile) + private bool EvaluateTestD(string test, SVR_AniDB_File aniFile) { try { @@ -269,7 +307,7 @@ private static bool EvaluateTestD(string test, SVR_AniDB_File aniFile) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -280,7 +318,7 @@ private static bool EvaluateTestD(string test, SVR_AniDB_File aniFile) /// <param name="test"></param> /// <param name="aniFile"></param> /// <returns></returns> - private static bool EvaluateTestS(string test, SVR_AniDB_File aniFile) + private bool EvaluateTestS(string test, SVR_AniDB_File aniFile) { try { @@ -312,7 +350,7 @@ private static bool EvaluateTestS(string test, SVR_AniDB_File aniFile) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -323,7 +361,7 @@ private static bool EvaluateTestS(string test, SVR_AniDB_File aniFile) /// <param name="test"></param> /// <param name="aniFile"></param> /// <returns></returns> - private static bool EvaluateTestF(string test, SVR_AniDB_File aniFile) + private bool EvaluateTestF(string test, SVR_AniDB_File aniFile) { try { @@ -371,7 +409,7 @@ private static bool EvaluateTestF(string test, SVR_AniDB_File aniFile) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -382,7 +420,7 @@ private static bool EvaluateTestF(string test, SVR_AniDB_File aniFile) /// <param name="test"></param> /// <param name="vid"></param> /// <returns></returns> - private static bool EvaluateTestZ(string test, SVR_VideoLocal vid) + private bool EvaluateTestZ(string test, SVR_VideoLocal vid) { try { @@ -430,12 +468,12 @@ private static bool EvaluateTestZ(string test, SVR_VideoLocal vid) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - private static bool EvaluateTestW(string test, SVR_VideoLocal vid) + private bool EvaluateTestW(string test, SVR_VideoLocal vid) { try { @@ -485,12 +523,12 @@ private static bool EvaluateTestW(string test, SVR_VideoLocal vid) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - private static bool EvaluateTestU(string test, SVR_VideoLocal vid) + private bool EvaluateTestU(string test, SVR_VideoLocal vid) { try { @@ -540,13 +578,13 @@ private static bool EvaluateTestU(string test, SVR_VideoLocal vid) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - private static bool EvaluateTestR(string test, SVR_AniDB_File aniFile) + private bool EvaluateTestR(string test, SVR_AniDB_File aniFile) { try { @@ -581,12 +619,12 @@ private static bool EvaluateTestR(string test, SVR_AniDB_File aniFile) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - private static bool EvaluateTestT(string test, SVR_AniDB_Anime anime) + private bool EvaluateTestT(string test, SVR_AniDB_Anime anime) { try { @@ -616,12 +654,12 @@ private static bool EvaluateTestT(string test, SVR_AniDB_Anime anime) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - private static bool EvaluateTestY(string test, SVR_AniDB_Anime anime) + private bool EvaluateTestY(string test, SVR_AniDB_Anime anime) { try { @@ -664,12 +702,12 @@ private static bool EvaluateTestY(string test, SVR_AniDB_Anime anime) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - private static bool EvaluateTestE(string test, List<SVR_AniDB_Episode> episodes) + private bool EvaluateTestE(string test, List<SVR_AniDB_Episode> episodes) { try { @@ -712,12 +750,12 @@ private static bool EvaluateTestE(string test, List<SVR_AniDB_Episode> episodes) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - private static bool EvaluateTestH(string test, List<SVR_AniDB_Episode> episodes) + private bool EvaluateTestH(string test, List<SVR_AniDB_Episode> episodes) { try { @@ -761,7 +799,7 @@ private static bool EvaluateTestH(string test, List<SVR_AniDB_Episode> episodes) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -776,7 +814,7 @@ private static bool EvaluateTestH(string test, List<SVR_AniDB_Episode> episodes) /// <param name="greaterThanEqual"></param> /// <param name="lessThan"></param> /// <param name="lessThanEqual"></param> - private static void ProcessNumericalOperators(ref string test, out bool notCondition, out bool greaterThan, + private void ProcessNumericalOperators(ref string test, out bool notCondition, out bool greaterThan, out bool greaterThanEqual, out bool lessThan, out bool lessThanEqual) { notCondition = false; @@ -819,7 +857,7 @@ private static void ProcessNumericalOperators(ref string test, out bool notCondi test = test.Substring(1, test.Length - 1); } - private static bool EvaluateTestX(string test, SVR_AniDB_Anime anime) + private bool EvaluateTestX(string test, SVR_AniDB_Anime anime) { try { @@ -862,7 +900,7 @@ private static bool EvaluateTestX(string test, SVR_AniDB_Anime anime) } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } @@ -876,7 +914,7 @@ private static bool EvaluateTestX(string test, SVR_AniDB_Anime anime) /// <param name="episodes"></param> /// <param name="anime"></param> /// <returns></returns> - private static bool EvaluateTestI(string test, SVR_VideoLocal vid, SVR_AniDB_File aniFile, + private bool EvaluateTestI(string test, SVR_VideoLocal vid, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes, SVR_AniDB_Anime anime) { @@ -1343,194 +1381,136 @@ private static bool EvaluateTestI(string test, SVR_VideoLocal vid, SVR_AniDB_Fil } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); return false; } } - public static string GetNewFileName(RenameEventArgs args, string script) + private (bool success, string name) GetNewFileName(RelocationEventArgs args, WebAOMSettings settings, string script) { // Cheat and just look it up by location to avoid rewriting this whole file. var sourceFolder = RepoFactory.ImportFolder.GetAll() - .FirstOrDefault(a => args.FileInfo.Path.StartsWith(a.ImportFolderLocation)); - if (sourceFolder == null) - { - throw new Exception("*Unable to get import folder"); - } + .FirstOrDefault(a => args.File.Path.StartsWith(a.ImportFolderLocation)); + if (sourceFolder == null) return (false, "Unable to Get Import Folder"); var place = RepoFactory.VideoLocalPlace.GetByFilePathAndImportFolderID( - args.FileInfo.Path.Replace(sourceFolder.ImportFolderLocation, ""), sourceFolder.ImportFolderID); + args.File.Path.Replace(sourceFolder.ImportFolderLocation, ""), sourceFolder.ImportFolderID); var vid = place?.VideoLocal; var lines = script.Split(Environment.NewLine.ToCharArray()); var newFileName = string.Empty; - var episodes = new List<SVR_AniDB_Episode>(); SVR_AniDB_Anime anime; - if (vid == null) - { - throw new Exception("*Error: Unable to access file"); - } + if (vid == null) return (false, "Unable to access file"); // get all the data so we don't need to get multiple times var aniFile = vid.AniDBFile; if (aniFile == null) { var animeEps = vid.AnimeEpisodes; - if (animeEps.Count == 0) - { - throw new Exception("*Error: Unable to get episode for file"); - } + if (animeEps.Count == 0) return (false, "Unable to get episode for file"); - episodes.AddRange(animeEps.Select(a => a.AniDB_Episode).OrderBy(a => a.EpisodeType) + episodes.AddRange(animeEps.Select(a => a.AniDB_Episode).OrderBy(a => a?.EpisodeType ?? (int)EpisodeType.Other) .ThenBy(a => a.EpisodeNumber)); anime = RepoFactory.AniDB_Anime.GetByAnimeID(episodes[0].AnimeID); - if (anime == null) - { - throw new Exception("*Error: Unable to get anime for file"); - } + if (anime == null) return (false, "Unable to get anime for file"); } else { episodes = aniFile.Episodes; - if (episodes.Count == 0) - { - throw new Exception("*Error: Unable to get episode for file"); - } + if (episodes.Count == 0) return (false, "Unable to get episode for file"); anime = RepoFactory.AniDB_Anime.GetByAnimeID(episodes[0].AnimeID); - if (anime == null) - { - throw new Exception("*Error: Unable to get anime for file"); - } + if (anime == null) return (false, "Unable to get anime for file"); } foreach (var line in lines) { var thisLine = line.Trim(); - if (thisLine.Length == 0) - { - continue; - } + if (thisLine.Length == 0) continue; // remove all comments from this line var comPos = thisLine.IndexOf("//", StringComparison.Ordinal); - if (comPos >= 0) - { - thisLine = thisLine.Substring(0, comPos); - } + if (comPos >= 0) thisLine = thisLine.Substring(0, comPos); // check if this line has no tests (applied to all files) if (thisLine.StartsWith(Constants.FileRenameReserved.Do, StringComparison.InvariantCultureIgnoreCase)) { var action = GetAction(thisLine); - PerformActionOnFileName(ref newFileName, action, vid, aniFile, episodes, anime); + var (success, name) = PerformActionOnFileName(newFileName, action, settings, vid, aniFile, episodes, anime); + if (!success) return (false, name); + newFileName = name; } else if (EvaluateTest(thisLine, vid, aniFile, episodes, anime)) { // if the line has passed the tests, then perform the action - var action = GetAction(thisLine); // if the action is fail, we don't want to rename - if (action.ToUpper() - .Trim() - .Equals(Constants.FileRenameReserved.Fail, StringComparison.InvariantCultureIgnoreCase)) - { - throw new Exception("*Error: The script called FAIL"); - } + if (action.ToUpper().Trim().Equals(Constants.FileRenameReserved.Fail, StringComparison.InvariantCultureIgnoreCase)) + return (false, "The script called FAIL"); - PerformActionOnFileName(ref newFileName, action, vid, aniFile, episodes, anime); + var (success, name) = PerformActionOnFileName(newFileName, action, settings, vid, aniFile, episodes, anime); + if (!success) return (false, name); + newFileName = name; } } - if (string.IsNullOrEmpty(newFileName)) - { - throw new Exception("*Error: the new filename is empty (script error)"); - } + if (string.IsNullOrEmpty(newFileName)) return (false, "The new filename is empty (script error)"); var pathToVid = place.FilePath; - if (string.IsNullOrEmpty(pathToVid)) - { - throw new Exception("*Error: Unable to get the file's old filename"); - } + if (string.IsNullOrEmpty(pathToVid)) return (false, "Unable to get the file's old filename"); - var ext = - Path.GetExtension(pathToVid); //Prefer VideoLocal_Place as this is more accurate. - if (string.IsNullOrEmpty(ext)) - { - throw - new Exception( - "*Error: Unable to get the file's extension"); // fail if we get a blank extension, something went wrong. - } + var ext = Path.GetExtension(pathToVid); // Prefer VideoLocal_Place as this is more accurate. + if (string.IsNullOrEmpty(ext)) return (false, "Unable to get the file's extension"); // fail if we get a blank extension, something went wrong. // finally add back the extension - return Utils.ReplaceInvalidFolderNameCharacters($"{newFileName.Replace("`", "'")}{ext}"); + return (true, Utils.ReplaceInvalidFolderNameCharacters($"{newFileName.Replace("`", "'")}{ext}")); } - private static void PerformActionOnFileName(ref string newFileName, string action, SVR_VideoLocal vid, - SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes, SVR_AniDB_Anime anime) + private (bool, string) PerformActionOnFileName(string newFileName, string action, WebAOMSettings settings, SVR_VideoLocal vid, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes, SVR_AniDB_Anime anime) { // find the first test - var posStart = action.IndexOf(" ", StringComparison.Ordinal); - if (posStart < 0) - { - return; - } + var posStart = action.IndexOf(' '); + if (posStart < 0) return (true, newFileName); - var actionType = action.Substring(0, posStart); + var actionType = action[..posStart]; var parameter = action.Substring(posStart + 1, action.Length - posStart - 1); + // action is to add the new file name + if (actionType.Trim().Equals(Constants.FileRenameReserved.Add, StringComparison.InvariantCultureIgnoreCase)) + return PerformActionOnFileNameADD(newFileName, parameter, settings, vid, aniFile, episodes, anime); - // action is to add the the new file name - if (actionType.Trim() - .Equals(Constants.FileRenameReserved.Add, StringComparison.InvariantCultureIgnoreCase)) - { - PerformActionOnFileNameADD(ref newFileName, parameter, vid, aniFile, episodes, anime); - } + if (actionType.Trim().Equals(Constants.FileRenameReserved.Replace, StringComparison.InvariantCultureIgnoreCase)) + return PerformActionOnFileNameREPLACE(ref newFileName, parameter); - if (actionType.Trim() - .Equals(Constants.FileRenameReserved.Replace, StringComparison.InvariantCultureIgnoreCase)) - { - PerformActionOnFileNameREPLACE(ref newFileName, parameter); - } + return (true, newFileName); } - private static void PerformActionOnFileNameREPLACE(ref string newFileName, string action) + private (bool, string) PerformActionOnFileNameREPLACE(ref string newFileName, string action) { try { action = action.Trim(); - var posStart1 = action.IndexOf("'", 0, StringComparison.Ordinal); - if (posStart1 < 0) - { - return; - } + var posStart1 = action.IndexOf('\'', 0); + if (posStart1 < 0) return (false, "Unable to parse replace string"); - var posEnd1 = action.IndexOf("'", posStart1 + 1, StringComparison.Ordinal); - if (posEnd1 < 0) - { - return; - } + var posEnd1 = action.IndexOf('\'', posStart1 + 1); + if (posEnd1 < 0) return (false, "Unable to parse replace string"); var toReplace = action.Substring(posStart1 + 1, posEnd1 - posStart1 - 1); + if (string.IsNullOrEmpty(toReplace)) return (false, "Replace string cannot be empty"); - var posStart2 = action.IndexOf("'", posEnd1 + 1, StringComparison.Ordinal); - if (posStart2 < 0) - { - return; - } + var posStart2 = action.IndexOf('\'', posEnd1 + 1); + if (posStart2 < 0) return (false, "Unable to parse replace with string"); - var posEnd2 = action.IndexOf("'", posStart2 + 1, StringComparison.Ordinal); - if (posEnd2 < 0) - { - return; - } + var posEnd2 = action.IndexOf('\'', posStart2 + 1); + if (posEnd2 < 0) return (false, "Unable to parse replace with string"); var replaceWith = action.Substring(posStart2 + 1, posEnd2 - posStart2 - 1); @@ -1538,11 +1518,14 @@ private static void PerformActionOnFileNameREPLACE(ref string newFileName, strin } catch (Exception ex) { - logger.Error(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); + return (false, ex.Message); } + + return (true, newFileName); } - private static void PerformActionOnFileNameADD(ref string newFileName, string action, SVR_VideoLocal vid, + private (bool, string) PerformActionOnFileNameADD(string newFileName, string action, WebAOMSettings settings, SVR_VideoLocal vid, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes, SVR_AniDB_Anime anime) { newFileName += action; @@ -1561,12 +1544,10 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().ToLower().Contains(Constants.FileRenameTag.AnimeNameEnglish.ToLower())) { - newFileName = anime.Titles - .Where(ti => - ti.Language == TitleLanguage.English && - (ti.TitleType == TitleType.Main || ti.TitleType == TitleType.Official)) - .Aggregate(newFileName, - (current, ti) => current.Replace(Constants.FileRenameTag.AnimeNameEnglish, ti.Title)); + var title = anime.Titles.FirstOrDefault(ti => ti.Language == TitleLanguage.English && ti.TitleType is TitleType.Main or TitleType.Official)?.Title; + if (string.IsNullOrEmpty(title)) + return (false, "Unable to get the English title"); + newFileName = newFileName.Replace(Constants.FileRenameTag.AnimeNameEnglish, title); } #endregion @@ -1575,12 +1556,14 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().ToLower().Contains(Constants.FileRenameTag.AnimeNameMain.ToLower())) { - newFileName = anime.Titles - .Where(ti => - ti.TitleType == TitleType.Main || - (ti.Language == TitleLanguage.Romaji && ti.TitleType == TitleType.Official)) - .Aggregate(newFileName, - (current, ti) => current.Replace(Constants.FileRenameTag.AnimeNameMain, ti.Title)); + var title = anime.Titles + .FirstOrDefault(ti => ti.TitleType == TitleType.Main || (ti.Language == TitleLanguage.Romaji && ti.TitleType == TitleType.Official)) + ?.Title ?? + anime.MainTitle; + if (string.IsNullOrEmpty(title)) + return (false, "Unable to get the main title"); + + newFileName = newFileName.Replace(Constants.FileRenameTag.AnimeNameMain, title); } #endregion @@ -1589,12 +1572,11 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().ToLower().Contains(Constants.FileRenameTag.AnimeNameKanji.ToLower())) { - newFileName = anime.Titles - .Where(ti => - ti.Language == TitleLanguage.Japanese && - (ti.TitleType == TitleType.Main || ti.TitleType == TitleType.Official)) - .Aggregate(newFileName, - (current, ti) => current.Replace(Constants.FileRenameTag.AnimeNameKanji, ti.Title)); + var title = anime.Titles.FirstOrDefault(ti => ti.Language == TitleLanguage.Japanese && ti.TitleType is TitleType.Main or TitleType.Official)?.Title; + if (string.IsNullOrEmpty(title)) + return (false, "Unable to get the kanji title"); + + newFileName = newFileName.Replace(Constants.FileRenameTag.AnimeNameKanji, title); } #endregion @@ -1657,8 +1639,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (episodes.Count > 1) { - episodeNumber += "-" + - episodes[episodes.Count - 1].EpisodeNumber.ToString().PadLeft(zeroPadding, '0'); + episodeNumber += "-" + episodes[^1].EpisodeNumber.ToString().PadLeft(zeroPadding, '0'); } newFileName = newFileName.Replace(Constants.FileRenameTag.EpisodeNumber, episodeNumber); @@ -1668,7 +1649,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac #region Episode Number - if (action.Trim().ToLower().Contains(Constants.FileRenameTag.Episodes.ToLower())) + if (action.Trim().Contains(Constants.FileRenameTag.Episodes, StringComparison.CurrentCultureIgnoreCase)) { int epCount; @@ -1707,11 +1688,8 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac var epname = RepoFactory.AniDB_Episode_Title .GetByEpisodeIDAndLanguage(episodes[0].EpisodeID, TitleLanguage.English) .FirstOrDefault()?.Title; - var settings = Utils.SettingsProvider.GetSettings(); - if (epname?.Length > settings.LegacyRenamerMaxEpisodeLength) - { - epname = epname.Substring(0, settings.LegacyRenamerMaxEpisodeLength - 1) + "…"; - } + if (string.IsNullOrEmpty(epname)) return (false, "Unable to get the english episode name"); + if (epname.Length > settings.MaxEpisodeLength) epname = epname[..(settings.MaxEpisodeLength - 1)] + "…"; newFileName = newFileName.Replace(Constants.FileRenameTag.EpisodeNameEnglish, epname); } @@ -1725,11 +1703,8 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac var epname = RepoFactory.AniDB_Episode_Title .GetByEpisodeIDAndLanguage(episodes[0].EpisodeID, TitleLanguage.Romaji) .FirstOrDefault()?.Title; - var settings = Utils.SettingsProvider.GetSettings(); - if (epname?.Length > settings.LegacyRenamerMaxEpisodeLength) - { - epname = epname.Substring(0, settings.LegacyRenamerMaxEpisodeLength - 1) + "…"; - } + if (string.IsNullOrEmpty(epname)) return (false, "Unable to get the romaji episode name"); + if (epname.Length > settings.MaxEpisodeLength) epname = epname[..(settings.MaxEpisodeLength - 1)] + "…"; newFileName = newFileName.Replace(Constants.FileRenameTag.EpisodeNameRomaji, epname); } @@ -1741,10 +1716,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().ToLower().Contains(Constants.FileRenameTag.GroupShortName.ToLower())) { var subgroup = aniFile?.Anime_GroupNameShort ?? "Unknown"; - if (subgroup.Equals("raw", StringComparison.InvariantCultureIgnoreCase)) - { - subgroup = "Unknown"; - } + if (subgroup.Equals("raw", StringComparison.InvariantCultureIgnoreCase)) subgroup = "Unknown"; newFileName = newFileName.Replace(Constants.FileRenameTag.GroupShortName, subgroup); } @@ -1755,8 +1727,9 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().ToLower().Contains(Constants.FileRenameTag.GroupLongName.ToLower())) { - newFileName = newFileName.Replace(Constants.FileRenameTag.GroupLongName, - aniFile?.Anime_GroupName ?? "Unknown"); + var subgroup = aniFile?.Anime_GroupName ?? "Unknown"; + if (subgroup.Equals("raw", StringComparison.InvariantCultureIgnoreCase)) subgroup = "Unknown"; + newFileName = newFileName.Replace(Constants.FileRenameTag.GroupLongName, subgroup); } #endregion @@ -1813,8 +1786,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.FileVersion)) { - newFileName = newFileName.Replace(Constants.FileRenameTag.FileVersion, - aniFile?.FileVersion.ToString() ?? "1"); + newFileName = newFileName.Replace(Constants.FileRenameTag.FileVersion, aniFile?.FileVersion.ToString() ?? "1"); } #endregion @@ -1823,8 +1795,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.DubLanguage)) { - newFileName = - newFileName.Replace(Constants.FileRenameTag.DubLanguage, aniFile?.LanguagesRAW ?? string.Empty); + newFileName = newFileName.Replace(Constants.FileRenameTag.DubLanguage, aniFile?.LanguagesRAW ?? string.Empty); } #endregion @@ -1833,8 +1804,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.SubLanguage)) { - newFileName = - newFileName.Replace(Constants.FileRenameTag.SubLanguage, aniFile?.SubtitlesRAW ?? string.Empty); + newFileName = newFileName.Replace(Constants.FileRenameTag.SubLanguage, aniFile?.SubtitlesRAW ?? string.Empty); } #endregion @@ -1852,8 +1822,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.AudioCodec)) { - newFileName = newFileName.Replace(Constants.FileRenameTag.AudioCodec, - vid?.MediaInfo?.AudioStreams.FirstOrDefault()?.CodecID); + newFileName = newFileName.Replace(Constants.FileRenameTag.AudioCodec, vid?.MediaInfo?.AudioStreams.FirstOrDefault()?.CodecID); } #endregion @@ -1862,8 +1831,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.VideoBitDepth)) { - newFileName = newFileName.Replace(Constants.FileRenameTag.VideoBitDepth, - (vid?.MediaInfo?.VideoStream?.BitDepth).ToString()); + newFileName = newFileName.Replace(Constants.FileRenameTag.VideoBitDepth, (vid?.MediaInfo?.VideoStream?.BitDepth).ToString()); } #endregion @@ -1958,8 +1926,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.GroupID)) { - newFileName = - newFileName.Replace(Constants.FileRenameTag.GroupID, aniFile?.GroupID.ToString() ?? "Unknown"); + newFileName = newFileName.Replace(Constants.FileRenameTag.GroupID, aniFile?.GroupID.ToString() ?? "Unknown"); } #endregion @@ -1985,10 +1952,7 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.Censored)) { var censored = "cen"; - if (aniFile?.IsCensored ?? false) - { - censored = "unc"; - } + if (aniFile?.IsCensored ?? false) censored = "unc"; newFileName = newFileName.Replace(Constants.FileRenameTag.Censored, censored); } @@ -2000,31 +1964,27 @@ private static void PerformActionOnFileNameADD(ref string newFileName, string ac if (action.Trim().Contains(Constants.FileRenameTag.Deprecated)) { var depr = "New"; - if (aniFile?.IsDeprecated ?? false) - { - depr = "DEPR"; - } + if (aniFile?.IsDeprecated ?? false) depr = "DEPR"; newFileName = newFileName.Replace(Constants.FileRenameTag.Deprecated, depr); } #endregion + + return (true, newFileName); } - private static string GetAction(string line) + private string GetAction(string line) { // find the first test var posStart = line.IndexOf("DO ", StringComparison.Ordinal); - if (posStart < 0) - { - return string.Empty; - } + if (posStart < 0) return string.Empty; var action = line.Substring(posStart + 3, line.Length - posStart - 3); return action; } - private static bool EvaluateTest(string line, SVR_VideoLocal vid, SVR_AniDB_File aniFile, + private bool EvaluateTest(string line, SVR_VideoLocal vid, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes, SVR_AniDB_Anime anime) { @@ -2033,20 +1993,14 @@ private static bool EvaluateTest(string line, SVR_VideoLocal vid, SVR_AniDB_File foreach (var c in validTests) { var prefix = $"IF {c}("; - if (!line.ToUpper().StartsWith(prefix)) - { - continue; - } + if (!line.ToUpper().StartsWith(prefix)) continue; // find the first test var posStart = line.IndexOf('('); var posEnd = line.IndexOf(')'); var posStartOrig = posStart; - if (posEnd < posStart) - { - return false; - } + if (posEnd < posStart) return false; var condition = line.Substring(posStart + 1, posEnd - posStart - 1); var passed = EvaluateTest(c, condition, vid, aniFile, episodes, anime); @@ -2055,10 +2009,7 @@ private static bool EvaluateTest(string line, SVR_VideoLocal vid, SVR_AniDB_File while (posStart > 0) { posStart = line.IndexOf(';', posStart); - if (posStart <= 0) - { - continue; - } + if (posStart <= 0) continue; var thisLineRemainder = line.Substring(posStart + 1, line.Length - posStart - 1).Trim(); // remove any spacing @@ -2071,12 +2022,9 @@ private static bool EvaluateTest(string line, SVR_VideoLocal vid, SVR_AniDB_File var thisPassed = EvaluateTest(thisTest, condition, vid, aniFile, episodes, anime); - if (!passed || !thisPassed) - { - return false; - } + if (!passed || !thisPassed) return false; - posStart = posStart + 1; + posStart += 1; } // if the first test passed, and we only have OR's left then it is an automatic success @@ -2089,10 +2037,7 @@ private static bool EvaluateTest(string line, SVR_VideoLocal vid, SVR_AniDB_File while (posStart > 0) { posStart = line.IndexOf(',', posStart); - if (posStart <= 0) - { - continue; - } + if (posStart <= 0) continue; var thisLineRemainder = line.Substring(posStart + 1, line.Length - posStart - 1).Trim(); @@ -2106,19 +2051,16 @@ private static bool EvaluateTest(string line, SVR_VideoLocal vid, SVR_AniDB_File var thisPassed = EvaluateTest(thisTest, condition, vid, aniFile, episodes, anime); - if (thisPassed) - { - return true; - } + if (thisPassed) return true; - posStart = posStart + 1; + posStart += 1; } } return false; } - private static bool EvaluateTest(char testChar, string testCondition, SVR_VideoLocal vid, + private bool EvaluateTest(char testChar, string testCondition, SVR_VideoLocal vid, SVR_AniDB_File aniFile, List<SVR_AniDB_Episode> episodes, SVR_AniDB_Anime anime) { @@ -2169,157 +2111,139 @@ private static bool EvaluateTest(char testChar, string testCondition, SVR_VideoL } } - public (SVR_ImportFolder dest, string folder) GetDestinationFolder(MoveEventArgs args) + public (IImportFolder dest, string folder) GetDestinationFolder(RelocationEventArgs<WebAOMSettings> args) { - SVR_ImportFolder destFolder = null; - var settings = Utils.SettingsProvider.GetSettings(); - foreach (var fldr in RepoFactory.ImportFolder.GetAll()) + if (args.Settings.GroupAwareSorting) + return GetGroupAwareDestination(args); + + return GetFlatFolderDestination(args); + } + + private (IImportFolder dest, string folder) GetGroupAwareDestination(RelocationEventArgs<WebAOMSettings> args) + { + if (args?.Episodes == null || args.Episodes.Count == 0) { - if (!fldr.FolderIsDropDestination) - { - continue; - } + throw new ArgumentException("File is unrecognized. Not Moving"); + } - if (fldr.FolderIsDropSource) - { - continue; - } + // get the series + var series = args.Series.FirstOrDefault(); - if (!Directory.Exists(fldr.ImportFolderLocation)) - { - continue; - } + if (series == null) + { + throw new ArgumentException("Series cannot be found for file"); + } - // Continue if on a separate drive and there's no space - if (!settings.Import.SkipDiskSpaceChecks && - !args.FileInfo.Path.StartsWith(Path.GetPathRoot(fldr.ImportFolderLocation))) - { - var available = 0L; - try - { - available = new DriveInfo(fldr.ImportFolderLocation).AvailableFreeSpace; - } - catch (Exception e) - { - logger.Error(e); - } + // replace the invalid characters + var name = series.PreferredTitle.ReplaceInvalidPathCharacters(); + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Series Name is null or empty"); + } - if (available < args.FileInfo.Size) - { - continue; - } - } + var group = args.Groups.FirstOrDefault(); + if (group == null) + { + throw new ArgumentException("Group could not be found for file"); + } - destFolder = fldr; - break; + string path; + if (group.Series.Count == 1) + { + path = name; + } + else + { + var groupName = Utils.ReplaceInvalidFolderNameCharacters(group.PreferredTitle); + path = Path.Combine(groupName, name); } - var xrefs = args.EpisodeInfo; + var destFolder = series.Restricted switch + { + true => args.AvailableFolders.FirstOrDefault(a => + a.Path.Contains("Hentai", StringComparison.InvariantCultureIgnoreCase) && + ValidDestinationFolder(a)) ?? args.AvailableFolders.FirstOrDefault(ValidDestinationFolder), + false => args.AvailableFolders.FirstOrDefault(a => + !a.Path.Contains("Hentai", StringComparison.InvariantCultureIgnoreCase) && + ValidDestinationFolder(a)) + }; + + return (destFolder, path); + } + + private static bool ValidDestinationFolder(IImportFolder dest) + { + return dest.DropFolderType.HasFlag(DropFolderType.Destination); + } + + private (IImportFolder dest, string folder) GetFlatFolderDestination(RelocationEventArgs args) + { + // TODO make this only dependent on PluginAbstractions + var destFolder = _relocationService.GetFirstDestinationWithSpace(args); + + var xrefs = args.Episodes; if (xrefs.Count == 0) { return (null, "No xrefs"); } - var xref = xrefs.FirstOrDefault(a => a != null); + var xref = xrefs.FirstOrDefault(); if (xref == null) { return (null, "No xrefs"); } // find the series associated with this episode - var series = RepoFactory.AnimeSeries.GetByAnimeID(xref.SeriesID); - if (series == null) + if (xref.Series is not SVR_AnimeSeries series) { return (null, "Series not Found"); } + // TODO move this into the RelocationService // sort the episodes by air date, so that we will move the file to the location of the latest episode var allEps = series.AllAnimeEpisodes - .OrderByDescending(a => a.AniDB_Episode.AirDate) + .OrderByDescending(a => a.AniDB_Episode?.AirDate ?? 0) .ToList(); foreach (var ep in allEps) { // check if this episode belongs to more than one anime - // if it does we will ignore it + // if it does, we will ignore it var fileEpXrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(ep.AniDB_EpisodeID); int? animeID = null; var crossOver = false; foreach (var fileEpXref in fileEpXrefs) { - if (!animeID.HasValue) - { - animeID = fileEpXref.AnimeID; - } - else - { - if (animeID.Value != fileEpXref.AnimeID) - { - crossOver = true; - } - } + if (!animeID.HasValue) animeID = fileEpXref.AnimeID; + else if (animeID.Value != fileEpXref.AnimeID) crossOver = true; } - if (crossOver) - { - continue; - } + if (crossOver) continue; - foreach (var vid in ep.VideoLocals - .Where(a => a.Places.Any(b => b.ImportFolder.IsDropSource == 0)).ToList()) + var settings = _settingsProvider.GetSettings(); + foreach (var vid in ep.VideoLocals.Where(a => a.Places.Any(b => b.ImportFolder.IsDropSource == 0)).ToList()) { - if (vid.Hash == args.VideoInfo.Hashes.ED2K) - { - continue; - } + if (vid.Hash == args.File.Video.Hashes.ED2K) continue; var place = vid.Places.FirstOrDefault(); var thisFileName = place?.FilePath; - if (thisFileName == null) - { - continue; - } + if (thisFileName == null) continue; var folderName = Path.GetDirectoryName(thisFileName); var dstImportFolder = place.ImportFolder; - if (dstImportFolder == null) - { - continue; - } + if (dstImportFolder == null) continue; // check space - if (!args.FileInfo.Path.StartsWith(Path.GetPathRoot(dstImportFolder.ImportFolderLocation)) && - !settings.Import.SkipDiskSpaceChecks) - { - var available = 0L; - try - { - available = new DriveInfo(dstImportFolder.ImportFolderLocation).AvailableFreeSpace; - } - catch (Exception e) - { - logger.Error(e); - } - - if (available < vid.FileSize) - { - continue; - } - } - - if (!Directory.Exists(Path.Combine(place.ImportFolder.ImportFolderLocation, folderName))) - { + if (!settings.Import.SkipDiskSpaceChecks && !_relocationService.ImportFolderHasSpace(dstImportFolder, args.File)) continue; - } + + if (!Directory.Exists(Path.Combine(place.ImportFolder.ImportFolderLocation, folderName!))) continue; // ensure we aren't moving to the current directory - if (Path.Combine(place.ImportFolder.ImportFolderLocation, folderName).Equals( - Path.GetDirectoryName(args.FileInfo.Path), StringComparison.InvariantCultureIgnoreCase)) - { + if (Path.Combine(place.ImportFolder.ImportFolderLocation, folderName).Equals(Path.GetDirectoryName(args.File.Path), StringComparison.InvariantCultureIgnoreCase)) continue; - } destFolder = place.ImportFolder; @@ -2332,6 +2256,52 @@ private static bool EvaluateTest(char testChar, string testCondition, SVR_VideoL return (null, "Unable to resolve a destination"); } - return (destFolder, Utils.ReplaceInvalidFolderNameCharacters(series.SeriesName)); + return (destFolder, Utils.ReplaceInvalidFolderNameCharacters(series.PreferredTitle)); } + + public WebAOMSettings DefaultSettings => new() + { + GroupAwareSorting = false, + Script = """ +// Sample Output: [Coalgirls]_Highschool_of_the_Dead_-_01_(1920x1080_Blu-ray_H264)_[90CC6DC1].mkv +// Sub group name +DO ADD '[%grp] ' +// Anime Name, use english name if it exists, otherwise use the Romaji name +IF I(eng) DO ADD '%eng ' +IF I(ann);I(!eng) DO ADD '%ann ' +// Episode Number, don't use episode number for movies +IF T(!Movie) DO ADD '- %enr' +// If the file version is v2 or higher add it here +IF F(!1) DO ADD 'v%ver' +// Video Resolution +DO ADD ' (%res' +// Video Source (only if blu-ray or DVD) +IF R(DVD),R(Blu-ray) DO ADD ' %src' +// Video Codec +DO ADD ' %vid' +// Video Bit Depth (only if 10bit) +IF Z(10) DO ADD ' %bitbit' +DO ADD ') ' +DO ADD '[%CRC]' + +// Replacement rules (cleanup) +DO REPLACE ' ' '_' // replace spaces with underscores +DO REPLACE 'H264/AVC' 'H264' +DO REPLACE '0x0' '' +DO REPLACE '__' '_' +DO REPLACE '__' '_' + +// Replace all illegal file name characters +DO REPLACE '<' '(' +DO REPLACE '>' ')' +DO REPLACE ':' '-' +DO REPLACE '" + (char)34 +' '`' +DO REPLACE '/' '_' +DO REPLACE '/' '_' +DO REPLACE '\\' '_' +DO REPLACE '|' '_' +DO REPLACE '?' '_' +DO REPLACE '*' '_' +""" + }; } diff --git a/Shoko.Server/Renamer/WebAOMSettings.cs b/Shoko.Server/Renamer/WebAOMSettings.cs new file mode 100644 index 000000000..bb76c605b --- /dev/null +++ b/Shoko.Server/Renamer/WebAOMSettings.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using Shoko.Plugin.Abstractions.Attributes; +using Shoko.Plugin.Abstractions.Enums; + +namespace Shoko.Server.Renamer; + +public class WebAOMSettings +{ + /// <summary> + /// The maximum length to truncate episode names to. + /// </summary> + [RenamerSetting(Name = "Max Episode Length", Description = "The maximum length to truncate episode names to.")] + [Range(1, 250)] + public int MaxEpisodeLength { get; set; } = 33; + + /// <summary> + /// Whether to place files in a folder structure based on the Shoko group structure. + /// </summary> + [RenamerSetting(Name = "Group Aware Sorting", Description = "Whether to place files in a folder structure based on the Shoko group structure.")] + public bool GroupAwareSorting { get; set; } + + /// <summary> + /// WebAOM Script. + /// </summary> + [RenamerSetting(Type = RenamerSettingType.Code, Language = CodeLanguage.PlainText, Description = "WebAOM Script.")] + public string Script { get; set; } +} diff --git a/Shoko.Server/Repositories/Cached/AniDB_AnimeRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_AnimeRepository.cs index 482dd90cf..2c534a8b0 100644 --- a/Shoko.Server/Repositories/Cached/AniDB_AnimeRepository.cs +++ b/Shoko.Server/Repositories/Cached/AniDB_AnimeRepository.cs @@ -6,10 +6,14 @@ using Shoko.Models.Enums; using Shoko.Models.Interfaces; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Databases; using Shoko.Server.Extensions; using Shoko.Server.Models; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Models.TMDB; using Shoko.Server.Repositories.NHibernate; +using Shoko.Server.Server; namespace Shoko.Server.Repositories.Cached; @@ -29,27 +33,11 @@ public override void PopulateIndexes() Animes = new PocoIndex<int, SVR_AniDB_Anime, int>(Cache, a => a.AnimeID); } - public override void RegenerateDb() { } - - public override void Save(SVR_AniDB_Anime obj) - { - Save(obj, true); - } - - public void Save(SVR_AniDB_Anime obj, bool generateTvDBMatches) + public override void RegenerateDb() { - if (obj.AniDB_AnimeID == 0) + foreach (var anime in Cache.Values.ToList()) { - base.Save(obj); - } - - // populate the database - base.Save(obj); - - if (generateTvDBMatches) - { - // Update TvDB Linking. Doing it here as updating anime updates episode info in batch - lock (obj) TvDBLinkingHelper.GenerateTvDBEpisodeMatches(obj.AnimeID); + anime.ResetPreferredTitle(); } } @@ -79,100 +67,69 @@ public List<SVR_AniDB_Anime> SearchByName(string queryText) public Dictionary<int, DefaultAnimeImages> GetDefaultImagesByAnime(ISessionWrapper session, int[] animeIds) { - if (session == null) - { - throw new ArgumentNullException("session"); - } - - if (animeIds == null) - { - throw new ArgumentNullException("animeIds"); - } - + ArgumentNullException.ThrowIfNull(session, nameof(session)); + ArgumentNullException.ThrowIfNull(animeIds, nameof(animeIds)); var defImagesByAnime = new Dictionary<int, DefaultAnimeImages>(); - if (animeIds.Length == 0) - { + if (animeIds is { Length: 0 }) return defImagesByAnime; - } // treating cache as a global DB lock, as well - var results = Lock(() => session.CreateSQLQuery( - @"SELECT {defImg.*}, {tvWide.*}, {tvPoster.*}, {tvFanart.*}, {movPoster.*}, {movFanart.*} - FROM AniDB_Anime_DefaultImage defImg - LEFT OUTER JOIN TvDB_ImageWideBanner AS tvWide - ON tvWide.TvDB_ImageWideBannerID = defImg.ImageParentID AND defImg.ImageParentType = :tvdbBannerType - LEFT OUTER JOIN TvDB_ImagePoster AS tvPoster - ON tvPoster.TvDB_ImagePosterID = defImg.ImageParentID AND defImg.ImageParentType = :tvdbCoverType - LEFT OUTER JOIN TvDB_ImageFanart AS tvFanart - ON tvFanart.TvDB_ImageFanartID = defImg.ImageParentID AND defImg.ImageParentType = :tvdbFanartType - LEFT OUTER JOIN MovieDB_Poster AS movPoster - ON movPoster.MovieDB_PosterID = defImg.ImageParentID AND defImg.ImageParentType = :movdbPosterType - LEFT OUTER JOIN MovieDB_Fanart AS movFanart - ON movFanart.MovieDB_FanartID = defImg.ImageParentID AND defImg.ImageParentType = :movdbFanartType - WHERE defImg.AnimeID IN (:animeIds) AND defImg.ImageParentType IN (:tvdbBannerType, :tvdbCoverType, :tvdbFanartType, :movdbPosterType, :movdbFanartType)" - ) - .AddEntity("defImg", typeof(AniDB_Anime_DefaultImage)) - .AddEntity("tvWide", typeof(TvDB_ImageWideBanner)) - .AddEntity("tvPoster", typeof(TvDB_ImagePoster)) - .AddEntity("tvFanart", typeof(TvDB_ImageFanart)) - .AddEntity("movPoster", typeof(MovieDB_Poster)) - .AddEntity("movFanart", typeof(MovieDB_Fanart)) - .SetParameterList("animeIds", animeIds) - .SetInt32("tvdbBannerType", (int)ImageEntityType.TvDB_Banner) - .SetInt32("tvdbCoverType", (int)ImageEntityType.TvDB_Cover) - .SetInt32("tvdbFanartType", (int)ImageEntityType.TvDB_FanArt) - .SetInt32("movdbPosterType", (int)ImageEntityType.MovieDB_Poster) - .SetInt32("movdbFanartType", (int)ImageEntityType.MovieDB_FanArt) - .List<object[]>()); + var results = Lock(() => + { + // TODO: Determine if joining on the correct columns + return session.CreateSQLQuery( + @"SELECT {prefImg.*}, {tmdbPoster.*}, {tmdbBackdrop.*} + FROM AniDB_Anime_PreferredImage prefImg + LEFT OUTER JOIN TMDB_Image AS tmdbPoster + ON tmdbPoster.ImageType = :imagePosterType AND tmdbPoster.TMDB_ImageID = prefImg.ImageID AND prefImg.ImageSource = :tmdbSourceType AND prefImg.ImageParentType = :imagePosterType + LEFT OUTER JOIN TMDB_Image AS tmdbBackdrop + ON tmdbBackdrop.ImageType = :imageBackdropType AND tmdbBackdrop.TMDB_ImageID = prefImg.ImageID AND prefImg.ImageSource = :tmdbSourceType AND prefImg.ImageType = :imageBackdropType + WHERE prefImg.AnimeID IN (:animeIds) AND prefImg.ImageType IN (:imagePosterType, :imageBackdropType)" + ) + .AddEntity("prefImg", typeof(AniDB_Anime_PreferredImage)) + .AddEntity("tmdbPoster", typeof(TMDB_Image)) + .AddEntity("tmdbBackdrop", typeof(TMDB_Image)) + .SetParameterList("animeIds", animeIds) + .SetInt32("tmdbSourceType", (int)DataSourceType.TMDB) + .SetInt32("imageBackdropType", (int)ImageEntityType.Backdrop) + .SetInt32("imagePosterType", (int)ImageEntityType.Poster) + .List<object[]>(); + }); foreach (var result in results) { - var aniDbDefImage = (AniDB_Anime_DefaultImage)result[0]; - IImageEntity parentImage = null; - - switch ((ImageEntityType)aniDbDefImage.ImageParentType) + var preferredImage = (AniDB_Anime_PreferredImage)result[0]; + IImageEntity image = null; + switch (preferredImage.ImageType.ToClient(preferredImage.ImageSource)) { - case ImageEntityType.TvDB_Banner: - parentImage = (IImageEntity)result[1]; - break; - case ImageEntityType.TvDB_Cover: - parentImage = (IImageEntity)result[2]; - break; - case ImageEntityType.TvDB_FanArt: - parentImage = (IImageEntity)result[3]; - break; - case ImageEntityType.MovieDB_Poster: - parentImage = (IImageEntity)result[4]; + case CL_ImageEntityType.MovieDB_Poster: + image = ((TMDB_Image)result[1]).ToClientPoster(); break; - case ImageEntityType.MovieDB_FanArt: - parentImage = (IImageEntity)result[5]; + case CL_ImageEntityType.MovieDB_FanArt: + image = ((TMDB_Image)result[2]).ToClientFanart(); break; } - if (parentImage == null) - { + if (image == null) continue; - } - - var defImage = new DefaultAnimeImage(aniDbDefImage, parentImage); - if (!defImagesByAnime.TryGetValue(aniDbDefImage.AnimeID, out var defImages)) + if (!defImagesByAnime.TryGetValue(preferredImage.AnidbAnimeID, out var defImages)) { - defImages = new DefaultAnimeImages { AnimeID = aniDbDefImage.AnimeID }; + defImages = new DefaultAnimeImages { AnimeID = preferredImage.AnidbAnimeID }; defImagesByAnime.Add(defImages.AnimeID, defImages); } - switch (defImage.AniDBImageSizeType) + switch (preferredImage.ImageType) { - case ImageSizeType.Poster: - defImages.Poster = defImage; + case ImageEntityType.Poster: + defImages.Poster = preferredImage.ToClient(image); break; - case ImageSizeType.WideBanner: - defImages.WideBanner = defImage; + case ImageEntityType.Banner: + defImages.Banner = preferredImage.ToClient(image); break; - case ImageSizeType.Fanart: - defImages.Fanart = defImage; + case ImageEntityType.Backdrop: + defImages.Backdrop = preferredImage.ToClient(image); break; } } @@ -187,22 +144,19 @@ public CL_AniDB_Anime_DefaultImage GetPosterContractNoBlanks() { if (Poster != null) { - return Poster.ToContract(); + return Poster; } - return new CL_AniDB_Anime_DefaultImage { AnimeID = AnimeID, ImageType = (int)ImageEntityType.AniDB_Cover }; + return new() { AnimeID = AnimeID, ImageType = (int)CL_ImageEntityType.AniDB_Cover }; } public CL_AniDB_Anime_DefaultImage GetFanartContractNoBlanks(CL_AniDB_Anime anime) { - if (anime == null) - { - throw new ArgumentNullException(nameof(anime)); - } + ArgumentNullException.ThrowIfNull(anime, nameof(anime)); - if (Fanart != null) + if (Backdrop != null) { - return Fanart.ToContract(); + return Backdrop; } var fanarts = anime.Fanarts; @@ -224,37 +178,9 @@ public CL_AniDB_Anime_DefaultImage GetFanartContractNoBlanks(CL_AniDB_Anime anim public int AnimeID { get; set; } - public DefaultAnimeImage Poster { get; set; } - - public DefaultAnimeImage Fanart { get; set; } - - public DefaultAnimeImage WideBanner { get; set; } -} - -public class DefaultAnimeImage -{ - private readonly IImageEntity _parentImage; - - public DefaultAnimeImage(AniDB_Anime_DefaultImage aniDbImage, IImageEntity parentImage) - { - AniDBImage = aniDbImage ?? throw new ArgumentNullException(nameof(aniDbImage)); - _parentImage = parentImage ?? throw new ArgumentNullException(nameof(parentImage)); - } - - public CL_AniDB_Anime_DefaultImage ToContract() - { - return AniDBImage.ToClient(_parentImage); - } - - public TImageType GetParentImage<TImageType>() - where TImageType : class, IImageEntity - { - return _parentImage as TImageType; - } - - public ImageSizeType AniDBImageSizeType => (ImageSizeType)AniDBImage.ImageType; + public CL_AniDB_Anime_DefaultImage Poster { get; set; } - public AniDB_Anime_DefaultImage AniDBImage { get; private set; } + public CL_AniDB_Anime_DefaultImage Backdrop { get; set; } - public ImageEntityType ParentImageType => (ImageEntityType)AniDBImage.ImageParentType; + public CL_AniDB_Anime_DefaultImage Banner { get; set; } } diff --git a/Shoko.Server/Repositories/Cached/AniDB_Anime_DefaultImageRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_Anime_DefaultImageRepository.cs deleted file mode 100644 index b55e9ed9f..000000000 --- a/Shoko.Server/Repositories/Cached/AniDB_Anime_DefaultImageRepository.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NutzCode.InMemoryIndex; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; - -namespace Shoko.Server.Repositories.Cached; - -public class AniDB_Anime_DefaultImageRepository : BaseCachedRepository<AniDB_Anime_DefaultImage, int> -{ - private PocoIndex<int, AniDB_Anime_DefaultImage, int> Animes; - - public AniDB_Anime_DefaultImage GetByAnimeIDAndImagezSizeType(int animeid, ImageSizeType imageType) - { - return GetByAnimeID(animeid).FirstOrDefault(a => a.ImageType == (int)imageType); - } - - public AniDB_Anime_DefaultImage GetByAnimeIDAndImagezSizeTypeAndImageEntityType(int animeid, - ImageSizeType imageType, ImageEntityType entityType) - { - var defaultImage = GetByAnimeIDAndImagezSizeType(animeid, imageType); - return defaultImage != null && defaultImage.ImageParentType == (int)entityType ? defaultImage : null; - } - - public AniDB_Anime_DefaultImage GetByAnimeIDAndImageEntityType(int animeid, ImageEntityType entityType) - { - return GetByAnimeID(animeid).FirstOrDefault(a => a.ImageParentType == (int)entityType); - } - - public List<AniDB_Anime_DefaultImage> GetByAnimeID(int id) - { - return ReadLock(() => Animes.GetMultiple(id)); - } - - protected override int SelectKey(AniDB_Anime_DefaultImage entity) - { - return entity.AniDB_Anime_DefaultImageID; - } - - public override void PopulateIndexes() - { - Animes = new PocoIndex<int, AniDB_Anime_DefaultImage, int>(Cache, a => a.AnimeID); - } - - public override void RegenerateDb() - { - } - - public AniDB_Anime_DefaultImageRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/AniDB_Anime_PreferredImageRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_Anime_PreferredImageRepository.cs new file mode 100644 index 000000000..92bc8c6a5 --- /dev/null +++ b/Shoko.Server/Repositories/Cached/AniDB_Anime_PreferredImageRepository.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Databases; +using Shoko.Server.Models.AniDB; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class AniDB_Anime_PreferredImageRepository : BaseCachedRepository<AniDB_Anime_PreferredImage, int> +{ + private PocoIndex<int, AniDB_Anime_PreferredImage, int>? AnimeIDs; + + public AniDB_Anime_PreferredImage? GetByAnidbAnimeIDAndType(int animeId, ImageEntityType imageType) + => GetByAnimeID(animeId).FirstOrDefault(a => a.ImageType == imageType); + + public AniDB_Anime_PreferredImage? GetByAnidbAnimeIDAndTypeAndSource(int animeId, ImageEntityType imageType, DataSourceType imageSource) + => GetByAnimeID(animeId).FirstOrDefault(a => a.ImageType == imageType && a.ImageSource == imageSource); + + public List<AniDB_Anime_PreferredImage> GetByAnimeID(int animeId) + => ReadLock(() => AnimeIDs!.GetMultiple(animeId)); + + protected override int SelectKey(AniDB_Anime_PreferredImage entity) + => entity.AniDB_Anime_PreferredImageID; + + public override void PopulateIndexes() + { + AnimeIDs = new(Cache, a => a.AnidbAnimeID); + } + + public override void RegenerateDb() + { + } + + public AniDB_Anime_PreferredImageRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { } +} diff --git a/Shoko.Server/Repositories/Cached/AniDB_CharacterRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_CharacterRepository.cs index 005e6e686..a934bd0a4 100644 --- a/Shoko.Server/Repositories/Cached/AniDB_CharacterRepository.cs +++ b/Shoko.Server/Repositories/Cached/AniDB_CharacterRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using NutzCode.InMemoryIndex; +using Shoko.Commons.Extensions; using Shoko.Models.Server; using Shoko.Server.Databases; @@ -17,7 +18,7 @@ public AniDB_Character GetByCharID(int id) public List<AniDB_Character> GetCharactersForAnime(int animeID) { - return ReadLock(() => RepoFactory.AniDB_Anime_Character.GetByAnimeID(animeID).Select(a => GetByCharID(a.CharID)).ToList()); + return ReadLock(() => RepoFactory.AniDB_Anime_Character.GetByAnimeID(animeID).Select(a => GetByCharID(a.CharID)).WhereNotNull().ToList()); } public AniDB_CharacterRepository(DatabaseFactory databaseFactory) : base(databaseFactory) diff --git a/Shoko.Server/Repositories/Cached/AniDB_Character_CreatorRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_Character_CreatorRepository.cs new file mode 100644 index 000000000..e289da372 --- /dev/null +++ b/Shoko.Server/Repositories/Cached/AniDB_Character_CreatorRepository.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Server.Databases; +using Shoko.Server.Models.AniDB; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class AniDB_Character_CreatorRepository : BaseCachedRepository<AniDB_Character_Creator, int> +{ + private PocoIndex<int, AniDB_Character_Creator, int>? _charIDs; + + public List<AniDB_Character_Creator> GetByCharacterID(int characterID) + { + return ReadLock(() => _charIDs!.GetMultiple(characterID)); + } + + public List<AniDB_Character_Creator> GetByCreatorID(int creatorID) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session.Query<AniDB_Character_Creator>() + .Where(a => a.CreatorID == creatorID) + .ToList(); + }); + } + + public override void PopulateIndexes() + { + _charIDs = new PocoIndex<int, AniDB_Character_Creator, int>(Cache, a => a.CharacterID); + } + + public override void RegenerateDb() + { + } + + protected override int SelectKey(AniDB_Character_Creator entity) + { + return entity.AniDB_Character_CreatorID; + } + + public AniDB_Character_CreatorRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Cached/AniDB_Character_SeiyuuRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_Character_SeiyuuRepository.cs deleted file mode 100644 index aea9d679d..000000000 --- a/Shoko.Server/Repositories/Cached/AniDB_Character_SeiyuuRepository.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NutzCode.InMemoryIndex; -using Shoko.Models.Server; -using Shoko.Server.Databases; - -namespace Shoko.Server.Repositories.Cached; - -public class AniDB_Character_SeiyuuRepository : BaseCachedRepository<AniDB_Character_Seiyuu, int> -{ - private PocoIndex<int, AniDB_Character_Seiyuu, int> _charIDs; - - public List<AniDB_Character_Seiyuu> GetByCharID(int id) - { - return ReadLock(() => _charIDs.GetMultiple(id)); - } - - public List<AniDB_Character_Seiyuu> GetBySeiyuuID(int id) - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return session.Query<AniDB_Character_Seiyuu>() - .Where(a => a.SeiyuuID == id) - .ToList(); - }); - } - - public override void PopulateIndexes() - { - _charIDs = new PocoIndex<int, AniDB_Character_Seiyuu, int>(Cache, a => a.CharID); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(AniDB_Character_Seiyuu entity) - { - return entity.AniDB_Character_SeiyuuID; - } - - public AniDB_Character_SeiyuuRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/AniDB_CreatorRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_CreatorRepository.cs new file mode 100644 index 000000000..6bcdc70bc --- /dev/null +++ b/Shoko.Server/Repositories/Cached/AniDB_CreatorRepository.cs @@ -0,0 +1,47 @@ +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Server.Models.AniDB; +using Shoko.Server.Databases; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class AniDB_CreatorRepository : BaseCachedRepository<AniDB_Creator, int> +{ + private PocoIndex<int, AniDB_Creator, int>? _seiyuuIDs; + + public AniDB_Creator? GetByCreatorID(int id) + { + return ReadLock(() => _seiyuuIDs!.GetOne(id)); + } + + public AniDB_Creator? GetByName(string creatorName) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session.Query<AniDB_Creator>() + .Where(a => a.Name == creatorName) + .Take(1) + .SingleOrDefault(); + }); + } + + public override void PopulateIndexes() + { + _seiyuuIDs = new PocoIndex<int, AniDB_Creator, int>(Cache, a => a.CreatorID); + } + + public override void RegenerateDb() + { + } + + protected override int SelectKey(AniDB_Creator entity) + { + return entity.AniDB_CreatorID; + } + + public AniDB_CreatorRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Cached/AniDB_EpisodeRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_EpisodeRepository.cs index 1e3ebe1eb..a451f9411 100644 --- a/Shoko.Server/Repositories/Cached/AniDB_EpisodeRepository.cs +++ b/Shoko.Server/Repositories/Cached/AniDB_EpisodeRepository.cs @@ -51,14 +51,14 @@ public List<SVR_AniDB_Episode> GetForDate(DateTime startDate, DateTime endDate) public List<SVR_AniDB_Episode> GetByAnimeIDAndEpisodeNumber(int animeid, int epnumber) { return GetByAnimeID(animeid) - .Where(a => a.EpisodeNumber == epnumber && a.GetEpisodeTypeEnum() == EpisodeType.Episode) + .Where(a => a.EpisodeNumber == epnumber && a.EpisodeTypeEnum == EpisodeType.Episode) .ToList(); } public List<SVR_AniDB_Episode> GetByAnimeIDAndEpisodeTypeNumber(int animeid, EpisodeType epType, int epnumber) { return GetByAnimeID(animeid) - .Where(a => a.EpisodeNumber == epnumber && a.GetEpisodeTypeEnum() == epType) + .Where(a => a.EpisodeNumber == epnumber && a.EpisodeTypeEnum == epType) .ToList(); } diff --git a/Shoko.Server/Repositories/Cached/AniDB_Episode_PreferredImageRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_Episode_PreferredImageRepository.cs new file mode 100644 index 000000000..546e19ac4 --- /dev/null +++ b/Shoko.Server/Repositories/Cached/AniDB_Episode_PreferredImageRepository.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Databases; +using Shoko.Server.Models.AniDB; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class AniDB_Episode_PreferredImageRepository : BaseCachedRepository<AniDB_Episode_PreferredImage, int> +{ + private PocoIndex<int, AniDB_Episode_PreferredImage, int>? _episodeIDs; + + public AniDB_Episode_PreferredImage? GetByAnidbEpisodeIDAndType(int episodeId, ImageEntityType imageType) + => GetByEpisodeID(episodeId).FirstOrDefault(a => a.ImageType == imageType); + + public AniDB_Episode_PreferredImage? GetByAnidbEpisodeIDAndTypeAndSource(int episodeId, ImageEntityType imageType, DataSourceType imageSource) + => GetByEpisodeID(episodeId).FirstOrDefault(a => a.ImageType == imageType && a.ImageSource == imageSource); + + public List<AniDB_Episode_PreferredImage> GetByEpisodeID(int episodeId) + => ReadLock(() => _episodeIDs!.GetMultiple(episodeId)); + + protected override int SelectKey(AniDB_Episode_PreferredImage entity) + => entity.AniDB_Episode_PreferredImageID; + + public override void PopulateIndexes() + { + _episodeIDs = new(Cache, a => a.AnidbEpisodeID); + } + + public override void RegenerateDb() + { + } + + public AniDB_Episode_PreferredImageRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { } +} diff --git a/Shoko.Server/Repositories/Cached/AniDB_FileRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_FileRepository.cs index d4d6b9f95..324e8c677 100644 --- a/Shoko.Server/Repositories/Cached/AniDB_FileRepository.cs +++ b/Shoko.Server/Repositories/Cached/AniDB_FileRepository.cs @@ -50,7 +50,7 @@ public void Save(SVR_AniDB_File obj, bool updateStats) } Logger.Trace("Updating group stats by file from AniDB_FileRepository.Save: {Hash}", obj.Hash); - var anime = RepoFactory.CrossRef_File_Episode.GetByHash(obj.Hash).Select(a => a.AnimeID).Distinct(); + var anime = RepoFactory.CrossRef_File_Episode.GetByHash(obj.Hash).Select(a => a.AnimeID).Except([0]).Distinct(); Task.WhenAll(anime.Select(a => _jobFactory.CreateJob<RefreshAnimeStatsJob>(b => b.AnimeID = a).Process())).GetAwaiter().GetResult(); } diff --git a/Shoko.Server/Repositories/Cached/AniDB_ReleaseGroupRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_ReleaseGroupRepository.cs index bdf3a5646..5e083c096 100644 --- a/Shoko.Server/Repositories/Cached/AniDB_ReleaseGroupRepository.cs +++ b/Shoko.Server/Repositories/Cached/AniDB_ReleaseGroupRepository.cs @@ -5,16 +5,19 @@ using Shoko.Models.Server; using Shoko.Server.Databases; using Shoko.Server.Models; +using Shoko.Server.Models.AniDB; +#pragma warning disable CS8618 +#nullable enable namespace Shoko.Server.Repositories.Cached; public class AniDB_ReleaseGroupRepository : BaseCachedRepository<AniDB_ReleaseGroup, int> { - private PocoIndex<int, AniDB_ReleaseGroup, int> GroupIDs; + private PocoIndex<int, AniDB_ReleaseGroup, int> _groupIDs; - public AniDB_ReleaseGroup GetByGroupID(int id) + public AniDB_ReleaseGroup? GetByGroupID(int id) { - return ReadLock(() => GroupIDs.GetOne(id)); + return ReadLock(() => _groupIDs.GetOne(id)); } public IReadOnlyList<AniDB_ReleaseGroup> GetUsedReleaseGroups() @@ -41,7 +44,7 @@ public IReadOnlyList<AniDB_ReleaseGroup> GetUnusedReleaseGroups() public override void PopulateIndexes() { - GroupIDs = Cache.CreateIndex(a => a.GroupID); + _groupIDs = Cache.CreateIndex(a => a.GroupID); } public override void RegenerateDb() diff --git a/Shoko.Server/Repositories/Cached/AniDB_SeiyuuRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_SeiyuuRepository.cs deleted file mode 100644 index 47734abc6..000000000 --- a/Shoko.Server/Repositories/Cached/AniDB_SeiyuuRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -using NutzCode.InMemoryIndex; -using Shoko.Models.Server; -using Shoko.Server.Databases; - -namespace Shoko.Server.Repositories.Cached; - -public class AniDB_SeiyuuRepository : BaseCachedRepository<AniDB_Seiyuu, int> -{ - private PocoIndex<int, AniDB_Seiyuu, int> _seiyuuIDs; - - public AniDB_Seiyuu GetBySeiyuuID(int id) - { - return ReadLock(() => _seiyuuIDs.GetOne(id)); - } - - public override void PopulateIndexes() - { - _seiyuuIDs = new PocoIndex<int, AniDB_Seiyuu, int>(Cache, a => a.SeiyuuID); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(AniDB_Seiyuu entity) - { - return entity.SeiyuuID; - } - - public AniDB_SeiyuuRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/AniDB_VoteRepository.cs b/Shoko.Server/Repositories/Cached/AniDB_VoteRepository.cs index 75d06deb7..26df4180c 100644 --- a/Shoko.Server/Repositories/Cached/AniDB_VoteRepository.cs +++ b/Shoko.Server/Repositories/Cached/AniDB_VoteRepository.cs @@ -26,7 +26,7 @@ public AniDB_VoteRepository(DatabaseFactory databaseFactory, JobFactory jobFacto jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = cr.EntityID).Process().GetAwaiter().GetResult(); break; case (int)AniDBVoteType.Episode: - var ep = RepoFactory.AnimeEpisode.GetByID(cr.EntityID); + var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(cr.EntityID); RepoFactory.AnimeEpisode.Save(ep); break; } @@ -40,7 +40,7 @@ public AniDB_VoteRepository(DatabaseFactory databaseFactory, JobFactory jobFacto jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = cr.EntityID).Process().GetAwaiter().GetResult(); break; case (int)AniDBVoteType.Episode: - var ep = RepoFactory.AnimeEpisode.GetByID(cr.EntityID); + var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(cr.EntityID); RepoFactory.AnimeEpisode.Save(ep); break; } diff --git a/Shoko.Server/Repositories/Cached/AnimeEpisodeRepository.cs b/Shoko.Server/Repositories/Cached/AnimeEpisodeRepository.cs index fbc50c4f9..dee692e3c 100644 --- a/Shoko.Server/Repositories/Cached/AnimeEpisodeRepository.cs +++ b/Shoko.Server/Repositories/Cached/AnimeEpisodeRepository.cs @@ -84,6 +84,7 @@ public SVR_AnimeEpisode GetByFilename(string name) /// <returns></returns> public List<SVR_AnimeEpisode> GetByHash(string hash) { + if (string.IsNullOrEmpty(hash)) return []; return RepoFactory.CrossRef_File_Episode.GetByHash(hash) .Select(a => GetByAniDBEpisodeID(a.EpisodeID)) .Where(a => a != null) @@ -121,7 +122,12 @@ public List<SVR_AnimeEpisode> GetWithMultipleReleases(bool ignoreVariations, int return ids .Select(GetByAniDBEpisodeID) - .Where(a => a != null) + .Select(episode => (episode, anidbEpisode: episode?.AniDB_Episode)) + .Where(tuple => tuple.anidbEpisode is not null) + .OrderBy(tuple => tuple.anidbEpisode!.AnimeID) + .ThenBy(tuple => tuple.anidbEpisode!.EpisodeTypeEnum) + .ThenBy(tuple => tuple.anidbEpisode!.EpisodeNumber) + .Select(tuple => tuple.episode!) .ToList(); } @@ -148,7 +154,7 @@ public List<SVR_AnimeEpisode> GetAllWatchedEpisodes(int userid, DateTime? after_ return list; } - public List<SVR_AnimeEpisode> GetEpisodesWithNoFiles(bool includeSpecials) + public List<SVR_AnimeEpisode> GetEpisodesWithNoFiles(bool includeSpecials, bool includeOnlyAired = false) { var all = GetAll().Where(a => { @@ -170,13 +176,18 @@ public List<SVR_AnimeEpisode> GetEpisodesWithNoFiles(bool includeSpecials) return false; } + if (includeOnlyAired && !aniep.HasAired) + { + return false; + } + return a.VideoLocals.Count == 0; }) .ToList(); all.Sort((a1, a2) => { - var name1 = a1.AnimeSeries?.SeriesName; - var name2 = a2.AnimeSeries?.SeriesName; + var name1 = a1.AnimeSeries?.PreferredTitle; + var name2 = a2.AnimeSeries?.PreferredTitle; if (!string.IsNullOrEmpty(name1) && !string.IsNullOrEmpty(name2)) { diff --git a/Shoko.Server/Repositories/Cached/AnimeSeriesRepository.cs b/Shoko.Server/Repositories/Cached/AnimeSeriesRepository.cs index fc4a850db..cebc9ed50 100644 --- a/Shoko.Server/Repositories/Cached/AnimeSeriesRepository.cs +++ b/Shoko.Server/Repositories/Cached/AnimeSeriesRepository.cs @@ -11,6 +11,7 @@ using Shoko.Commons.Properties; using Shoko.Models.Enums; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Databases; using Shoko.Server.Models; using Shoko.Server.Repositories.NHibernate; @@ -18,16 +19,18 @@ using Shoko.Server.Tasks; using Shoko.Server.Utilities; +#pragma warning disable CA1822 +#nullable enable namespace Shoko.Server.Repositories.Cached; public class AnimeSeriesRepository : BaseCachedRepository<SVR_AnimeSeries, int> { - private static Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - private PocoIndex<int, SVR_AnimeSeries, int> AniDBIds; - private PocoIndex<int, SVR_AnimeSeries, int> Groups; + private PocoIndex<int, SVR_AnimeSeries, int>? AniDBIds; + private PocoIndex<int, SVR_AnimeSeries, int>? Groups; - private ChangeTracker<int> Changes = new(); + private readonly ChangeTracker<int> Changes = new(); public AnimeSeriesRepository(DatabaseFactory databaseFactory) : base(databaseFactory) { @@ -69,9 +72,17 @@ public override void RegenerateDb() { try { + ServerState.Instance.ServerStartingStatus = string.Format(Resources.Database_Validating, nameof(AnimeSeries), " Database Regeneration - Caching Titles & Overview"); + foreach (var series in Cache.Values.ToList()) + { + series.ResetPreferredTitle(); + series.ResetPreferredOverview(); + series.ResetAnimeTitles(); + } + var sers = Cache.Values.Where(a => a.AnimeGroupID == 0 || RepoFactory.AnimeGroup.GetByID(a.AnimeGroupID) == null).ToList(); var max = sers.Count; - ServerState.Instance.ServerStartingStatus = string.Format(Resources.Database_Validating, nameof(AnimeSeries), " DbRegen - Ensuring Groups Exist"); + ServerState.Instance.ServerStartingStatus = string.Format(Resources.Database_Validating, nameof(AnimeSeries), " Database Regeneration - Ensuring Groups Exist"); var groupCreator = Utils.ServiceContainer.GetRequiredService<AnimeGroupCreator>(); for (var i = 0; i < max; i++) @@ -125,7 +136,7 @@ public void Save(SVR_AnimeSeries obj, bool updateGroups, bool onlyupdatestats, b var totalSw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew(); var newSeries = false; - SVR_AnimeGroup oldGroup = null; + SVR_AnimeGroup? oldGroup = null; // Updated Now obj.DateTimeUpdated = DateTime.Now; var isMigrating = false; @@ -197,7 +208,7 @@ public void Save(SVR_AnimeSeries obj, bool updateGroups, bool onlyupdatestats, b logger.Trace($"Saving Series {animeID} | Saved Series to Database in {sw.Elapsed.TotalSeconds:0.00###}s"); sw.Restart(); - if (updateGroups && !isMigrating) UpdateGroups(obj, animeID, sw, oldGroup); + if (updateGroups && !isMigrating) UpdateGroups(obj, animeID, sw, oldGroup!); Changes.AddOrUpdate(obj.AnimeSeriesID); @@ -217,7 +228,7 @@ private static void RegenerateSeasons(SVR_AnimeSeries obj, Stopwatch sw, string var anime = obj.AniDB_Anime; if (anime != null) { - RepoFactory.AniDB_Anime.Save(anime, true); + RepoFactory.AniDB_Anime.Save(anime); } sw.Stop(); @@ -266,15 +277,8 @@ private static void UpdateGroups(SVR_AnimeSeries obj, string animeID, Stopwatch public async Task UpdateBatch(ISessionWrapper session, IReadOnlyCollection<SVR_AnimeSeries> seriesBatch) { - if (session == null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (seriesBatch == null) - { - throw new ArgumentNullException(nameof(seriesBatch)); - } + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(seriesBatch); if (seriesBatch.Count == 0) { @@ -289,14 +293,14 @@ public async Task UpdateBatch(ISessionWrapper session, IReadOnlyCollection<SVR_A } } - public SVR_AnimeSeries GetByAnimeID(int id) + public SVR_AnimeSeries? GetByAnimeID(int id) { - return ReadLock(() => AniDBIds.GetOne(id)); + return ReadLock(() => AniDBIds!.GetOne(id)); } public List<SVR_AnimeSeries> GetByGroupID(int groupid) { - return ReadLock(() => Groups.GetMultiple(groupid)); + return ReadLock(() => Groups!.GetMultiple(groupid)); } public List<SVR_AnimeSeries> GetWithMissingEpisodes() @@ -335,10 +339,13 @@ public List<SVR_AnimeSeries> GetWithMultipleReleases(bool ignoreVariations) return ids .Distinct() .Select(GetByAnimeID) - .Where(a => a != null) + .WhereNotNull() .ToList(); } + public ImageEntityType[] GetAllImageTypes() + => [ImageEntityType.Backdrop, ImageEntityType.Banner, ImageEntityType.Logo, ImageEntityType.Poster]; + public IEnumerable<int> GetAllYears() { var anime = RepoFactory.AnimeSeries.GetAll().Select(a => RepoFactory.AniDB_Anime.GetByAnimeID(a.AniDB_ID)).Where(a => a?.AirDate != null).ToList(); diff --git a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_OtherRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_OtherRepository.cs deleted file mode 100644 index 9b71331d4..000000000 --- a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_OtherRepository.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NHibernate.Criterion; -using NutzCode.InMemoryIndex; -using Shoko.Commons.Collections; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Repositories.NHibernate; - -namespace Shoko.Server.Repositories.Cached; - -public class CrossRef_AniDB_OtherRepository : BaseCachedRepository<CrossRef_AniDB_Other, int> -{ - private PocoIndex<int, CrossRef_AniDB_Other, (int, CrossRefType)> _animeIDTypes; - - public CrossRef_AniDB_Other GetByAnimeIDAndType(int animeID, CrossRefType xrefType) - { - return ReadLock(() => _animeIDTypes.GetOne((animeID, xrefType))); - } - - /// <summary> - /// Gets other cross references by anime ID. - /// </summary> - /// <param name="session">The NHibernate session.</param> - /// <param name="animeIds">An optional list of anime IDs whose cross references are to be retrieved. - /// Can be <c>null</c> to get cross references for ALL anime.</param> - /// <param name="xrefTypes">The types of cross references to find.</param> - /// <returns>A <see cref="ILookup{TKey,TElement}"/> that maps anime ID to their associated other cross references.</returns> - /// <exception cref="ArgumentNullException"><paramref name="session"/> is <c>null</c>.</exception> - public ILookup<int, CrossRef_AniDB_Other> GetByAnimeIDsAndType(ISessionWrapper session, - IReadOnlyCollection<int> animeIds, - params CrossRefType[] xrefTypes) - { - if (session == null) throw new ArgumentNullException(nameof(session)); - - if (xrefTypes == null || xrefTypes.Length == 0 || animeIds is { Count: 0 }) return EmptyLookup<int, CrossRef_AniDB_Other>.Instance; - - return Lock(() => - { - var criteria = session.CreateCriteria<CrossRef_AniDB_Other>().Add(Restrictions.In(nameof(CrossRef_AniDB_Other.CrossRefType), xrefTypes)); - if (animeIds != null) criteria = criteria.Add(Restrictions.InG(nameof(CrossRef_AniDB_Other.AnimeID), animeIds)); - var crossRefs = criteria.List<CrossRef_AniDB_Other>().ToLookup(cr => cr.AnimeID); - return crossRefs; - }); - } - - public override void PopulateIndexes() - { - _animeIDTypes = new PocoIndex<int, CrossRef_AniDB_Other, (int, CrossRefType)>(Cache, a => (a.AnimeID, (CrossRefType)a.CrossRefType)); - } - - public override void RegenerateDb() - { - - } - - protected override int SelectKey(CrossRef_AniDB_Other entity) - { - return entity.CrossRef_AniDB_OtherID; - } - - public CrossRef_AniDB_OtherRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_EpisodeRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_EpisodeRepository.cs new file mode 100644 index 000000000..c3dec6209 --- /dev/null +++ b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_EpisodeRepository.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Server.Databases; +using Shoko.Server.Models.CrossReference; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class CrossRef_AniDB_TMDB_EpisodeRepository : BaseCachedRepository<CrossRef_AniDB_TMDB_Episode, int> +{ + private PocoIndex<int, CrossRef_AniDB_TMDB_Episode, int>? _anidbAnimeIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Episode, int>? _anidbEpisodeIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Episode, int>? _tmdbShowIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Episode, int>? _tmdbEpisodeIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Episode, (int, int)>? _pairedIDs; + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetByAnidbAnimeID(int animeId) + => ReadLock(() => _anidbAnimeIDs!.GetMultiple(animeId).ToList()); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetByAnidbEpisodeID(int episodeId) + => ReadLock(() => _anidbEpisodeIDs!.GetMultiple(episodeId).OrderBy(a => a.Ordering).ToList()); + + public CrossRef_AniDB_TMDB_Episode? GetByAnidbEpisodeAndTmdbEpisodeIDs(int anidbEpisodeId, int tmdbEpisodeId) + => GetByAnidbAnimeID(anidbEpisodeId).FirstOrDefault(xref => xref.TmdbEpisodeID == tmdbEpisodeId); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetByTmdbShowID(int showId) + => ReadLock(() => _tmdbShowIDs!.GetMultiple(showId).ToList()); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetByTmdbEpisodeID(int episodeId) + => ReadLock(() => _tmdbEpisodeIDs!.GetMultiple(episodeId).OrderBy(a => a.Ordering).ToList()); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetAllByAnidbAnimeAndTmdbShowIDs(int anidbId, int tmdbId) + => ReadLock(() => _tmdbShowIDs!.GetMultiple(tmdbId).Concat(_anidbAnimeIDs!.GetMultiple(anidbId)).ToList()); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Episode> GetOnlyByAnidbAnimeAndTmdbShowIDs(int anidbId, int tmdbId) + => ReadLock(() => _pairedIDs!.GetMultiple((anidbId, tmdbId))); + + protected override int SelectKey(CrossRef_AniDB_TMDB_Episode entity) + => entity.CrossRef_AniDB_TMDB_EpisodeID; + + public override void PopulateIndexes() + { + _anidbAnimeIDs = new(Cache, a => a.AnidbAnimeID); + _anidbEpisodeIDs = new(Cache, a => a.AnidbEpisodeID); + _tmdbShowIDs = new(Cache, a => a.TmdbShowID); + _tmdbEpisodeIDs = new(Cache, a => a.TmdbEpisodeID); + _pairedIDs = new(Cache, a => (a.AnidbAnimeID, a.TmdbShowID)); + } + + public override void RegenerateDb() + { + } + + public CrossRef_AniDB_TMDB_EpisodeRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_MovieRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_MovieRepository.cs new file mode 100644 index 000000000..292225a5b --- /dev/null +++ b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_MovieRepository.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Commons.Collections; +using Shoko.Server.Databases; +using Shoko.Server.Models.CrossReference; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class CrossRef_AniDB_TMDB_MovieRepository : BaseCachedRepository<CrossRef_AniDB_TMDB_Movie, int> +{ + private PocoIndex<int, CrossRef_AniDB_TMDB_Movie, int>? _anidbAnimeIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Movie, int>? _anidbEpisodeIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Movie, int>? _tmdbMovieIDs; + + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> GetByAnidbAnimeID(int animeId) + => ReadLock(() => _anidbAnimeIDs!.GetMultiple(animeId)); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> GetByAnidbEpisodeID(int episodeId) + => ReadLock(() => _anidbEpisodeIDs!.GetMultiple(episodeId)); + + public CrossRef_AniDB_TMDB_Movie? GetByAnidbEpisodeAndTmdbMovieIDs(int episodeId, int movieId) + => GetByAnidbEpisodeID(episodeId).FirstOrDefault(xref => xref.TmdbMovieID == movieId); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Movie> GetByTmdbMovieID(int movieId) + => ReadLock(() => _tmdbMovieIDs!.GetMultiple(movieId)); + + public ILookup<int, CrossRef_AniDB_TMDB_Movie> GetByAnimeIDsAndType(IReadOnlyCollection<int> animeIds) + { + if (animeIds == null || animeIds?.Count == 0) + return EmptyLookup<int, CrossRef_AniDB_TMDB_Movie>.Instance; + + return Lock( + () => animeIds!.SelectMany(animeId => _anidbAnimeIDs!.GetMultiple(animeId)).ToLookup(xref => xref.AnidbAnimeID) + ); + } + + protected override int SelectKey(CrossRef_AniDB_TMDB_Movie entity) + => entity.CrossRef_AniDB_TMDB_MovieID; + + public override void PopulateIndexes() + { + _tmdbMovieIDs = new(Cache, a => a.TmdbMovieID); + _anidbAnimeIDs = new(Cache, a => a.AnidbAnimeID); + _anidbEpisodeIDs = new(Cache, a => a.AnidbEpisodeID); + } + + public override void RegenerateDb() + { + } + + public CrossRef_AniDB_TMDB_MovieRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_ShowRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_ShowRepository.cs new file mode 100644 index 000000000..2aed7bb69 --- /dev/null +++ b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TMDB_ShowRepository.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Commons.Collections; +using Shoko.Server.Databases; +using Shoko.Server.Models.CrossReference; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class CrossRef_AniDB_TMDB_ShowRepository : BaseCachedRepository<CrossRef_AniDB_TMDB_Show, int> +{ + private PocoIndex<int, CrossRef_AniDB_TMDB_Show, int>? _anidbAnimeIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Show, int>? _tmdbShowIDs; + private PocoIndex<int, CrossRef_AniDB_TMDB_Show, (int, int)>? _pairedIDs; + + public IReadOnlyList<CrossRef_AniDB_TMDB_Show> GetByAnidbAnimeID(int animeId) + => ReadLock(() => _anidbAnimeIDs!.GetMultiple(animeId)); + + public IReadOnlyList<CrossRef_AniDB_TMDB_Show> GetByTmdbShowID(int showId) + => ReadLock(() => _tmdbShowIDs!.GetMultiple(showId)); + + public CrossRef_AniDB_TMDB_Show? GetByAnidbAnimeAndTmdbShowIDs(int anidbId, int tmdbId) + => ReadLock(() => _pairedIDs!.GetOne((anidbId, tmdbId))); + + public ILookup<int, CrossRef_AniDB_TMDB_Show> GetByAnimeIDsAndType(IReadOnlyCollection<int> animeIds) + { + if (animeIds == null || animeIds?.Count == 0) + return EmptyLookup<int, CrossRef_AniDB_TMDB_Show>.Instance; + + return Lock( + () => animeIds!.SelectMany(animeId => _anidbAnimeIDs!.GetMultiple(animeId)).ToLookup(xref => xref.AnidbAnimeID) + ); + } + + protected override int SelectKey(CrossRef_AniDB_TMDB_Show entity) + => entity.CrossRef_AniDB_TMDB_ShowID; + + public override void PopulateIndexes() + { + _tmdbShowIDs = new(Cache, a => a.TmdbShowID); + _anidbAnimeIDs = new(Cache, a => a.AnidbAnimeID); + _pairedIDs = new(Cache, a => (a.AnidbAnimeID, a.TmdbShowID)); + } + + public override void RegenerateDb() + { + } + + public CrossRef_AniDB_TMDB_ShowRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDBRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDBRepository.cs deleted file mode 100644 index cd084e022..000000000 --- a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDBRepository.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NutzCode.InMemoryIndex; -using Shoko.Commons.Collections; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Models; - -namespace Shoko.Server.Repositories.Cached; - -public class CrossRef_AniDB_TvDBRepository : BaseCachedRepository<CrossRef_AniDB_TvDB, int> -{ - private PocoIndex<int, CrossRef_AniDB_TvDB, int> TvDBIDs; - private PocoIndex<int, CrossRef_AniDB_TvDB, int> AnimeIDs; - - public override void PopulateIndexes() - { - TvDBIDs = new PocoIndex<int, CrossRef_AniDB_TvDB, int>(Cache, a => a.TvDBID); - AnimeIDs = new PocoIndex<int, CrossRef_AniDB_TvDB, int>(Cache, a => a.AniDBID); - } - - public List<CrossRef_AniDB_TvDB> GetByAnimeID(int id) - { - return ReadLock(() => AnimeIDs.GetMultiple(id)); - } - - public List<CrossRef_AniDB_TvDB> GetByTvDBID(int id) - { - return ReadLock(() => TvDBIDs.GetMultiple(id)); - } - - public ILookup<int, CrossRef_AniDB_TvDB> GetByAnimeIDs(IReadOnlyCollection<int> animeIds) - { - if (animeIds == null) - { - throw new ArgumentNullException(nameof(animeIds)); - } - - if (animeIds.Count == 0) - { - return EmptyLookup<int, CrossRef_AniDB_TvDB>.Instance; - } - - return ReadLock(() => animeIds.SelectMany(id => AnimeIDs.GetMultiple(id)) - .ToLookup(xref => xref.AniDBID)); - } - - public CrossRef_AniDB_TvDB GetByAniDBAndTvDBID(int animeID, int tvdbID) - { - return ReadLock(() => TvDBIDs.GetMultiple(tvdbID).FirstOrDefault(xref => xref.AniDBID == animeID)); - } - - public List<SVR_AnimeSeries> GetSeriesWithoutLinks() - { - return RepoFactory.AnimeSeries.GetAll().Where(a => - { - var anime = a.AniDB_Anime; - if (anime == null) - { - return false; - } - - if (anime.Restricted > 0) - { - return false; - } - - if (anime.AnimeType == (int)AnimeType.Movie) - { - return false; - } - - return !GetByAnimeID(a.AniDB_ID).Any(); - }).ToList(); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(CrossRef_AniDB_TvDB entity) - { - return entity.CrossRef_AniDB_TvDBID; - } - - public List<CrossRef_AniDB_TvDBV2> GetV2LinksFromAnime(int animeID) - { - var overrides = RepoFactory.CrossRef_AniDB_TvDB_Episode_Override.GetByAnimeID(animeID); - var normals = RepoFactory.CrossRef_AniDB_TvDB_Episode.GetByAnimeID(animeID); - var ls = new List<(int anidb_episode, int tvdb_episode)>(); - foreach (var epo in normals) - { - var ov = overrides.FirstOrDefault(a => a.AniDBEpisodeID == epo.AniDBEpisodeID); - if (ov != null) - { - ls.Add((ov.AniDBEpisodeID, ov.TvDBEpisodeID)); - overrides.Remove(ov); - } - else - { - ls.Add((epo.AniDBEpisodeID, epo.TvDBEpisodeID)); - } - } - - ls.AddRange(overrides.Select(ov => (ov.AniDBEpisodeID, ov.TvDBEpisodeID))); - var eplinks = ls.ToLookup(a => RepoFactory.AniDB_Episode.GetByEpisodeID(a.anidb_episode), - b => RepoFactory.TvDB_Episode.GetByTvDBID(b.tvdb_episode)) - .Select(a => (AniDB: a.Key, TvDB: a.FirstOrDefault())).Where(a => a.AniDB != null && a.TvDB != null) - .OrderBy(a => a.AniDB.EpisodeType).ThenBy(a => a.AniDB.EpisodeNumber).ToList(); - - var output = new List<(int EpisodeType, int EpisodeNumber, int TvDBSeries, int TvDBSeason, int TvDBNumber)>(); - for (var i = 0; i < eplinks.Count; i++) - { - // Cases: - // - first ep - // - new type/season - // - the next episode is not a simple increment - var b = eplinks[i]; - if (i == 0) - { - if (b.AniDB == null || b.TvDB == null) - { - return new List<CrossRef_AniDB_TvDBV2>(); - } - - output.Add((b.AniDB.EpisodeType, b.AniDB.EpisodeNumber, b.TvDB.SeriesID, b.TvDB.SeasonNumber, - b.TvDB.EpisodeNumber)); - continue; - } - - var a = eplinks[i - 1]; - if (a.AniDB.EpisodeType != b.AniDB.EpisodeType || b.TvDB.SeasonNumber != a.TvDB.SeasonNumber) - { - output.Add((b.AniDB.EpisodeType, b.AniDB.EpisodeNumber, b.TvDB.SeriesID, b.TvDB.SeasonNumber, - b.TvDB.EpisodeNumber)); - continue; - } - - if (b.AniDB.EpisodeNumber - a.AniDB.EpisodeNumber != 1 || b.TvDB.EpisodeNumber - a.TvDB.EpisodeNumber != 1) - { - output.Add((b.AniDB.EpisodeType, b.AniDB.EpisodeNumber, b.TvDB.SeriesID, b.TvDB.SeasonNumber, - b.TvDB.EpisodeNumber)); - } - } - - return output.Select(a => new CrossRef_AniDB_TvDBV2 - { - AnimeID = animeID, - AniDBStartEpisodeType = a.EpisodeType, - AniDBStartEpisodeNumber = a.EpisodeNumber, - TvDBID = a.TvDBSeries, - TvDBSeasonNumber = a.TvDBSeason, - TvDBStartEpisodeNumber = a.TvDBNumber, - TvDBTitle = RepoFactory.TvDB_Series.GetByTvDBID(a.TvDBSeries)?.SeriesName - }).ToList(); - } - - public CrossRef_AniDB_TvDBRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDB_EpisodeRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDB_EpisodeRepository.cs deleted file mode 100644 index bbb8ff630..000000000 --- a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDB_EpisodeRepository.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NutzCode.InMemoryIndex; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Utilities; - -namespace Shoko.Server.Repositories.Cached; - -public class CrossRef_AniDB_TvDB_EpisodeRepository : BaseCachedRepository<CrossRef_AniDB_TvDB_Episode, int> -{ - private PocoIndex<int, CrossRef_AniDB_TvDB_Episode, int> AnimeIDs; - private PocoIndex<int, CrossRef_AniDB_TvDB_Episode, int> EpisodeIDs; - - public override void PopulateIndexes() - { - AnimeIDs = new PocoIndex<int, CrossRef_AniDB_TvDB_Episode, int>(Cache, - a => RepoFactory.AniDB_Episode.GetByEpisodeID(a.AniDBEpisodeID)?.AnimeID ?? -1); - EpisodeIDs = new PocoIndex<int, CrossRef_AniDB_TvDB_Episode, int>(Cache, a => a.AniDBEpisodeID); - } - - public CrossRef_AniDB_TvDB_Episode GetByAniDBAndTvDBEpisodeIDs(int anidbID, int tvdbID) - { - return ReadLock(() => EpisodeIDs.GetMultiple(anidbID).FirstOrDefault(a => a.TvDBEpisodeID == tvdbID)); - } - - public List<CrossRef_AniDB_TvDB_Episode> GetByAniDBEpisodeID(int id) - { - return ReadLock(() => EpisodeIDs.GetMultiple(id)); - } - - public List<CrossRef_AniDB_TvDB_Episode> GetByAnimeID(int id) - { - return ReadLock(() => AnimeIDs.GetMultiple(id)); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(CrossRef_AniDB_TvDB_Episode entity) - { - return entity.CrossRef_AniDB_TvDB_EpisodeID; - } - - public void DeleteAllUnverifiedLinksForAnime(int AnimeID) - { - var toRemove = GetByAnimeID(AnimeID).Where(a => a.MatchRating != MatchRating.UserVerified) - .ToList(); - if (toRemove.Count <= 0) - { - return; - } - - foreach (var episode in toRemove) - { - DeleteFromCache(episode); - } - - Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - using var transaction = session.BeginTransaction(); - // I'm aware that this is stupid, but it's MySQL's fault - // see https://stackoverflow.com/questions/45494/mysql-error-1093-cant-specify-target-table-for-update-in-from-clause - var settings = Utils.SettingsProvider.GetSettings(); - if (settings.Database.Type.Equals("mysql", StringComparison.InvariantCultureIgnoreCase)) - { - try - { - session.CreateSQLQuery(@"SET optimizer_switch = 'derived_merge=off';").ExecuteUpdate(); - } - catch - { - // ignore - } - } - - session.CreateSQLQuery( - @"DELETE FROM CrossRef_AniDB_TvDB_Episode -WHERE CrossRef_AniDB_TvDB_Episode.MatchRating != :rating AND CrossRef_AniDB_TvDB_Episode.CrossRef_AniDB_TvDB_EpisodeID IN ( -SELECT CrossRef_AniDB_TvDB_EpisodeID FROM ( -SELECT CrossRef_AniDB_TvDB_EpisodeID -FROM CrossRef_AniDB_TvDB_Episode -INNER JOIN AniDB_Episode ON AniDB_Episode.EpisodeID = CrossRef_AniDB_TvDB_Episode.AniDBEpisodeID -WHERE AniDB_Episode.AnimeID = :animeid -) x);") - .SetInt32("animeid", AnimeID).SetInt32("rating", (int)MatchRating.UserVerified) - .ExecuteUpdate(); - - if (settings.Database.Type.Equals("mysql", StringComparison.InvariantCultureIgnoreCase)) - { - try - { - session.CreateSQLQuery(@"SET optimizer_switch = 'derived_merge=on';").ExecuteUpdate(); - } - catch - { - // ignore - } - } - - transaction.Commit(); - }); - } - - public void DeleteAllUnverifiedLinks() - { - var toRemove = GetAll().Where(a => a.MatchRating != MatchRating.UserVerified).ToList(); - foreach (var episode in toRemove) - { - DeleteFromCache(episode); - } - - Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - using var transaction = session.BeginTransaction(); - session.CreateSQLQuery("DELETE FROM CrossRef_AniDB_TvDB_Episode WHERE MatchRating != :rating;") - .SetInt32("rating", (int)MatchRating.UserVerified) - .ExecuteUpdate(); - transaction.Commit(); - }); - } - - public CrossRef_AniDB_TvDB_EpisodeRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDB_Episode_OverrideRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDB_Episode_OverrideRepository.cs deleted file mode 100644 index 3202d064a..000000000 --- a/Shoko.Server/Repositories/Cached/CrossRef_AniDB_TvDB_Episode_OverrideRepository.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NutzCode.InMemoryIndex; -using Shoko.Models.Server; -using Shoko.Server.Databases; - -namespace Shoko.Server.Repositories.Cached; - -public class CrossRef_AniDB_TvDB_Episode_OverrideRepository : BaseCachedRepository<CrossRef_AniDB_TvDB_Episode_Override, int> -{ - private PocoIndex<int, CrossRef_AniDB_TvDB_Episode_Override, int> AnimeIDs; - private PocoIndex<int, CrossRef_AniDB_TvDB_Episode_Override, int> EpisodeIDs; - - public override void PopulateIndexes() - { - AnimeIDs = new PocoIndex<int, CrossRef_AniDB_TvDB_Episode_Override, int>(Cache, - a => RepoFactory.AniDB_Episode.GetByEpisodeID(a.AniDBEpisodeID)?.AnimeID ?? -1); - EpisodeIDs = new PocoIndex<int, CrossRef_AniDB_TvDB_Episode_Override, int>(Cache, a => a.AniDBEpisodeID); - } - - public CrossRef_AniDB_TvDB_Episode_Override GetByAniDBAndTvDBEpisodeIDs(int anidbID, int tvdbID) - { - return ReadLock(() => EpisodeIDs.GetMultiple(anidbID).FirstOrDefault(a => a.TvDBEpisodeID == tvdbID)); - } - - public List<CrossRef_AniDB_TvDB_Episode_Override> GetByAniDBEpisodeID(int id) - { - return ReadLock(() => EpisodeIDs.GetMultiple(id)); - } - - public List<CrossRef_AniDB_TvDB_Episode_Override> GetByAnimeID(int id) - { - return ReadLock(() => AnimeIDs.GetMultiple(id)); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(CrossRef_AniDB_TvDB_Episode_Override entity) - { - return entity.CrossRef_AniDB_TvDB_Episode_OverrideID; - } - - public CrossRef_AniDB_TvDB_Episode_OverrideRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/CrossRef_Languages_AniDB_FileRepository.cs b/Shoko.Server/Repositories/Cached/CrossRef_Languages_AniDB_FileRepository.cs index 17a9253c2..4e2ca39b6 100644 --- a/Shoko.Server/Repositories/Cached/CrossRef_Languages_AniDB_FileRepository.cs +++ b/Shoko.Server/Repositories/Cached/CrossRef_Languages_AniDB_FileRepository.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using NHibernate; using NutzCode.InMemoryIndex; using Shoko.Commons.Extensions; using Shoko.Models.Server; @@ -12,14 +11,13 @@ namespace Shoko.Server.Repositories.Cached; public class CrossRef_Languages_AniDB_FileRepository : BaseCachedRepository<CrossRef_Languages_AniDB_File, int> { - private PocoIndex<int, CrossRef_Languages_AniDB_File, int> FileIDs; - private PocoIndex<int, CrossRef_Languages_AniDB_File, int> AnimeIDs; + private PocoIndex<int, CrossRef_Languages_AniDB_File, int> _fileIDs; public List<CrossRef_Languages_AniDB_File> GetByFileID(int id) { - return ReadLock(() => FileIDs.GetMultiple(id)); + return ReadLock(() => _fileIDs.GetMultiple(id)); } - + public HashSet<string> GetLanguagesForGroup(SVR_AnimeGroup group) { return ReadLock(() => @@ -29,7 +27,7 @@ public HashSet<string> GetLanguagesForGroup(SVR_AnimeGroup group) .ToHashSet(StringComparer.InvariantCultureIgnoreCase); }); } - + public HashSet<string> GetLanguagesForAnime(int animeID) { return ReadLock(() => @@ -41,7 +39,7 @@ public HashSet<string> GetLanguagesForAnime(int animeID) public override void PopulateIndexes() { - FileIDs = Cache.CreateIndex(a => a.FileID); + _fileIDs = Cache.CreateIndex(a => a.FileID); } public override void RegenerateDb() { } diff --git a/Shoko.Server/Repositories/Cached/CustomTagRepository.cs b/Shoko.Server/Repositories/Cached/CustomTagRepository.cs index 3e041b550..106638376 100644 --- a/Shoko.Server/Repositories/Cached/CustomTagRepository.cs +++ b/Shoko.Server/Repositories/Cached/CustomTagRepository.cs @@ -1,14 +1,18 @@ using System.Collections.Generic; using System.Linq; +using NutzCode.InMemoryIndex; using Shoko.Models.Server; using Shoko.Server.Databases; using Shoko.Server.Repositories.NHibernate; +#nullable enable namespace Shoko.Server.Repositories.Cached; public class CustomTagRepository : BaseCachedRepository<CustomTag, int> { - public CustomTagRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + private PocoIndex<int, CustomTag, string?>? _names; + + public CustomTagRepository(DatabaseFactory databaseFactory) : base(databaseFactory) { DeleteWithOpenTransactionCallback = (ses, obj) => { @@ -24,6 +28,7 @@ protected override int SelectKey(CustomTag entity) public override void PopulateIndexes() { + _names = new PocoIndex<int, CustomTag, string?>(Cache, a => a.TagName); } public override void RegenerateDb() @@ -38,6 +43,10 @@ public List<CustomTag> GetByAnimeID(int animeID) .ToList(); } + public CustomTag? GetByTagName(string? tagName) + => !string.IsNullOrEmpty(tagName?.Trim()) + ? ReadLock(() => _names!.GetOne(tagName)) + : null; public Dictionary<int, List<CustomTag>> GetByAnimeIDs(ISessionWrapper session, int[] animeIDs) { diff --git a/Shoko.Server/Repositories/Cached/FilterPresetRepository.cs b/Shoko.Server/Repositories/Cached/FilterPresetRepository.cs index b4f516da1..fc1823177 100644 --- a/Shoko.Server/Repositories/Cached/FilterPresetRepository.cs +++ b/Shoko.Server/Repositories/Cached/FilterPresetRepository.cs @@ -83,7 +83,7 @@ public void CreateOrVerifyLockedFilters() Locked = true, ApplyAtSeriesLevel = false, FilterType = GroupFilterType.None, - Expression = new AndExpression{ Left = new HasWatchedEpisodesExpression(), Right = new HasUnwatchedEpisodesExpression() }, + Expression = new AndExpression { Left = new HasWatchedEpisodesExpression(), Right = new HasUnwatchedEpisodesExpression() }, SortingExpression = new LastWatchedDateSortingSelector { Descending = true } }; Save(gf); @@ -102,7 +102,7 @@ public void CreateOrVerifyLockedFilters() Save(gf); } } - + public void CreateInitialFilters() { // group filters @@ -127,7 +127,7 @@ public void CreateInitialFilters() Name = Constants.GroupFilterName.MissingEpisodes, FilterType = GroupFilterType.UserDefined, Expression = new HasMissingEpisodesCollectingExpression(), - SortingExpression = new MissingEpisodeCollectingCountSortingSelector{ Descending = true} + SortingExpression = new MissingEpisodeCollectingCountSortingSelector { Descending = true } }; Save(gf); @@ -142,7 +142,7 @@ public void CreateInitialFilters() Left = new DateAddFunction(new LastAddedDateSelector(), TimeSpan.FromDays(10)), Right = new TodayFunction() }, - SortingExpression = new LastAddedDateSortingSelector { Descending = true} + SortingExpression = new LastAddedDateSortingSelector { Descending = true } }; Save(gf); @@ -193,7 +193,7 @@ public void CreateInitialFilters() { Name = Constants.GroupFilterName.RecentlyWatched, FilterType = GroupFilterType.UserDefined, - Expression = new AndExpression(new HasWatchedEpisodesExpression(), new + Expression = new AndExpression(new HasWatchedEpisodesExpression(), new DateGreaterThanEqualsExpression(new DateAddFunction(new LastWatchedDateSelector(), TimeSpan.FromDays(10)), new TodayFunction())), SortingExpression = new LastWatchedDateSortingSelector { @@ -202,13 +202,13 @@ public void CreateInitialFilters() }; Save(gf); - // TvDB/MovieDB Link Missing + // TMDB Link Missing gf = new FilterPreset { Name = Constants.GroupFilterName.MissingLinks, ApplyAtSeriesLevel = true, FilterType = GroupFilterType.UserDefined, - Expression = new OrExpression(new MissingTvDBLinkExpression(), new MissingTMDbLinkExpression()), + Expression = new MissingTmdbLinkExpression(), SortingExpression = new NameSortingSelector() }; Save(gf); diff --git a/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs b/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs index a5e1169ae..e97848135 100644 --- a/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs +++ b/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs @@ -73,15 +73,9 @@ public SVR_ImportFolder SaveImportFolder(ImportFolder folder) throw new Exception("Cannot find Import Folder location"); } - if (folder.ImportFolderID == 0) - { - var nsTemp = - GetByImportLocation(folder.ImportFolderLocation); - if (nsTemp != null) - { - throw new Exception("Another entry already exists for the specified Import Folder location"); - } - } + if (GetAll().ExceptBy([folder.ImportFolderID], iF => iF.ImportFolderID).Any(iF => folder.ImportFolderLocation.StartsWith(iF.ImportFolderLocation, StringComparison.OrdinalIgnoreCase) || iF.ImportFolderLocation.StartsWith(folder.ImportFolderLocation, StringComparison.OrdinalIgnoreCase))) + throw new Exception("Unable to nest an import folder within another import folder."); + ns.ImportFolderName = folder.ImportFolderName; ns.ImportFolderLocation = folder.ImportFolderLocation; diff --git a/Shoko.Server/Repositories/Cached/MovieDB_FanartRepository.cs b/Shoko.Server/Repositories/Cached/MovieDB_FanartRepository.cs deleted file mode 100644 index c3c6c158a..000000000 --- a/Shoko.Server/Repositories/Cached/MovieDB_FanartRepository.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NHibernate; -using Shoko.Commons.Collections; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Repositories.NHibernate; - -namespace Shoko.Server.Repositories.Cached; - -public class MovieDB_FanartRepository : BaseCachedRepository<MovieDB_Fanart, int> -{ - public MovieDB_Fanart GetByOnlineID(string url) - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return session.Query<MovieDB_Fanart>().Where(a => a.URL == url).Take(1).SingleOrDefault(); - }); - } - - public List<MovieDB_Fanart> GetByMovieID(int id) - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return session.Query<MovieDB_Fanart>().Where(a => a.MovieId == id).ToList(); - }); - } - - public ILookup<int, MovieDB_Fanart> GetByAnimeIDs(ISessionWrapper session, int[] animeIds) - { - if (session == null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (animeIds == null) - { - throw new ArgumentNullException(nameof(animeIds)); - } - - if (animeIds.Length == 0) - { - return EmptyLookup<int, MovieDB_Fanart>.Instance; - } - - return Lock(() => - { - var fanartByAnime = session.CreateSQLQuery( - @" - SELECT DISTINCT adbOther.AnimeID, {mdbFanart.*} - FROM CrossRef_AniDB_Other AS adbOther - INNER JOIN MovieDB_Fanart AS mdbFanart - ON mdbFanart.MovieId = adbOther.CrossRefID - WHERE adbOther.CrossRefType = :crossRefType AND adbOther.AnimeID IN (:animeIds)" - ) - .AddScalar("AnimeID", NHibernateUtil.Int32) - .AddEntity("mdbFanart", typeof(MovieDB_Fanart)) - .SetInt32("crossRefType", (int)CrossRefType.MovieDB) - .SetParameterList("animeIds", animeIds) - .List<object[]>() - .ToLookup(r => (int)r[0], r => (MovieDB_Fanart)r[1]); - - return fanartByAnime; - }); - } - - public List<MovieDB_Fanart> GetAllOriginal() - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return session - .Query<MovieDB_Fanart>() - .Where(a => a.ImageSize == Shoko.Models.Constants.MovieDBImageSize.Original) - .ToList(); - }); - } - - public override void PopulateIndexes() - { - - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(MovieDB_Fanart entity) - { - return entity.MovieDB_FanartID; - } - - public MovieDB_FanartRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/TMDB_ImageRepository.cs b/Shoko.Server/Repositories/Cached/TMDB_ImageRepository.cs new file mode 100644 index 000000000..eddb19516 --- /dev/null +++ b/Shoko.Server/Repositories/Cached/TMDB_ImageRepository.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Linq; +using NutzCode.InMemoryIndex; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Repositories.Cached; + +public class TMDB_ImageRepository : BaseCachedRepository<TMDB_Image, int> +{ + private PocoIndex<int, TMDB_Image, int?>? _tmdbMovieIDs; + private PocoIndex<int, TMDB_Image, int?>? _tmdbEpisodeIDs; + private PocoIndex<int, TMDB_Image, int?>? _tmdbSeasonIDs; + private PocoIndex<int, TMDB_Image, int?>? _tmdbShowIDs; + private PocoIndex<int, TMDB_Image, int?>? _tmdbCollectionIDs; + private PocoIndex<int, TMDB_Image, int?>? _tmdbNetworkIDs; + private PocoIndex<int, TMDB_Image, int?>? _tmdbCompanyIDs; + private PocoIndex<int, TMDB_Image, int?>? _tmdbPersonIDs; + private PocoIndex<int, TMDB_Image, ImageEntityType>? _tmdbTypes; + private PocoIndex<int, TMDB_Image, (string filePath, ImageEntityType type)>? _tmdbRemoteFileNames; + + public IReadOnlyList<TMDB_Image> GetByTmdbMovieID(int? movieId) + => movieId.HasValue ? ReadLock(() => _tmdbMovieIDs!.GetMultiple(movieId)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbMovieIDAndType(int? movieId, ImageEntityType type) + => GetByTmdbMovieID(movieId).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByTmdbEpisodeID(int? episodeId) + => episodeId.HasValue ? ReadLock(() => _tmdbEpisodeIDs!.GetMultiple(episodeId)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbEpisodeIDAndType(int? episodeId, ImageEntityType type) + => GetByTmdbEpisodeID(episodeId).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByTmdbSeasonID(int? seasonId) + => seasonId.HasValue ? ReadLock(() => _tmdbSeasonIDs!.GetMultiple(seasonId)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbSeasonIDAndType(int? seasonId, ImageEntityType type) + => GetByTmdbSeasonID(seasonId).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByTmdbShowID(int? showId) + => showId.HasValue ? ReadLock(() => _tmdbShowIDs!.GetMultiple(showId)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbShowIDAndType(int? showId, ImageEntityType type) + => GetByTmdbShowID(showId).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByTmdbCollectionID(int? collectionId) + => collectionId.HasValue ? ReadLock(() => _tmdbCollectionIDs!.GetMultiple(collectionId)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbCollectionIDAndType(int? collectionId, ImageEntityType type) + => ReadLock(() => _tmdbCollectionIDs!.GetMultiple(collectionId)).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByTmdbNetworkID(int? networkId) + => networkId.HasValue ? ReadLock(() => _tmdbNetworkIDs!.GetMultiple(networkId.Value)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbNetworkIDAndType(int? networkId, ImageEntityType type) + => GetByTmdbNetworkID(networkId).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByTmdbCompanyID(int? companyId) + => companyId.HasValue ? ReadLock(() => _tmdbCompanyIDs!.GetMultiple(companyId.Value)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbCompanyIDAndType(int? companyId, ImageEntityType type) + => GetByTmdbCompanyID(companyId).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByTmdbPersonID(int? personId) + => personId.HasValue ? ReadLock(() => _tmdbPersonIDs!.GetMultiple(personId.Value)) ?? new() : new(); + + public IReadOnlyList<TMDB_Image> GetByTmdbPersonIDAndType(int? personId, ImageEntityType type) + => GetByTmdbPersonID(personId).Where(image => image.ImageType == type).ToList(); + + public IReadOnlyList<TMDB_Image> GetByType(ImageEntityType type) + => ReadLock(() => _tmdbTypes!.GetMultiple(type)) ?? new(); + + public IReadOnlyList<TMDB_Image> GetByForeignID(int? id, ForeignEntityType foreignType) + => foreignType switch + { + ForeignEntityType.Movie => GetByTmdbMovieID(id), + ForeignEntityType.Episode => GetByTmdbEpisodeID(id), + ForeignEntityType.Season => GetByTmdbSeasonID(id), + ForeignEntityType.Show => GetByTmdbShowID(id), + ForeignEntityType.Collection => GetByTmdbCollectionID(id), + _ => new List<TMDB_Image>(), + }; + + public IReadOnlyList<TMDB_Image> GetByForeignIDAndType(int? id, ForeignEntityType foreignType, ImageEntityType type) + => foreignType switch + { + ForeignEntityType.Movie => GetByTmdbMovieIDAndType(id, type), + ForeignEntityType.Episode => GetByTmdbEpisodeIDAndType(id, type), + ForeignEntityType.Season => GetByTmdbSeasonIDAndType(id, type), + ForeignEntityType.Show => GetByTmdbShowIDAndType(id, type), + ForeignEntityType.Collection => GetByTmdbCollectionIDAndType(id, type), + _ => new List<TMDB_Image>(), + }; + + public TMDB_Image? GetByRemoteFileNameAndType(string fileName, ImageEntityType type) + { + if (fileName.EndsWith(".svg")) + fileName = fileName[..^4] + ".png"; + return ReadLock(() => _tmdbRemoteFileNames!.GetOne((fileName, type))); + } + + public ILookup<int, TMDB_Image> GetByAnimeIDsAndType(int[] animeIds, ImageEntityType type) + { + return animeIds + .SelectMany(animeId => + RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(animeId).SelectMany(xref => GetByTmdbMovieIDAndType(xref.TmdbMovieID, type)) + .Concat(RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(animeId).SelectMany(xref => GetByTmdbShowIDAndType(xref.TmdbShowID, type))) + .Select(image => (AnimeID: animeId, Image: image)) + ) + .ToLookup(a => a.AnimeID, a => a.Image); + } + + protected override int SelectKey(TMDB_Image entity) + => entity.TMDB_ImageID; + + public override void PopulateIndexes() + { + _tmdbMovieIDs = new(Cache, a => a.TmdbMovieID); + _tmdbEpisodeIDs = new(Cache, a => a.TmdbEpisodeID); + _tmdbSeasonIDs = new(Cache, a => a.TmdbSeasonID); + _tmdbShowIDs = new(Cache, a => a.TmdbShowID); + _tmdbCollectionIDs = new(Cache, a => a.TmdbCollectionID); + _tmdbNetworkIDs = new(Cache, a => a.TmdbNetworkID); + _tmdbCompanyIDs = new(Cache, a => a.TmdbCompanyID); + _tmdbPersonIDs = new(Cache, a => a.TmdbPersonID); + _tmdbTypes = new(Cache, a => a.ImageType); + _tmdbRemoteFileNames = new(Cache, a => (a.RemoteFileName, a.ImageType)); + } + + public override void RegenerateDb() + { + } + + public TMDB_ImageRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Cached/TvDB_EpisodeRepository.cs b/Shoko.Server/Repositories/Cached/TvDB_EpisodeRepository.cs deleted file mode 100644 index b51ce9b52..000000000 --- a/Shoko.Server/Repositories/Cached/TvDB_EpisodeRepository.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NutzCode.InMemoryIndex; -using Shoko.Models.Server; -using Shoko.Server.Databases; - -namespace Shoko.Server.Repositories.Cached; - -public class TvDB_EpisodeRepository : BaseCachedRepository<TvDB_Episode, int> -{ - private PocoIndex<int, TvDB_Episode, int> SeriesIDs; - private PocoIndex<int, TvDB_Episode, int> EpisodeIDs; - - public override void PopulateIndexes() - { - SeriesIDs = new PocoIndex<int, TvDB_Episode, int>(Cache, a => a.SeriesID); - EpisodeIDs = new PocoIndex<int, TvDB_Episode, int>(Cache, a => a.Id); - } - - public TvDB_Episode GetByTvDBID(int id) - { - return ReadLock(() => EpisodeIDs.GetOne(id)); - } - - public List<TvDB_Episode> GetBySeriesID(int seriesID) - { - return ReadLock(() => SeriesIDs.GetMultiple(seriesID)); - } - - /// <summary> - /// Returns a set of all tvdb seasons in a series - /// </summary> - /// <param name="seriesID"></param> - /// <returns>distinct list of integers</returns> - public List<int> GetSeasonNumbersForSeries(int seriesID) - { - return GetBySeriesID(seriesID).Select(xref => xref.SeasonNumber).Distinct().ToList(); - } - - /// <summary> - /// Returns the last TvDB Season Number, or -1 if unable - /// </summary> - /// <param name="seriesID">The TvDB series ID</param> - /// <returns>The last TvDB Season Number, or -1 if unable</returns> - public int GetLastSeasonForSeries(int seriesID) - { - var seriesIDs = GetBySeriesID(seriesID); - if (seriesIDs.Count == 0) - { - return -1; - } - - return seriesIDs.Max(xref => xref.SeasonNumber); - } - - /// <summary> - /// Gets a unique episode by series, season, and tvdb episode number - /// </summary> - /// <param name="seriesID"></param> - /// <param name="seasonNumber"></param> - /// <param name="epNumber"></param> - /// <returns></returns> - public TvDB_Episode GetBySeriesIDSeasonNumberAndEpisode(int seriesID, int seasonNumber, int epNumber) - { - return GetBySeriesID(seriesID).FirstOrDefault(xref => xref.SeasonNumber == seasonNumber && - xref.EpisodeNumber == epNumber); - } - - /// <summary> - /// Returns the Number of Episodes in a Season - /// </summary> - /// <param name="seriesID"></param> - /// <param name="seasonNumber"></param> - /// <returns>int</returns> - public int GetNumberOfEpisodesForSeason(int seriesID, int seasonNumber) - { - return GetBySeriesID(seriesID).Count(xref => xref.SeasonNumber == seasonNumber); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(TvDB_Episode entity) - { - return entity.TvDB_EpisodeID; - } - - public TvDB_EpisodeRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/TvDB_ImageFanartRepository.cs b/Shoko.Server/Repositories/Cached/TvDB_ImageFanartRepository.cs deleted file mode 100644 index 3da486fb6..000000000 --- a/Shoko.Server/Repositories/Cached/TvDB_ImageFanartRepository.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NHibernate; -using NutzCode.InMemoryIndex; -using Shoko.Commons.Collections; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Repositories.NHibernate; - -namespace Shoko.Server.Repositories.Cached; - -public class TvDB_ImageFanartRepository : BaseCachedRepository<TvDB_ImageFanart, int> -{ - private PocoIndex<int, TvDB_ImageFanart, int> SeriesIDs; - private PocoIndex<int, TvDB_ImageFanart, int> TvDBIDs; - - public override void PopulateIndexes() - { - SeriesIDs = new PocoIndex<int, TvDB_ImageFanart, int>(Cache, a => a.SeriesID); - TvDBIDs = new PocoIndex<int, TvDB_ImageFanart, int>(Cache, a => a.Id); - } - - public TvDB_ImageFanart GetByTvDBID(int id) - { - return ReadLock(() => TvDBIDs.GetOne(id)); - } - - public List<TvDB_ImageFanart> GetBySeriesID(int seriesID) - { - return ReadLock(() => SeriesIDs.GetMultiple(seriesID)); - } - - public ILookup<int, TvDB_ImageFanart> GetByAnimeIDs(ISessionWrapper session, int[] animeIds) - { - if (session == null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (animeIds == null) - { - throw new ArgumentNullException(nameof(animeIds)); - } - - if (animeIds.Length == 0) - { - return EmptyLookup<int, TvDB_ImageFanart>.Instance; - } - - return Lock(() => - { - var fanartByAnime = session.CreateSQLQuery(@" - SELECT DISTINCT crAdbTvTb.AniDBID, {tvdbFanart.*} - FROM CrossRef_AniDB_TvDB AS crAdbTvTb - INNER JOIN TvDB_ImageFanart AS tvdbFanart - ON tvdbFanart.SeriesID = crAdbTvTb.TvDBID - WHERE crAdbTvTb.AniDBID IN (:animeIds)") - .AddScalar("AniDBID", NHibernateUtil.Int32) - .AddEntity("tvdbFanart", typeof(TvDB_ImageFanart)) - .SetParameterList("animeIds", animeIds) - .List<object[]>() - .ToLookup(r => (int)r[0], r => (TvDB_ImageFanart)r[1]); - - return fanartByAnime; - }); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(TvDB_ImageFanart entity) - { - return entity.TvDB_ImageFanartID; - } - - public TvDB_ImageFanartRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/TvDB_ImagePosterRepository.cs b/Shoko.Server/Repositories/Cached/TvDB_ImagePosterRepository.cs deleted file mode 100644 index 76b166d85..000000000 --- a/Shoko.Server/Repositories/Cached/TvDB_ImagePosterRepository.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using NutzCode.InMemoryIndex; -using Shoko.Models.Server; -using Shoko.Server.Databases; - -namespace Shoko.Server.Repositories.Cached; - -public class TvDB_ImagePosterRepository : BaseCachedRepository<TvDB_ImagePoster, int> -{ - private PocoIndex<int, TvDB_ImagePoster, int> SeriesIDs; - private PocoIndex<int, TvDB_ImagePoster, int> TvDBIDs; - - public override void PopulateIndexes() - { - SeriesIDs = new PocoIndex<int, TvDB_ImagePoster, int>(Cache, a => a.SeriesID); - TvDBIDs = new PocoIndex<int, TvDB_ImagePoster, int>(Cache, a => a.Id); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(TvDB_ImagePoster entity) - { - return entity.TvDB_ImagePosterID; - } - - public TvDB_ImagePoster GetByTvDBID(int id) - { - return ReadLock(() => TvDBIDs.GetOne(id)); - } - - public List<TvDB_ImagePoster> GetBySeriesID(int seriesID) - { - return ReadLock(() => SeriesIDs.GetMultiple(seriesID)); - } - - public TvDB_ImagePosterRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/TvDB_ImageWideBannerRepository.cs b/Shoko.Server/Repositories/Cached/TvDB_ImageWideBannerRepository.cs deleted file mode 100644 index 381ba6759..000000000 --- a/Shoko.Server/Repositories/Cached/TvDB_ImageWideBannerRepository.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NHibernate; -using NutzCode.InMemoryIndex; -using Shoko.Commons.Collections; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Repositories.NHibernate; - -namespace Shoko.Server.Repositories.Cached; - -public class TvDB_ImageWideBannerRepository : BaseCachedRepository<TvDB_ImageWideBanner, int> -{ - private PocoIndex<int, TvDB_ImageWideBanner, int> SeriesIDs; - private PocoIndex<int, TvDB_ImageWideBanner, int> TvDBIDs; - - public override void PopulateIndexes() - { - SeriesIDs = new PocoIndex<int, TvDB_ImageWideBanner, int>(Cache, a => a.SeriesID); - TvDBIDs = new PocoIndex<int, TvDB_ImageWideBanner, int>(Cache, a => a.Id); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(TvDB_ImageWideBanner entity) - { - return entity.TvDB_ImageWideBannerID; - } - - public TvDB_ImageWideBanner GetByTvDBID(int id) - { - return ReadLock(() => TvDBIDs.GetOne(id)); - } - - public List<TvDB_ImageWideBanner> GetBySeriesID(int seriesID) - { - return ReadLock(() => SeriesIDs.GetMultiple(seriesID)); - } - - public ILookup<int, TvDB_ImageWideBanner> GetByAnimeIDs(ISessionWrapper session, int[] animeIds) - { - if (session == null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (animeIds == null) - { - throw new ArgumentNullException(nameof(animeIds)); - } - - if (animeIds.Length == 0) - { - return EmptyLookup<int, TvDB_ImageWideBanner>.Instance; - } - - return Lock(() => - { - var bannersByAnime = session.CreateSQLQuery(@" - SELECT DISTINCT crAdbTvTb.AniDBID, {tvdbBanner.*} - FROM CrossRef_AniDB_TvDB AS crAdbTvTb - INNER JOIN TvDB_ImageWideBanner AS tvdbBanner - ON tvdbBanner.SeriesID = crAdbTvTb.TvDBID - WHERE crAdbTvTb.AniDBID IN (:animeIds)") - .AddScalar("AniDBID", NHibernateUtil.Int32) - .AddEntity("tvdbBanner", typeof(TvDB_ImageWideBanner)) - .SetParameterList("animeIds", animeIds) - .List<object[]>() - .ToLookup(r => (int)r[0], r => (TvDB_ImageWideBanner)r[1]); - - return bannersByAnime; - }); - } - - public TvDB_ImageWideBannerRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/TvDB_SeriesRepository.cs b/Shoko.Server/Repositories/Cached/TvDB_SeriesRepository.cs deleted file mode 100644 index 43f5fdbee..000000000 --- a/Shoko.Server/Repositories/Cached/TvDB_SeriesRepository.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Linq; -using NutzCode.InMemoryIndex; -using Shoko.Commons.Collections; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Repositories.NHibernate; - -namespace Shoko.Server.Repositories.Cached; - -public class TvDB_SeriesRepository : BaseCachedRepository<TvDB_Series, int> -{ - private PocoIndex<int, TvDB_Series, int> TvDBIDs; - - public override void PopulateIndexes() - { - TvDBIDs = new PocoIndex<int, TvDB_Series, int>(Cache, a => a.SeriesID); - } - - public TvDB_Series GetByTvDBID(int id) - { - return ReadLock(() => TvDBIDs.GetOne(id)); - } - - public ILookup<int, Tuple<CrossRef_AniDB_TvDB, TvDB_Series>> GetByAnimeIDs(ISessionWrapper session, - int[] animeIds) - { - if (session == null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (animeIds == null) - { - throw new ArgumentNullException(nameof(animeIds)); - } - - if (animeIds.Length == 0) - { - return EmptyLookup<int, Tuple<CrossRef_AniDB_TvDB, TvDB_Series>>.Instance; - } - - return Lock(() => - { - var tvDbSeriesByAnime = session.CreateSQLQuery(@" - SELECT {cr.*}, {series.*} - FROM CrossRef_AniDB_TvDB cr - INNER JOIN TvDB_Series series - ON series.SeriesID = cr.TvDBID - WHERE cr.AniDBID IN (:animeIds)") - .AddEntity("cr", typeof(CrossRef_AniDB_TvDB)) - .AddEntity("series", typeof(TvDB_Series)) - .SetParameterList("animeIds", animeIds) - .List<object[]>() - .ToLookup(r => ((CrossRef_AniDB_TvDB)r[0]).AniDBID, - r => new Tuple<CrossRef_AniDB_TvDB, TvDB_Series>((CrossRef_AniDB_TvDB)r[0], - (TvDB_Series)r[1])); - - return tvDbSeriesByAnime; - }); - } - - public override void RegenerateDb() - { - } - - protected override int SelectKey(TvDB_Series entity) - { - return entity.TvDB_SeriesID; - } - - public TvDB_SeriesRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs b/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs index 2dc30f285..1d21e33cc 100644 --- a/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs +++ b/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs @@ -19,6 +19,7 @@ using Shoko.Server.Services; using Shoko.Server.Utilities; +#pragma warning disable CS0618 namespace Shoko.Server.Repositories.Cached; public class VideoLocalRepository : BaseCachedRepository<SVR_VideoLocal, int> @@ -528,6 +529,7 @@ public List<SVR_VideoLocal> GetVideosWithMissingCrossReferenceData() private static bool IsImported(SVR_CrossRef_File_Episode xref) { + if (xref.AnimeID == 0) return false; var ep = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(xref.EpisodeID); if (ep?.AniDB_Episode == null) return false; var anime = RepoFactory.AnimeSeries.GetByAnimeID(xref.AnimeID); diff --git a/Shoko.Server/Repositories/Direct/AniDB_MessageRepository.cs b/Shoko.Server/Repositories/Direct/AniDB_MessageRepository.cs new file mode 100644 index 000000000..087c84a67 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/AniDB_MessageRepository.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models; +using Shoko.Server.Server; + +namespace Shoko.Server.Repositories.Direct; + +public class AniDB_MessageRepository : BaseDirectRepository<AniDB_Message, int> +{ + public AniDB_Message GetByMessageId(int id) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session.Query<AniDB_Message>() + .Where(a => a.MessageID == id) + .Take(1) + .SingleOrDefault(); + }); + } + + public List<AniDB_Message> GetUnhandledFileMoveMessages() + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session.Query<AniDB_Message>() + .Where(a => a.Flags.HasFlag(AniDBMessageFlags.FileMoved) && !a.Flags.HasFlag(AniDBMessageFlags.FileMoveHandled)) + .ToList(); + }); + } + + public AniDB_MessageRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/AniDB_NotifyQueueRepository.cs b/Shoko.Server/Repositories/Direct/AniDB_NotifyQueueRepository.cs new file mode 100644 index 000000000..9bf8e165f --- /dev/null +++ b/Shoko.Server/Repositories/Direct/AniDB_NotifyQueueRepository.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using NHibernate.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models; +using Shoko.Server.Server; + +namespace Shoko.Server.Repositories.Direct; + +public class AniDB_NotifyQueueRepository : BaseDirectRepository<AniDB_NotifyQueue, int> +{ + public AniDB_NotifyQueue GetByTypeID(AniDBNotifyType type, int id) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenStatelessSession(); + return session.Query<AniDB_NotifyQueue>() + .Where(a => a.Type == type && a.ID == id) + .Take(1) + .SingleOrDefault(); + }); + } + + public List<AniDB_NotifyQueue> GetByType(AniDBNotifyType type) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenStatelessSession(); + return session.Query<AniDB_NotifyQueue>() + .Where(a => a.Type == type) + .ToList(); + }); + } + + public void DeleteForTypeID(AniDBNotifyType type, int id) + { + Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenStatelessSession(); + // Query can't batch delete, while Query can + session.Query<AniDB_NotifyQueue>().Where(a => a.Type == type && a.ID == id).Delete(); + }); + } + + public AniDB_NotifyQueueRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/MovieDB_PosterRepository.cs b/Shoko.Server/Repositories/Direct/MovieDB_PosterRepository.cs deleted file mode 100644 index ac6b800cd..000000000 --- a/Shoko.Server/Repositories/Direct/MovieDB_PosterRepository.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NHibernate; -using NHibernate.Criterion; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Repositories.NHibernate; - -namespace Shoko.Server.Repositories.Direct; - -public class MovieDB_PosterRepository : BaseDirectRepository<MovieDB_Poster, int> -{ - public MovieDB_Poster GetByOnlineID(string url) - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return GetByOnlineID(session, url); - }); - } - - public MovieDB_Poster GetByOnlineID(ISession session, string url) - { - var cr = session - .CreateCriteria(typeof(MovieDB_Poster)) - .Add(Restrictions.Eq("URL", url)) - .List<MovieDB_Poster>().FirstOrDefault(); - return cr; - } - - public List<MovieDB_Poster> GetByMovieID(int id) - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return GetByMovieID(session.Wrap(), id); - }); - } - - public List<MovieDB_Poster> GetByMovieID(ISessionWrapper session, int id) - { - var objs = session - .CreateCriteria(typeof(MovieDB_Poster)) - .Add(Restrictions.Eq("MovieId", id)) - .List<MovieDB_Poster>(); - - return new List<MovieDB_Poster>(objs); - } - - public List<MovieDB_Poster> GetAllOriginal() - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - var objs = session - .CreateCriteria(typeof(MovieDB_Poster)) - .Add(Restrictions.Eq("ImageSize", Shoko.Models.Constants.MovieDBImageSize.Original)) - .List<MovieDB_Poster>(); - - return new List<MovieDB_Poster>(objs); - }); - } - - public MovieDB_PosterRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Direct/MovieDb_MovieRepository.cs b/Shoko.Server/Repositories/Direct/MovieDb_MovieRepository.cs deleted file mode 100644 index 182cec1b7..000000000 --- a/Shoko.Server/Repositories/Direct/MovieDb_MovieRepository.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NHibernate.Criterion; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Server.Databases; -using Shoko.Server.Repositories.NHibernate; - -namespace Shoko.Server.Repositories.Direct; - -public class MovieDb_MovieRepository : BaseDirectRepository<MovieDB_Movie, int> -{ - public MovieDB_Movie GetByOnlineID(int id) - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return GetByOnlineIDUnsafe(session.Wrap(), id); - }); - } - - public MovieDB_Movie GetByOnlineID(ISessionWrapper session, int id) - { - return Lock(() => GetByOnlineIDUnsafe(session, id)); - } - - private static MovieDB_Movie GetByOnlineIDUnsafe(ISessionWrapper session, int id) - { - var cr = session - .CreateCriteria(typeof(MovieDB_Movie)) - .Add(Restrictions.Eq("MovieId", id)) - .UniqueResult<MovieDB_Movie>(); - return cr; - } - - public Dictionary<int, Tuple<CrossRef_AniDB_Other, MovieDB_Movie>> GetByAnimeIDs(ISessionWrapper session, - int[] animeIds) - { - if (session == null) - { - throw new ArgumentNullException(nameof(session)); - } - - if (animeIds == null) - { - throw new ArgumentNullException(nameof(animeIds)); - } - - if (animeIds.Length == 0) - { - return new Dictionary<int, Tuple<CrossRef_AniDB_Other, MovieDB_Movie>>(); - } - - return Lock(() => - { - var movieByAnime = session.CreateSQLQuery( - @" - SELECT {cr.*}, {movie.*} - FROM CrossRef_AniDB_Other cr - INNER JOIN MovieDB_Movie movie - ON cr.CrossRefType = :crossRefType - AND movie.MovieId = cr.CrossRefID - WHERE cr.AnimeID IN (:animeIds)" - ) - .AddEntity("cr", typeof(CrossRef_AniDB_Other)) - .AddEntity("movie", typeof(MovieDB_Movie)) - .SetInt32("crossRefType", (int)CrossRefType.MovieDB) - .SetParameterList("animeIds", animeIds) - .List<object[]>() - .ToDictionary( - r => ((CrossRef_AniDB_Other)r[0]).AnimeID, - r => new Tuple<CrossRef_AniDB_Other, MovieDB_Movie>( - (CrossRef_AniDB_Other)r[0], - (MovieDB_Movie)r[1] - ) - ); - - return movieByAnime; - }); - } - - public MovieDb_MovieRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Direct/RenameScriptRepository.cs b/Shoko.Server/Repositories/Direct/RenameScriptRepository.cs deleted file mode 100644 index 812feae8b..000000000 --- a/Shoko.Server/Repositories/Direct/RenameScriptRepository.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NHibernate.Criterion; -using Shoko.Models.Server; -using Shoko.Server.Databases; - -#nullable enable -namespace Shoko.Server.Repositories.Direct; - -public class RenameScriptRepository : BaseDirectRepository<RenameScript, int> -{ - public RenameScript? GetDefaultScript() - { - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - var cr = session - .Query<RenameScript>() - .Where(a => a.IsEnabledOnImport == 1) - .Take(1).SingleOrDefault(); - return cr; - }); - } - - public RenameScript? GetDefaultOrFirst() - { - return Lock(() => - { - // This should list the enabled one first, falling back if none are - using var session = _databaseFactory.SessionFactory.OpenSession(); - return session - .Query<RenameScript>() - .OrderByDescending(a => a.IsEnabledOnImport) - .ThenBy(a => a.RenameScriptID) - .Take(1).SingleOrDefault(); - }); - } - - public RenameScript? GetByName(string? scriptName) - { - if (string.IsNullOrEmpty(scriptName)) - return null; - return Lock(() => - { - using var session = _databaseFactory.SessionFactory.OpenSession(); - return session - .Query<RenameScript>() - .Where(a => a.ScriptName == scriptName) - .Take(1) - .SingleOrDefault(); - }); - } - - public List<RenameScript> GetByRenamerType(string renamerType) - { - if (string.IsNullOrEmpty(renamerType)) return new(); - using var session = _databaseFactory.SessionFactory.OpenSession(); - var cr = session - .CreateCriteria(typeof(RenameScript)) - .Add(Restrictions.Eq("RenamerType", renamerType)) - .List<RenameScript>() - .ToList(); - return cr; - } - - public RenameScriptRepository(DatabaseFactory databaseFactory) : base(databaseFactory) - { - } -} diff --git a/Shoko.Server/Repositories/Direct/RenamerConfigRepository.cs b/Shoko.Server/Repositories/Direct/RenamerConfigRepository.cs new file mode 100644 index 000000000..66ccbe616 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/RenamerConfigRepository.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class RenamerConfigRepository : BaseDirectRepository<RenamerConfig, int> +{ + + public RenamerConfig? GetByName(string? scriptName) + { + if (string.IsNullOrEmpty(scriptName)) + return null; + + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<RenamerConfig>() + .Where(a => a.Name == scriptName) + .Take(1) + .SingleOrDefault(); + }); + } + + public RenamerConfigRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrderingRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrderingRepository.cs new file mode 100644 index 000000000..eaae331e9 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrderingRepository.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_AlternateOrderingRepository : BaseDirectRepository<TMDB_AlternateOrdering, int> +{ + public IReadOnlyList<TMDB_AlternateOrdering> GetByTmdbShowID(int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering>() + .Where(a => a.TmdbShowID == showId) + .ToList(); + }); + } + + public TMDB_AlternateOrdering? GetByTmdbEpisodeGroupCollectionID(string episodeGroupCollectionId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering>() + .Where(a => a.TmdbEpisodeGroupCollectionID == episodeGroupCollectionId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_AlternateOrdering? GetByEpisodeGroupCollectionAndShowIDs(string collectionId, int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering>() + .Where(a => a.TmdbEpisodeGroupCollectionID == collectionId && a.TmdbShowID == showId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_AlternateOrderingRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrdering_EpisodeRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrdering_EpisodeRepository.cs new file mode 100644 index 000000000..1ba045ed4 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrdering_EpisodeRepository.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_AlternateOrdering_EpisodeRepository : BaseDirectRepository<TMDB_AlternateOrdering_Episode, int> +{ + public IReadOnlyList<TMDB_AlternateOrdering_Episode> GetByTmdbShowID(int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Episode>() + .Where(a => a.TmdbShowID == showId) + .OrderBy(a => a.TmdbEpisodeGroupCollectionID) + .ThenBy(e => e.SeasonNumber == 0) + .ThenBy(e => e.SeasonNumber) + .ThenBy(xref => xref.EpisodeNumber) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_AlternateOrdering_Episode> GetByTmdbEpisodeGroupCollectionID(string collectionId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Episode>() + .Where(a => a.TmdbEpisodeGroupCollectionID == collectionId) + .OrderBy(e => e.SeasonNumber == 0) + .ThenBy(e => e.SeasonNumber) + .ThenBy(xref => xref.EpisodeNumber) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_AlternateOrdering_Episode> GetByTmdbEpisodeGroupID(string groupId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Episode>() + .Where(a => a.TmdbEpisodeGroupID == groupId) + .OrderBy(xref => xref.EpisodeNumber) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_AlternateOrdering_Episode> GetByTmdbEpisodeID(int episodeId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Episode>() + .Where(a => a.TmdbEpisodeID == episodeId) + .OrderBy(a => a.TmdbEpisodeGroupID) + .ToList(); + }); + } + + public TMDB_AlternateOrdering_Episode? GetByEpisodeGroupCollectionAndEpisodeIDs(string collectionId, int episodeId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Episode>() + .Where(a => a.TmdbEpisodeGroupCollectionID == collectionId && a.TmdbEpisodeID == episodeId) + .OrderBy(a => a.SeasonNumber) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_AlternateOrdering_EpisodeRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrdering_SeasonRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrdering_SeasonRepository.cs new file mode 100644 index 000000000..238d34276 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_AlternateOrdering_SeasonRepository.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_AlternateOrdering_SeasonRepository : BaseDirectRepository<TMDB_AlternateOrdering_Season, int> +{ + public IReadOnlyList<TMDB_AlternateOrdering_Season> GetByTmdbShowID(int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Season>() + .Where(a => a.TmdbShowID == showId) + .OrderBy(a => a.TmdbEpisodeGroupCollectionID) + .ThenBy(e => e.SeasonNumber == 0) + .ThenBy(e => e.SeasonNumber) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_AlternateOrdering_Season> GetByTmdbEpisodeGroupCollectionID(string collectionId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Season>() + .Where(a => a.TmdbEpisodeGroupCollectionID == collectionId) + .OrderBy(e => e.SeasonNumber == 0) + .ThenBy(e => e.SeasonNumber) + .ToList(); + }); + } + + public TMDB_AlternateOrdering_Season? GetByTmdbEpisodeGroupID(string groupId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_AlternateOrdering_Season>() + .Where(a => a.TmdbEpisodeGroupID == groupId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_AlternateOrdering_SeasonRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_CollectionRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_CollectionRepository.cs new file mode 100644 index 000000000..17445cca3 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_CollectionRepository.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_CollectionRepository : BaseDirectRepository<TMDB_Collection, int> +{ + public TMDB_Collection? GetByTmdbCollectionID(int collectionId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Collection>() + .Where(a => a.TmdbCollectionID == collectionId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_CollectionRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_Collection_MovieRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_Collection_MovieRepository.cs new file mode 100644 index 000000000..3d137b230 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_Collection_MovieRepository.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_Collection_MovieRepository : BaseDirectRepository<TMDB_Collection_Movie, int> +{ + public IReadOnlyList<TMDB_Collection_Movie> GetByTmdbCollectionID(int collectionId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Collection_Movie>() + .Where(a => a.TmdbCollectionID == collectionId) + .ToList(); + }); + } + + public TMDB_Collection_Movie? GetByTmdbMovieID(int movieId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Collection_Movie>() + .Where(a => a.TmdbMovieID == movieId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_Collection_MovieRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_NetworkRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_NetworkRepository.cs new file mode 100644 index 000000000..ed53b19f7 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_NetworkRepository.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_NetworkRepository : BaseDirectRepository<TMDB_Network, int> +{ + public TMDB_Network? GetByTmdbNetworkID(int tmdbNetworkId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Network>() + .Where(a => a.TmdbNetworkID == tmdbNetworkId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_NetworkRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_Show_NetworkRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_Show_NetworkRepository.cs new file mode 100644 index 000000000..bd8e124c8 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Optional/TMDB_Show_NetworkRepository.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_Show_NetworkRepository : BaseDirectRepository<TMDB_Show_Network, int> +{ + public IReadOnlyList<TMDB_Show_Network> GetByTmdbNetworkID(int networkId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Show_Network>() + .Where(a => a.TmdbNetworkID == networkId) + .OrderBy(e => e.TmdbShowID) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Show_Network> GetByTmdbShowID(int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Show_Network>() + .Where(a => a.TmdbShowID == showId) + .OrderBy(e => e.Ordering) + .ToList(); + }); + } + + public TMDB_Show_NetworkRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_CompanyRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_CompanyRepository.cs new file mode 100644 index 000000000..2b0691282 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_CompanyRepository.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_CompanyRepository : BaseDirectRepository<TMDB_Company, int> +{ + public TMDB_Company? GetByTmdbCompanyID(int companyId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Company>() + .Where(a => a.TmdbCompanyID == companyId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_CompanyRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_Company_EntityRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Company_EntityRepository.cs new file mode 100644 index 000000000..6558a20cb --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Company_EntityRepository.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_Company_EntityRepository : BaseDirectRepository<TMDB_Company_Entity, int> +{ + public IReadOnlyList<TMDB_Company_Entity> GetByTmdbCompanyID(int companyId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Company_Entity>() + .Where(a => a.TmdbCompanyID == companyId) + .OrderBy(xref => xref.ReleasedAt ?? DateOnly.MaxValue) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Company_Entity> GetByTmdbEntityTypeAndID(ForeignEntityType entityType, int entityId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Company_Entity>() + .Where(a => a.TmdbEntityType == entityType && a.TmdbEntityID == entityId) + .OrderBy(xref => xref.Ordering) + .ToList(); + }); + } + + public TMDB_Company_EntityRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_EpisodeRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_EpisodeRepository.cs new file mode 100644 index 000000000..479af70ca --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_EpisodeRepository.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_EpisodeRepository : BaseDirectRepository<TMDB_Episode, int> +{ + public IReadOnlyList<TMDB_Episode> GetByTmdbShowID(int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode>() + .Where(a => a.TmdbShowID == showId) + .OrderBy(e => e.SeasonNumber == 0) + .ThenBy(e => e.SeasonNumber) + .ThenBy(e => e.EpisodeNumber) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Episode> GetByTmdbSeasonID(int seasonId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode>() + .Where(a => a.TmdbSeasonID == seasonId) + .OrderBy(e => e.EpisodeNumber) + .ToList(); + }); + } + + public TMDB_Episode? GetByTmdbEpisodeID(int episodeId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode>() + .Where(a => a.TmdbEpisodeID == episodeId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_EpisodeRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_Episode_CastRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Episode_CastRepository.cs new file mode 100644 index 000000000..b0d9ab02a --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Episode_CastRepository.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_Episode_CastRepository : BaseDirectRepository<TMDB_Episode_Cast, int> +{ + public IReadOnlyList<TMDB_Episode_Cast> GetByTmdbPersonID(int personId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Cast>() + .Where(a => a.TmdbPersonID == personId) + .OrderBy(e => e.TmdbShowID) + .ThenBy(e => e.TmdbEpisodeID) + .ThenBy(e => e.Ordering) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Episode_Cast> GetByTmdbShowID(int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Cast>() + .Where(a => a.TmdbShowID == showId) + .OrderBy(e => e.TmdbEpisodeID) + .ThenBy(e => e.Ordering) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Episode_Cast> GetByTmdbSeasonID(int seasonId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Cast>() + .Where(a => a.TmdbSeasonID == seasonId) + .OrderBy(e => e.TmdbEpisodeID) + .ThenBy(e => e.Ordering) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Episode_Cast> GetByTmdbEpisodeID(int episodeId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Cast>() + .Where(a => a.TmdbEpisodeID == episodeId) + .OrderBy(e => e.Ordering) + .ToList(); + }); + } + + public TMDB_Episode_CastRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_Episode_CrewRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Episode_CrewRepository.cs new file mode 100644 index 000000000..8ace20e6d --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Episode_CrewRepository.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_Episode_CrewRepository : BaseDirectRepository<TMDB_Episode_Crew, int> +{ + public IReadOnlyList<TMDB_Episode_Crew> GetByTmdbPersonID(int personId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Crew>() + .Where(a => a.TmdbPersonID == personId) + .OrderBy(e => e.TmdbShowID) + .ThenBy(e => e.Department) + .ThenBy(e => e.Job) + .ThenBy(e => e.TmdbCreditID) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Episode_Crew> GetByTmdbShowID(int showId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Crew>() + .Where(a => a.TmdbShowID == showId) + .OrderBy(e => e.Department) + .ThenBy(e => e.Job) + .ThenBy(e => e.TmdbCreditID) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Episode_Crew> GetByTmdbSeasonID(int seasonId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Crew>() + .Where(a => a.TmdbSeasonID == seasonId) + .OrderBy(e => e.Department) + .ThenBy(e => e.Job) + .ThenBy(e => e.TmdbCreditID) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Episode_Crew> GetByTmdbEpisodeID(int episodeId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Episode_Crew>() + .Where(a => a.TmdbEpisodeID == episodeId) + .OrderBy(e => e.Department) + .ThenBy(e => e.Job) + .ThenBy(e => e.TmdbCreditID) + .ToList(); + }); + } + + public TMDB_Episode_CrewRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_MovieRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_MovieRepository.cs new file mode 100644 index 000000000..8cd75e363 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_MovieRepository.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories.NHibernate; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_MovieRepository : BaseDirectRepository<TMDB_Movie, int> +{ + public TMDB_Movie? GetByTmdbMovieID(int tmdbMovieId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Movie>() + .Where(a => a.TmdbMovieID == tmdbMovieId) + .Take(1) + .SingleOrDefault(); + }); + } + + public IReadOnlyList<TMDB_Movie> GetByTmdbCollectionID(int tmdbCollectionId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Movie>() + .Where(a => a.TmdbCollectionID == tmdbCollectionId) + .OrderBy(a => a.EnglishTitle) + .ThenBy(a => a.TmdbMovieID) + .ToList(); + }); + } + + public Dictionary<int, Tuple<CrossRef_AniDB_TMDB_Movie, TMDB_Movie>> GetByAnimeIDs(ISessionWrapper session, + int[] animeIds) + { + ArgumentNullException.ThrowIfNull(session, nameof(session)); + ArgumentNullException.ThrowIfNull(animeIds, nameof(animeIds)); + + if (animeIds.Length == 0) + return []; + + return Lock(() => + { + var movieByAnime = session.CreateSQLQuery( + @" + SELECT {cr.*}, {movie.*} + FROM CrossRef_AniDB_TMDB_Movie cr + INNER JOIN TMDB_Movie movie + ON movie.TmdbMovieID = cr.TmdbMovieID + WHERE cr.AnidbAnimeID IN (:animeIds)" + ) + .AddEntity("cr", typeof(CrossRef_AniDB_TMDB_Movie)) + .AddEntity("movie", typeof(TMDB_Movie)) + .SetParameterList("animeIds", animeIds) + .List<object[]>() + .ToDictionary( + r => ((CrossRef_AniDB_TMDB_Movie)r[0]).AnidbAnimeID, + r => new Tuple<CrossRef_AniDB_TMDB_Movie, TMDB_Movie>( + (CrossRef_AniDB_TMDB_Movie)r[0], + (TMDB_Movie)r[1] + ) + ); + + return movieByAnime; + }); + } + + public TMDB_MovieRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_Movie_CastRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Movie_CastRepository.cs new file mode 100644 index 000000000..87b58514f --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Movie_CastRepository.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_Movie_CastRepository : BaseDirectRepository<TMDB_Movie_Cast, int> +{ + public IReadOnlyList<TMDB_Movie_Cast> GetByTmdbPersonID(int personId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Movie_Cast>() + .Where(a => a.TmdbPersonID == personId) + .OrderBy(e => e.TmdbMovieID) + .ThenBy(e => e.Ordering) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Movie_Cast> GetByTmdbMovieID(int movieId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Movie_Cast>() + .Where(a => a.TmdbMovieID == movieId) + .OrderBy(e => e.Ordering) + .ToList(); + }); + } + + public TMDB_Movie_CastRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_Movie_CrewRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Movie_CrewRepository.cs new file mode 100644 index 000000000..98af70772 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_Movie_CrewRepository.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_Movie_CrewRepository : BaseDirectRepository<TMDB_Movie_Crew, int> +{ + public IReadOnlyList<TMDB_Movie_Crew> GetByTmdbPersonID(int personId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Movie_Crew>() + .Where(a => a.TmdbPersonID == personId) + .OrderBy(e => e.TmdbMovieID) + .ThenBy(e => e.Department) + .ThenBy(e => e.Job) + .ThenBy(e => e.TmdbCreditID) + .ToList(); + }); + } + + public IReadOnlyList<TMDB_Movie_Crew> GetByTmdbMovieID(int movieId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Movie_Crew>() + .Where(a => a.TmdbMovieID == movieId) + .OrderBy(e => e.Department) + .ThenBy(e => e.Job) + .ThenBy(e => e.TmdbCreditID) + .ToList(); + }); + } + + public TMDB_Movie_CrewRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_PersonRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_PersonRepository.cs new file mode 100644 index 000000000..aa7dc68c1 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_PersonRepository.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_PersonRepository : BaseDirectRepository<TMDB_Person, int> +{ + public TMDB_Person? GetByTmdbPersonID(int creditId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Person>() + .Where(a => a.TmdbPersonID == creditId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_PersonRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_SeasonRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_SeasonRepository.cs new file mode 100644 index 000000000..6900116a6 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_SeasonRepository.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_SeasonRepository : BaseDirectRepository<TMDB_Season, int> +{ + public IReadOnlyList<TMDB_Season> GetByTmdbShowID(int tmdbShowId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Season>() + .Where(a => a.TmdbShowID == tmdbShowId) + .OrderBy(e => e.SeasonNumber == 0) + .ThenBy(e => e.SeasonNumber) + .ToList(); + }); + } + + public TMDB_Season? GetByTmdbSeasonID(int tmdbSeasonId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Season>() + .Where(a => a.TmdbSeasonID == tmdbSeasonId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_SeasonRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/TMDB_ShowRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/TMDB_ShowRepository.cs new file mode 100644 index 000000000..a3e89f1aa --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/TMDB_ShowRepository.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_ShowRepository : BaseDirectRepository<TMDB_Show, int> +{ + public TMDB_Show? GetByTmdbShowID(int tmdbShowId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Show>() + .Where(a => a.TmdbShowID == tmdbShowId) + .Take(1) + .SingleOrDefault(); + }); + } + + public TMDB_ShowRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Text/TMDB_OverviewRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Text/TMDB_OverviewRepository.cs new file mode 100644 index 000000000..53682813f --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Text/TMDB_OverviewRepository.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_OverviewRepository : BaseDirectRepository<TMDB_Overview, int> +{ + public IReadOnlyList<TMDB_Overview> GetByParentTypeAndID(ForeignEntityType parentType, int parentId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Overview>() + .Where(a => a.ParentType == parentType && a.ParentID == parentId) + .ToList(); + }); + } + + public TMDB_OverviewRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/TMDB/Text/TMDB_TitleRepository.cs b/Shoko.Server/Repositories/Direct/TMDB/Text/TMDB_TitleRepository.cs new file mode 100644 index 000000000..418519389 --- /dev/null +++ b/Shoko.Server/Repositories/Direct/TMDB/Text/TMDB_TitleRepository.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Linq; +using Shoko.Server.Databases; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Server; + +#nullable enable +namespace Shoko.Server.Repositories.Direct; + +public class TMDB_TitleRepository : BaseDirectRepository<TMDB_Title, int> +{ + public IReadOnlyList<TMDB_Title> GetByParentTypeAndID(ForeignEntityType parentType, int parentId) + { + return Lock(() => + { + using var session = _databaseFactory.SessionFactory.OpenSession(); + return session + .Query<TMDB_Title>() + .Where(a => a.ParentType == parentType && a.ParentID == parentId) + .ToList(); + }); + } + + public TMDB_TitleRepository(DatabaseFactory databaseFactory) : base(databaseFactory) + { + } +} diff --git a/Shoko.Server/Repositories/Direct/Trakt_ShowRepository.cs b/Shoko.Server/Repositories/Direct/Trakt_ShowRepository.cs index 711c52742..3d8cc678c 100644 --- a/Shoko.Server/Repositories/Direct/Trakt_ShowRepository.cs +++ b/Shoko.Server/Repositories/Direct/Trakt_ShowRepository.cs @@ -1,6 +1,6 @@ using System.Linq; using NHibernate; -using Shoko.Models.Server; +using Shoko.Server.Models.Trakt; using Shoko.Server.Databases; namespace Shoko.Server.Repositories.Direct; diff --git a/Shoko.Server/Repositories/RepoFactory.cs b/Shoko.Server/Repositories/RepoFactory.cs index cb4309674..8ff4d2b94 100644 --- a/Shoko.Server/Repositories/RepoFactory.cs +++ b/Shoko.Server/Repositories/RepoFactory.cs @@ -9,189 +9,282 @@ // ReSharper disable InconsistentNaming +#pragma warning disable CA2211 namespace Shoko.Server.Repositories; public class RepoFactory { - private readonly ILogger<RepoFactory> logger; - private readonly ICachedRepository[] CachedRepositories; + private readonly ILogger<RepoFactory> _logger; + private readonly ICachedRepository[] _cachedRepositories; - public static VersionsRepository Versions; - public static Trakt_ShowRepository Trakt_Show; - public static Trakt_SeasonRepository Trakt_Season; - public static Trakt_EpisodeRepository Trakt_Episode; - public static ScheduledUpdateRepository ScheduledUpdate; - public static RenameScriptRepository RenameScript; - public static PlaylistRepository Playlist; - public static MovieDB_PosterRepository MovieDB_Poster; - public static MovieDB_FanartRepository MovieDB_Fanart; - public static MovieDb_MovieRepository MovieDb_Movie; - public static IgnoreAnimeRepository IgnoreAnime; - public static FileNameHashRepository FileNameHash; - public static AniDB_AnimeUpdateRepository AniDB_AnimeUpdate; - public static AniDB_FileUpdateRepository AniDB_FileUpdate; - public static CrossRef_Subtitles_AniDB_FileRepository CrossRef_Subtitles_AniDB_File; - public static CrossRef_Languages_AniDB_FileRepository CrossRef_Languages_AniDB_File; - public static CrossRef_AniDB_OtherRepository CrossRef_AniDB_Other; - public static CrossRef_AniDB_MALRepository CrossRef_AniDB_MAL; - public static BookmarkedAnimeRepository BookmarkedAnime; - public static AniDB_SeiyuuRepository AniDB_Seiyuu; - public static AniDB_ReleaseGroupRepository AniDB_ReleaseGroup; - public static AniDB_GroupStatusRepository AniDB_GroupStatus; - public static AniDB_CharacterRepository AniDB_Character; - public static AniDB_Character_SeiyuuRepository AniDB_Character_Seiyuu; - public static AniDB_Anime_SimilarRepository AniDB_Anime_Similar; - public static AniDB_Anime_RelationRepository AniDB_Anime_Relation; - public static AniDB_Anime_DefaultImageRepository AniDB_Anime_DefaultImage; public static AniDB_Anime_CharacterRepository AniDB_Anime_Character; + public static AniDB_Anime_PreferredImageRepository AniDB_Anime_PreferredImage; + public static AniDB_Anime_RelationRepository AniDB_Anime_Relation; + public static AniDB_Anime_SimilarRepository AniDB_Anime_Similar; public static AniDB_Anime_StaffRepository AniDB_Anime_Staff; - public static ScanRepository Scan; - public static ScanFileRepository ScanFile; - - public static JMMUserRepository JMMUser; - public static AuthTokensRepository AuthTokens; - public static ImportFolderRepository ImportFolder; + public static AniDB_Anime_TagRepository AniDB_Anime_Tag; + public static AniDB_Anime_TitleRepository AniDB_Anime_Title; public static AniDB_AnimeRepository AniDB_Anime; + public static AniDB_AnimeUpdateRepository AniDB_AnimeUpdate; + public static AniDB_Character_CreatorRepository AniDB_Character_Creator; + public static AniDB_CharacterRepository AniDB_Character; + public static AniDB_CreatorRepository AniDB_Creator; + public static AniDB_Episode_PreferredImageRepository AniDB_Episode_PreferredImage; public static AniDB_Episode_TitleRepository AniDB_Episode_Title; public static AniDB_EpisodeRepository AniDB_Episode; public static AniDB_FileRepository AniDB_File; - public static AniDB_Anime_TitleRepository AniDB_Anime_Title; - public static AniDB_Anime_TagRepository AniDB_Anime_Tag; + public static AniDB_FileUpdateRepository AniDB_FileUpdate; + public static AniDB_GroupStatusRepository AniDB_GroupStatus; + public static AniDB_MessageRepository AniDB_Message; + public static AniDB_NotifyQueueRepository AniDB_NotifyQueue; + public static AniDB_ReleaseGroupRepository AniDB_ReleaseGroup; public static AniDB_TagRepository AniDB_Tag; - public static CustomTagRepository CustomTag; - public static CrossRef_CustomTagRepository CrossRef_CustomTag; - public static CrossRef_File_EpisodeRepository CrossRef_File_Episode; - public static VideoLocal_PlaceRepository VideoLocalPlace; - public static VideoLocalRepository VideoLocal; - public static VideoLocal_UserRepository VideoLocalUser; - public static AnimeEpisodeRepository AnimeEpisode; - public static AnimeEpisode_UserRepository AnimeEpisode_User; - public static AnimeSeriesRepository AnimeSeries; - public static AnimeSeries_UserRepository AnimeSeries_User; - public static AnimeGroupRepository AnimeGroup; - public static AnimeGroup_UserRepository AnimeGroup_User; public static AniDB_VoteRepository AniDB_Vote; - public static TvDB_EpisodeRepository TvDB_Episode; - public static TvDB_SeriesRepository TvDB_Series; - public static CrossRef_AniDB_TvDBRepository CrossRef_AniDB_TvDB; - public static CrossRef_AniDB_TvDB_EpisodeRepository CrossRef_AniDB_TvDB_Episode; - public static CrossRef_AniDB_TvDB_Episode_OverrideRepository CrossRef_AniDB_TvDB_Episode_Override; - public static TvDB_ImagePosterRepository TvDB_ImagePoster; - public static TvDB_ImageFanartRepository TvDB_ImageFanart; - public static TvDB_ImageWideBannerRepository TvDB_ImageWideBanner; - public static CrossRef_AniDB_TraktV2Repository CrossRef_AniDB_TraktV2; public static AnimeCharacterRepository AnimeCharacter; + public static AnimeEpisode_UserRepository AnimeEpisode_User; + public static AnimeEpisodeRepository AnimeEpisode; + public static AnimeGroup_UserRepository AnimeGroup_User; + public static AnimeGroupRepository AnimeGroup; + public static AnimeSeries_UserRepository AnimeSeries_User; + public static AnimeSeriesRepository AnimeSeries; public static AnimeStaffRepository AnimeStaff; + public static AuthTokensRepository AuthTokens; + public static BookmarkedAnimeRepository BookmarkedAnime; + public static CrossRef_AniDB_MALRepository CrossRef_AniDB_MAL; + public static CrossRef_AniDB_TMDB_EpisodeRepository CrossRef_AniDB_TMDB_Episode; + public static CrossRef_AniDB_TMDB_MovieRepository CrossRef_AniDB_TMDB_Movie; + public static CrossRef_AniDB_TMDB_ShowRepository CrossRef_AniDB_TMDB_Show; + public static CrossRef_AniDB_TraktV2Repository CrossRef_AniDB_TraktV2; public static CrossRef_Anime_StaffRepository CrossRef_Anime_Staff; + public static CrossRef_CustomTagRepository CrossRef_CustomTag; + public static CrossRef_File_EpisodeRepository CrossRef_File_Episode; + public static CrossRef_Languages_AniDB_FileRepository CrossRef_Languages_AniDB_File; + public static CrossRef_Subtitles_AniDB_FileRepository CrossRef_Subtitles_AniDB_File; + public static CustomTagRepository CustomTag; + public static FileNameHashRepository FileNameHash; public static FilterPresetRepository FilterPreset; + public static IgnoreAnimeRepository IgnoreAnime; + public static ImportFolderRepository ImportFolder; + public static JMMUserRepository JMMUser; + public static PlaylistRepository Playlist; + public static RenamerConfigRepository RenamerConfig; + public static ScanFileRepository ScanFile; + public static ScanRepository Scan; + public static ScheduledUpdateRepository ScheduledUpdate; + public static TMDB_AlternateOrdering_EpisodeRepository TMDB_AlternateOrdering_Episode; + public static TMDB_AlternateOrdering_SeasonRepository TMDB_AlternateOrdering_Season; + public static TMDB_AlternateOrderingRepository TMDB_AlternateOrdering; + public static TMDB_Collection_MovieRepository TMDB_Collection_Movie; + public static TMDB_CollectionRepository TMDB_Collection; + public static TMDB_Company_EntityRepository TMDB_Company_Entity; + public static TMDB_CompanyRepository TMDB_Company; + public static TMDB_Episode_CastRepository TMDB_Episode_Cast; + public static TMDB_Episode_CrewRepository TMDB_Episode_Crew; + public static TMDB_EpisodeRepository TMDB_Episode; + public static TMDB_ImageRepository TMDB_Image; + public static TMDB_Movie_CastRepository TMDB_Movie_Cast; + public static TMDB_Movie_CrewRepository TMDB_Movie_Crew; + public static TMDB_MovieRepository TMDB_Movie; + public static TMDB_NetworkRepository TMDB_Network; + public static TMDB_OverviewRepository TMDB_Overview; + public static TMDB_PersonRepository TMDB_Person; + public static TMDB_SeasonRepository TMDB_Season; + public static TMDB_Show_NetworkRepository TMDB_Show_Network; + public static TMDB_ShowRepository TMDB_Show; + public static TMDB_TitleRepository TMDB_Title; + public static Trakt_EpisodeRepository Trakt_Episode; + public static Trakt_SeasonRepository Trakt_Season; + public static Trakt_ShowRepository Trakt_Show; + public static VersionsRepository Versions; + public static VideoLocal_PlaceRepository VideoLocalPlace; + public static VideoLocal_UserRepository VideoLocalUser; + public static VideoLocalRepository VideoLocal; - public RepoFactory(ILogger<RepoFactory> logger, IEnumerable<ICachedRepository> repositories, VersionsRepository versions, Trakt_ShowRepository traktShow, - Trakt_SeasonRepository traktSeason, Trakt_EpisodeRepository traktEpisode, ScheduledUpdateRepository scheduledUpdate, - RenameScriptRepository renameScript, PlaylistRepository playlist, MovieDB_PosterRepository movieDBPoster, MovieDB_FanartRepository movieDBFanart, - MovieDb_MovieRepository movieDbMovie, IgnoreAnimeRepository ignoreAnime, FileNameHashRepository fileNameHash, - AniDB_AnimeUpdateRepository aniDBAnimeUpdate, AniDB_FileUpdateRepository aniDBFileUpdate, - CrossRef_Subtitles_AniDB_FileRepository crossRefSubtitlesAniDBFile, CrossRef_Languages_AniDB_FileRepository crossRefLanguagesAniDBFile, - CrossRef_AniDB_OtherRepository crossRefAniDBOther, CrossRef_AniDB_MALRepository crossRefAniDBMal, BookmarkedAnimeRepository bookmarkedAnime, - AniDB_SeiyuuRepository aniDBSeiyuu, AniDB_ReleaseGroupRepository aniDBReleaseGroup, AniDB_GroupStatusRepository aniDBGroupStatus, - AniDB_CharacterRepository aniDBCharacter, AniDB_Character_SeiyuuRepository aniDBCharacterSeiyuu, AniDB_Anime_SimilarRepository aniDBAnimeSimilar, - AniDB_Anime_RelationRepository aniDBAnimeRelation, AniDB_Anime_DefaultImageRepository aniDBAnimeDefaultImage, - AniDB_Anime_CharacterRepository aniDBAnimeCharacter, AniDB_Anime_StaffRepository aniDBAnimeStaff, ScanRepository scan, ScanFileRepository scanFile, - JMMUserRepository jmmUser, AuthTokensRepository authTokens, ImportFolderRepository importFolder, AniDB_AnimeRepository aniDBAnime, - AniDB_Episode_TitleRepository aniDBEpisodeTitle, AniDB_EpisodeRepository aniDBEpisode, AniDB_FileRepository aniDBFile, - AniDB_Anime_TitleRepository aniDBAnimeTitle, AniDB_Anime_TagRepository aniDBAnimeTag, AniDB_TagRepository aniDBTag, CustomTagRepository customTag, - CrossRef_CustomTagRepository crossRefCustomTag, CrossRef_File_EpisodeRepository crossRefFileEpisode, VideoLocal_PlaceRepository videoLocalPlace, - VideoLocalRepository videoLocal, VideoLocal_UserRepository videoLocalUser, AnimeEpisodeRepository animeEpisode, - AnimeEpisode_UserRepository animeEpisodeUser, AnimeSeriesRepository animeSeries, AnimeSeries_UserRepository animeSeriesUser, - AnimeGroupRepository animeGroup, AnimeGroup_UserRepository animeGroupUser, AniDB_VoteRepository aniDBVote, TvDB_EpisodeRepository tvDBEpisode, - TvDB_SeriesRepository tvDBSeries, CrossRef_AniDB_TvDBRepository crossRefAniDBTvDB, CrossRef_AniDB_TvDB_EpisodeRepository crossRefAniDBTvDBEpisode, - CrossRef_AniDB_TvDB_Episode_OverrideRepository crossRefAniDBTvDBEpisodeOverride, TvDB_ImagePosterRepository tvDBImagePoster, - TvDB_ImageFanartRepository tvDBImageFanart, TvDB_ImageWideBannerRepository tvDBImageWideBanner, CrossRef_AniDB_TraktV2Repository crossRefAniDBTraktV2, - AnimeCharacterRepository animeCharacter, AnimeStaffRepository animeStaff, CrossRef_Anime_StaffRepository crossRefAnimeStaff, - FilterPresetRepository filterPreset) + public RepoFactory( + ILogger<RepoFactory> logger, + IEnumerable<ICachedRepository> repositories, + AniDB_Anime_CharacterRepository anidbAnimeCharacter, + AniDB_Anime_PreferredImageRepository anidbAnimePreferredImage, + AniDB_Anime_RelationRepository anidbAnimeRelation, + AniDB_Anime_SimilarRepository anidbAnimeSimilar, + AniDB_Anime_StaffRepository anidbAnimeStaff, + AniDB_Anime_TagRepository anidbAnimeTag, + AniDB_Anime_TitleRepository anidbAnimeTitle, + AniDB_AnimeRepository anidbAnime, + AniDB_AnimeUpdateRepository anidbAnimeUpdate, + AniDB_Character_CreatorRepository anidbCharacterCreator, + AniDB_CharacterRepository anidbCharacter, + AniDB_CreatorRepository anidbCreator, + AniDB_Episode_PreferredImageRepository anidbEpisodePreferredImage, + AniDB_Episode_TitleRepository anidbEpisodeTitle, + AniDB_EpisodeRepository anidbEpisode, + AniDB_FileRepository anidbFile, + AniDB_FileUpdateRepository anidbFileUpdate, + AniDB_GroupStatusRepository anidbGroupStatus, + AniDB_MessageRepository anidbMessage, + AniDB_NotifyQueueRepository anidbNotifyQueue, + AniDB_ReleaseGroupRepository anidbReleaseGroup, + AniDB_TagRepository anidbTag, + AniDB_VoteRepository anidbVote, + AnimeCharacterRepository animeCharacter, + AnimeEpisode_UserRepository animeEpisodeUser, + AnimeEpisodeRepository animeEpisode, + AnimeGroup_UserRepository animeGroupUser, + AnimeGroupRepository animeGroup, + AnimeSeries_UserRepository animeSeriesUser, + AnimeSeriesRepository animeSeries, + AnimeStaffRepository animeStaff, + AuthTokensRepository authTokens, + BookmarkedAnimeRepository bookmarkedAnime, + CrossRef_AniDB_MALRepository crossRefAniDBMal, + CrossRef_AniDB_TMDB_EpisodeRepository crossRefAniDBTmdbEpisode, + CrossRef_AniDB_TMDB_MovieRepository crossRefAniDBTmdbMovie, + CrossRef_AniDB_TMDB_ShowRepository crossRefAniDBTmdbShow, + CrossRef_AniDB_TraktV2Repository crossRefAniDBTraktV2, + CrossRef_Anime_StaffRepository crossRefAnimeStaff, + CrossRef_CustomTagRepository crossRefCustomTag, + CrossRef_File_EpisodeRepository crossRefFileEpisode, + CrossRef_Languages_AniDB_FileRepository crossRefLanguagesAniDBFile, + CrossRef_Subtitles_AniDB_FileRepository crossRefSubtitlesAniDBFile, + CustomTagRepository customTag, + FileNameHashRepository fileNameHash, + FilterPresetRepository filterPreset, + IgnoreAnimeRepository ignoreAnime, + ImportFolderRepository importFolder, + JMMUserRepository jmmUser, + PlaylistRepository playlist, + RenamerConfigRepository renamerConfig, + ScanFileRepository scanFile, + ScanRepository scan, + ScheduledUpdateRepository scheduledUpdate, + Trakt_EpisodeRepository traktEpisode, + Trakt_SeasonRepository traktSeason, + Trakt_ShowRepository traktShow, + TMDB_AlternateOrdering_EpisodeRepository tmdbAlternateOrderingEpisode, + TMDB_AlternateOrdering_SeasonRepository tmdbAlternateOrderingSeason, + TMDB_AlternateOrderingRepository tmdbAlternateOrdering, + TMDB_Collection_MovieRepository tmdbCollectionMovie, + TMDB_CollectionRepository tmdbCollection, + TMDB_Company_EntityRepository tmdbCompanyEntity, + TMDB_CompanyRepository tmdbCompany, + TMDB_Episode_CastRepository tmdbEpisodeCast, + TMDB_Episode_CrewRepository tmdbEpisodeCrew, + TMDB_EpisodeRepository tmdbEpisode, + TMDB_ImageRepository tmdbImage, + TMDB_Movie_CastRepository tmdbMovieCast, + TMDB_Movie_CrewRepository tmdbMovieCrew, + TMDB_MovieRepository tmdbMovie, + TMDB_NetworkRepository tmdbNetwork, + TMDB_OverviewRepository tmdbOverview, + TMDB_PersonRepository tmdbPerson, + TMDB_SeasonRepository tmdbSeason, + TMDB_Show_NetworkRepository tmdbShowNetwork, + TMDB_ShowRepository tmdbShow, + TMDB_TitleRepository tmdbTitle, + VersionsRepository versions, + VideoLocal_PlaceRepository videoLocalPlace, + VideoLocal_UserRepository videoLocalUser, + VideoLocalRepository videoLocal + ) { - this.logger = logger; - CachedRepositories = repositories.ToArray(); - Versions = versions; - Trakt_Show = traktShow; - Trakt_Season = traktSeason; - Trakt_Episode = traktEpisode; - ScheduledUpdate = scheduledUpdate; - RenameScript = renameScript; - Playlist = playlist; - MovieDB_Poster = movieDBPoster; - MovieDB_Fanart = movieDBFanart; - MovieDb_Movie = movieDbMovie; - IgnoreAnime = ignoreAnime; - FileNameHash = fileNameHash; - AniDB_AnimeUpdate = aniDBAnimeUpdate; - AniDB_FileUpdate = aniDBFileUpdate; - CrossRef_Subtitles_AniDB_File = crossRefSubtitlesAniDBFile; - CrossRef_Languages_AniDB_File = crossRefLanguagesAniDBFile; - CrossRef_AniDB_Other = crossRefAniDBOther; - CrossRef_AniDB_MAL = crossRefAniDBMal; - BookmarkedAnime = bookmarkedAnime; - AniDB_Seiyuu = aniDBSeiyuu; - AniDB_ReleaseGroup = aniDBReleaseGroup; - AniDB_GroupStatus = aniDBGroupStatus; - AniDB_Character = aniDBCharacter; - AniDB_Character_Seiyuu = aniDBCharacterSeiyuu; - AniDB_Anime_Similar = aniDBAnimeSimilar; - AniDB_Anime_Relation = aniDBAnimeRelation; - AniDB_Anime_DefaultImage = aniDBAnimeDefaultImage; - AniDB_Anime_Character = aniDBAnimeCharacter; - AniDB_Anime_Staff = aniDBAnimeStaff; - Scan = scan; - ScanFile = scanFile; - JMMUser = jmmUser; - AuthTokens = authTokens; - ImportFolder = importFolder; - AniDB_Anime = aniDBAnime; - AniDB_Episode_Title = aniDBEpisodeTitle; - AniDB_Episode = aniDBEpisode; - AniDB_File = aniDBFile; - AniDB_Anime_Title = aniDBAnimeTitle; - AniDB_Anime_Tag = aniDBAnimeTag; - AniDB_Tag = aniDBTag; - CustomTag = customTag; - CrossRef_CustomTag = crossRefCustomTag; - CrossRef_File_Episode = crossRefFileEpisode; - VideoLocalPlace = videoLocalPlace; - VideoLocal = videoLocal; - VideoLocalUser = videoLocalUser; + _logger = logger; + _cachedRepositories = repositories.ToArray(); + AniDB_Anime = anidbAnime; + AniDB_Anime_Character = anidbAnimeCharacter; + AniDB_Anime_PreferredImage = anidbAnimePreferredImage; + AniDB_Anime_Relation = anidbAnimeRelation; + AniDB_Anime_Similar = anidbAnimeSimilar; + AniDB_Anime_Staff = anidbAnimeStaff; + AniDB_Anime_Tag = anidbAnimeTag; + AniDB_Anime_Title = anidbAnimeTitle; + AniDB_AnimeUpdate = anidbAnimeUpdate; + AniDB_Character = anidbCharacter; + AniDB_Character_Creator = anidbCharacterCreator; + AniDB_Creator = anidbCreator; + AniDB_Episode = anidbEpisode; + AniDB_Episode_PreferredImage = anidbEpisodePreferredImage; + AniDB_Episode_Title = anidbEpisodeTitle; + AniDB_File = anidbFile; + AniDB_FileUpdate = anidbFileUpdate; + AniDB_GroupStatus = anidbGroupStatus; + AniDB_Message = anidbMessage; + AniDB_NotifyQueue = anidbNotifyQueue; + AniDB_ReleaseGroup = anidbReleaseGroup; + AniDB_Tag = anidbTag; + AniDB_Vote = anidbVote; + AnimeCharacter = animeCharacter; AnimeEpisode = animeEpisode; AnimeEpisode_User = animeEpisodeUser; - AnimeSeries = animeSeries; - AnimeSeries_User = animeSeriesUser; AnimeGroup = animeGroup; AnimeGroup_User = animeGroupUser; - AniDB_Vote = aniDBVote; - TvDB_Episode = tvDBEpisode; - TvDB_Series = tvDBSeries; - CrossRef_AniDB_TvDB = crossRefAniDBTvDB; - CrossRef_AniDB_TvDB_Episode = crossRefAniDBTvDBEpisode; - CrossRef_AniDB_TvDB_Episode_Override = crossRefAniDBTvDBEpisodeOverride; - TvDB_ImagePoster = tvDBImagePoster; - TvDB_ImageFanart = tvDBImageFanart; - TvDB_ImageWideBanner = tvDBImageWideBanner; - CrossRef_AniDB_TraktV2 = crossRefAniDBTraktV2; - AnimeCharacter = animeCharacter; + AnimeSeries = animeSeries; + AnimeSeries_User = animeSeriesUser; AnimeStaff = animeStaff; + AuthTokens = authTokens; + BookmarkedAnime = bookmarkedAnime; + CrossRef_AniDB_MAL = crossRefAniDBMal; + CrossRef_AniDB_TMDB_Episode = crossRefAniDBTmdbEpisode; + CrossRef_AniDB_TMDB_Movie = crossRefAniDBTmdbMovie; + CrossRef_AniDB_TMDB_Show = crossRefAniDBTmdbShow; + CrossRef_AniDB_TraktV2 = crossRefAniDBTraktV2; CrossRef_Anime_Staff = crossRefAnimeStaff; + CrossRef_CustomTag = crossRefCustomTag; + CrossRef_File_Episode = crossRefFileEpisode; + CrossRef_Languages_AniDB_File = crossRefLanguagesAniDBFile; + CrossRef_Subtitles_AniDB_File = crossRefSubtitlesAniDBFile; + CustomTag = customTag; + FileNameHash = fileNameHash; FilterPreset = filterPreset; + IgnoreAnime = ignoreAnime; + ImportFolder = importFolder; + JMMUser = jmmUser; + Playlist = playlist; + RenamerConfig = renamerConfig; + Scan = scan; + ScanFile = scanFile; + ScheduledUpdate = scheduledUpdate; + TMDB_AlternateOrdering = tmdbAlternateOrdering; + TMDB_AlternateOrdering_Episode = tmdbAlternateOrderingEpisode; + TMDB_AlternateOrdering_Season = tmdbAlternateOrderingSeason; + TMDB_Collection = tmdbCollection; + TMDB_Collection_Movie = tmdbCollectionMovie; + TMDB_Company = tmdbCompany; + TMDB_Company_Entity = tmdbCompanyEntity; + TMDB_Episode = tmdbEpisode; + TMDB_Episode_Cast = tmdbEpisodeCast; + TMDB_Episode_Crew = tmdbEpisodeCrew; + TMDB_Image = tmdbImage; + TMDB_Movie = tmdbMovie; + TMDB_Movie_Cast = tmdbMovieCast; + TMDB_Movie_Crew = tmdbMovieCrew; + TMDB_Network = tmdbNetwork; + TMDB_Overview = tmdbOverview; + TMDB_Person = tmdbPerson; + TMDB_Season = tmdbSeason; + TMDB_Show = tmdbShow; + TMDB_Show_Network = tmdbShowNetwork; + TMDB_Title = tmdbTitle; + Trakt_Episode = traktEpisode; + Trakt_Season = traktSeason; + Trakt_Show = traktShow; + Versions = versions; + VideoLocal = videoLocal; + VideoLocalPlace = videoLocalPlace; + VideoLocalUser = videoLocalUser; } public void Init() { try { - foreach (var repo in CachedRepositories) + foreach (var repo in _cachedRepositories) { repo.Populate(); } } catch (Exception exception) { - logger.LogError(exception, "There was an error starting the Database Factory - Caching: {Ex}", exception); + _logger.LogError(exception, "There was an error starting the Database Factory - Caching: {Ex}", exception); throw; } } @@ -201,22 +294,22 @@ public void PostInit() // Update Contracts if necessary try { - logger.LogInformation("Starting Server: RepoFactory.PostInit()"); - foreach (var repo in CachedRepositories) + _logger.LogInformation("Starting Server: RepoFactory.PostInit()"); + foreach (var repo in _cachedRepositories) { ServerState.Instance.ServerStartingStatus = string.Format( - Resources.Database_Validating, repo.GetType().Name.Replace("Repository", ""), " DbRegen"); + Resources.Database_Validating, repo.GetType().Name.Replace("Repository", ""), " Database Regeneration"); repo.RegenerateDb(); } - foreach (var repo in CachedRepositories) + foreach (var repo in _cachedRepositories) { repo.PostProcess(); } } catch (Exception e) { - logger.LogError(e, "There was an error starting the Database Factory - Regenerating: {Ex}", e); + _logger.LogError(e, "There was an error starting the Database Factory - Regenerating: {Ex}", e); throw; } } diff --git a/Shoko.Server/Repositories/RepositoryStartup.cs b/Shoko.Server/Repositories/RepositoryStartup.cs index 81c1c717b..9b05b9df2 100644 --- a/Shoko.Server/Repositories/RepositoryStartup.cs +++ b/Shoko.Server/Repositories/RepositoryStartup.cs @@ -13,7 +13,7 @@ public static IServiceCollection AddRepositories(this IServiceCollection service services.AddSingleton<RepoFactory>(); services.AddSingleton<DatabaseFactory>(); services.AddDirectRepository<AniDB_AnimeUpdateRepository>(); - + services.AddDirectRepository<AniDB_Anime_RelationRepository>(); services.AddDirectRepository<AniDB_Anime_SimilarRepository>(); services.AddDirectRepository<AniDB_Anime_StaffRepository>(); @@ -22,30 +22,51 @@ public static IServiceCollection AddRepositories(this IServiceCollection service services.AddDirectRepository<BookmarkedAnimeRepository>(); services.AddDirectRepository<FileNameHashRepository>(); services.AddDirectRepository<IgnoreAnimeRepository>(); - services.AddDirectRepository<MovieDB_PosterRepository>(); - services.AddDirectRepository<MovieDb_MovieRepository>(); services.AddDirectRepository<PlaylistRepository>(); - services.AddDirectRepository<RenameScriptRepository>(); + services.AddDirectRepository<RenamerConfigRepository>(); services.AddDirectRepository<ScanFileRepository>(); services.AddDirectRepository<ScanRepository>(); services.AddDirectRepository<ScheduledUpdateRepository>(); + services.AddDirectRepository<TMDB_AlternateOrdering_EpisodeRepository>(); + services.AddDirectRepository<TMDB_AlternateOrdering_SeasonRepository>(); + services.AddDirectRepository<TMDB_AlternateOrderingRepository>(); + services.AddDirectRepository<TMDB_Collection_MovieRepository>(); + services.AddDirectRepository<TMDB_CollectionRepository>(); + services.AddDirectRepository<TMDB_Company_EntityRepository>(); + services.AddDirectRepository<TMDB_CompanyRepository>(); + services.AddDirectRepository<TMDB_EpisodeRepository>(); + services.AddDirectRepository<TMDB_Episode_CastRepository>(); + services.AddDirectRepository<TMDB_Episode_CrewRepository>(); + services.AddDirectRepository<TMDB_Movie_CastRepository>(); + services.AddDirectRepository<TMDB_Movie_CrewRepository>(); + services.AddDirectRepository<TMDB_MovieRepository>(); + services.AddDirectRepository<TMDB_NetworkRepository>(); + services.AddDirectRepository<TMDB_OverviewRepository>(); + services.AddDirectRepository<TMDB_PersonRepository>(); + services.AddDirectRepository<TMDB_SeasonRepository>(); + services.AddDirectRepository<TMDB_ShowRepository>(); + services.AddDirectRepository<TMDB_Show_NetworkRepository>(); + services.AddDirectRepository<TMDB_TitleRepository>(); services.AddDirectRepository<Trakt_EpisodeRepository>(); services.AddDirectRepository<Trakt_SeasonRepository>(); services.AddDirectRepository<Trakt_ShowRepository>(); services.AddDirectRepository<VersionsRepository>(); + services.AddDirectRepository<AniDB_MessageRepository>(); + services.AddDirectRepository<AniDB_NotifyQueueRepository>(); services.AddCachedRepository<AniDB_AnimeRepository>(); services.AddCachedRepository<AniDB_Anime_CharacterRepository>(); - services.AddCachedRepository<AniDB_Anime_DefaultImageRepository>(); + services.AddCachedRepository<AniDB_Anime_PreferredImageRepository>(); services.AddCachedRepository<AniDB_Anime_TagRepository>(); services.AddCachedRepository<AniDB_Anime_TitleRepository>(); services.AddCachedRepository<AniDB_CharacterRepository>(); - services.AddCachedRepository<AniDB_Character_SeiyuuRepository>(); + services.AddCachedRepository<AniDB_Character_CreatorRepository>(); services.AddCachedRepository<AniDB_EpisodeRepository>(); + services.AddCachedRepository<AniDB_Episode_PreferredImageRepository>(); services.AddCachedRepository<AniDB_Episode_TitleRepository>(); services.AddCachedRepository<AniDB_FileRepository>(); services.AddCachedRepository<AniDB_ReleaseGroupRepository>(); - services.AddCachedRepository<AniDB_SeiyuuRepository>(); + services.AddCachedRepository<AniDB_CreatorRepository>(); services.AddCachedRepository<AniDB_TagRepository>(); services.AddCachedRepository<AniDB_VoteRepository>(); services.AddCachedRepository<AnimeCharacterRepository>(); @@ -58,11 +79,10 @@ public static IServiceCollection AddRepositories(this IServiceCollection service services.AddCachedRepository<AnimeStaffRepository>(); services.AddCachedRepository<AuthTokensRepository>(); services.AddCachedRepository<CrossRef_AniDB_MALRepository>(); - services.AddCachedRepository<CrossRef_AniDB_OtherRepository>(); + services.AddCachedRepository<CrossRef_AniDB_TMDB_EpisodeRepository>(); + services.AddCachedRepository<CrossRef_AniDB_TMDB_MovieRepository>(); + services.AddCachedRepository<CrossRef_AniDB_TMDB_ShowRepository>(); services.AddCachedRepository<CrossRef_AniDB_TraktV2Repository>(); - services.AddCachedRepository<CrossRef_AniDB_TvDBRepository>(); - services.AddCachedRepository<CrossRef_AniDB_TvDB_EpisodeRepository>(); - services.AddCachedRepository<CrossRef_AniDB_TvDB_Episode_OverrideRepository>(); services.AddCachedRepository<CrossRef_Anime_StaffRepository>(); services.AddCachedRepository<CrossRef_CustomTagRepository>(); services.AddCachedRepository<CrossRef_File_EpisodeRepository>(); @@ -72,12 +92,7 @@ public static IServiceCollection AddRepositories(this IServiceCollection service services.AddCachedRepository<FilterPresetRepository>(); services.AddCachedRepository<ImportFolderRepository>(); services.AddCachedRepository<JMMUserRepository>(); - services.AddCachedRepository<MovieDB_FanartRepository>(); - services.AddCachedRepository<TvDB_EpisodeRepository>(); - services.AddCachedRepository<TvDB_ImageFanartRepository>(); - services.AddCachedRepository<TvDB_ImagePosterRepository>(); - services.AddCachedRepository<TvDB_ImageWideBannerRepository>(); - services.AddCachedRepository<TvDB_SeriesRepository>(); + services.AddCachedRepository<TMDB_ImageRepository>(); services.AddCachedRepository<VideoLocalRepository>(); services.AddCachedRepository<VideoLocal_PlaceRepository>(); services.AddCachedRepository<VideoLocal_UserRepository>(); @@ -90,7 +105,7 @@ private static void AddDirectRepository<Repo>(this IServiceCollection services) services.AddSingleton<IDirectRepository, Repo>(); services.AddSingleton(s => (Repo)s.GetServices(typeof(IDirectRepository)).FirstOrDefault(a => a?.GetType() == typeof(Repo))); } - + private static void AddCachedRepository<Repo>(this IServiceCollection services) where Repo : class, ICachedRepository { services.AddSingleton<ICachedRepository, Repo>(); diff --git a/Shoko.Server/Scheduling/Attributes/JobKeyMemberAttribute.cs b/Shoko.Server/Scheduling/Attributes/JobKeyMemberAttribute.cs index eb98a0b3b..e58162280 100644 --- a/Shoko.Server/Scheduling/Attributes/JobKeyMemberAttribute.cs +++ b/Shoko.Server/Scheduling/Attributes/JobKeyMemberAttribute.cs @@ -1,5 +1,6 @@ using System; +#nullable enable namespace Shoko.Server.Scheduling.Attributes; [AttributeUsage(AttributeTargets.Property | AttributeTargets.Class)] diff --git a/Shoko.Server/Scheduling/Concurrency/ConcurrencyGroups.cs b/Shoko.Server/Scheduling/Concurrency/ConcurrencyGroups.cs index b064574e8..58d2b2fe2 100644 --- a/Shoko.Server/Scheduling/Concurrency/ConcurrencyGroups.cs +++ b/Shoko.Server/Scheduling/Concurrency/ConcurrencyGroups.cs @@ -4,7 +4,5 @@ public static class ConcurrencyGroups { public const string AniDB_UDP = "AniDB_UDP"; public const string AniDB_HTTP = "AniDB_HTTP"; - public const string TvDB = "TvDB"; public const string Trakt = "Trakt"; - public const string TMDB = "TMDB"; } diff --git a/Shoko.Server/Scheduling/Delegates/MySQLDelegate.cs b/Shoko.Server/Scheduling/Delegates/MySQLDelegate.cs index 9ebc44f08..02efcf1f6 100644 --- a/Shoko.Server/Scheduling/Delegates/MySQLDelegate.cs +++ b/Shoko.Server/Scheduling/Delegates/MySQLDelegate.cs @@ -65,13 +65,13 @@ private static string GetSelectPartInTypes(int index, bool limit) FROM {TablePrefixSubst}{TableTriggers} t WHERE t.{ColumnSchedulerName} = @schedulerName AND {ColumnTriggerState} = '{StateWaiting}' AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan))"; - private const string SelectBlockedTypeCountsSql= @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count + private const string SelectBlockedTypeCountsSql = @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count FROM {TablePrefixSubst}{TableTriggers} t JOIN {TablePrefixSubst}{TableJobDetails} jd ON (jd.{ColumnSchedulerName} = t.{ColumnSchedulerName} AND jd.{ColumnJobGroup} = t.{ColumnJobGroup} AND jd.{ColumnJobName} = t.{ColumnJobName}) WHERE t.{ColumnSchedulerName} = @schedulerName AND (({ColumnTriggerState} = '{StateWaiting}' AND jd.{ColumnJobClass} IN (@types)) OR {ColumnTriggerState} = '{StateBlocked}') AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan)) GROUP BY jd.{ColumnJobClass} HAVING COUNT(1) > 0"; - private const string SelectJobClassesAndCountSql= @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count + private const string SelectJobClassesAndCountSql = @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count FROM {TablePrefixSubst}{TableTriggers} t JOIN {TablePrefixSubst}{TableJobDetails} jd ON (jd.{ColumnSchedulerName} = t.{ColumnSchedulerName} AND jd.{ColumnJobGroup} = t.{ColumnJobGroup} AND jd.{ColumnJobName} = t.{ColumnJobName}) WHERE t.{ColumnSchedulerName} = @schedulerName AND {ColumnTriggerState} = '{StateWaiting}' AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan)) @@ -445,11 +445,10 @@ public virtual async ValueTask<Dictionary<Type, int>> SelectJobTypeCounts(Connec var jobDataMap = map != null ? new JobDataMap(map) : null; var blocked = GetBooleanFromDbValue(rs[Blocked]); - var job = new JobDetail + var job = new JobDetail(jobType) { Name = jobName, Group = jobGroup!, - JobType = new JobType(jobType), Description = description, RequestsRecovery = requestsRecovery, JobDataMap = jobDataMap! @@ -527,7 +526,7 @@ private async Task<IDictionary> GetMapFromProperties(DbDataReader rs, int idx) var map = ConvertFromProperty(properties); return map; } - + private static string GetString(IDataReader reader, string columnName) { var columnValue = reader[columnName]; @@ -535,7 +534,7 @@ private static string GetString(IDataReader reader, string columnName) { return null; } - return (string) columnValue; + return (string)columnValue; } /// <summary> diff --git a/Shoko.Server/Scheduling/Delegates/SQLiteDelegate.cs b/Shoko.Server/Scheduling/Delegates/SQLiteDelegate.cs index 20b529789..fd5c9d92c 100644 --- a/Shoko.Server/Scheduling/Delegates/SQLiteDelegate.cs +++ b/Shoko.Server/Scheduling/Delegates/SQLiteDelegate.cs @@ -83,13 +83,13 @@ private static string GetSelectPartInTypes(int index) FROM {TablePrefixSubst}{TableTriggers} t WHERE t.{ColumnSchedulerName} = @schedulerName AND {ColumnTriggerState} = '{StateWaiting}' AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan))"; - private const string SelectBlockedTypeCountsSql= @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count + private const string SelectBlockedTypeCountsSql = @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count FROM {TablePrefixSubst}{TableTriggers} t JOIN {TablePrefixSubst}{TableJobDetails} jd ON (jd.{ColumnSchedulerName} = t.{ColumnSchedulerName} AND jd.{ColumnJobGroup} = t.{ColumnJobGroup} AND jd.{ColumnJobName} = t.{ColumnJobName}) WHERE t.{ColumnSchedulerName} = @schedulerName AND (({ColumnTriggerState} = '{StateWaiting}' AND jd.{ColumnJobClass} IN (@types)) OR {ColumnTriggerState} = '{StateBlocked}') AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan)) GROUP BY jd.{ColumnJobClass} HAVING COUNT(1) > 0"; - private const string SelectJobClassesAndCountSql= @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count + private const string SelectJobClassesAndCountSql = @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count FROM {TablePrefixSubst}{TableTriggers} t JOIN {TablePrefixSubst}{TableJobDetails} jd ON (jd.{ColumnSchedulerName} = t.{ColumnSchedulerName} AND jd.{ColumnJobGroup} = t.{ColumnJobGroup} AND jd.{ColumnJobName} = t.{ColumnJobName}) WHERE t.{ColumnSchedulerName} = @schedulerName AND {ColumnTriggerState} = '{StateWaiting}' AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan)) @@ -485,11 +485,10 @@ public virtual async ValueTask<Dictionary<Type, int>> SelectJobTypeCounts(Connec details.Stop(); var blocked = GetBooleanFromDbValue(rs[Blocked]); - var job = new JobDetail + var job = new JobDetail(jobType) { Name = jobName, Group = jobGroup!, - JobType = new JobType(jobType), Description = description, RequestsRecovery = requestsRecovery, JobDataMap = jobDataMap! diff --git a/Shoko.Server/Scheduling/Delegates/SqlServerDelegate.cs b/Shoko.Server/Scheduling/Delegates/SqlServerDelegate.cs index b31f508ce..5d923e612 100644 --- a/Shoko.Server/Scheduling/Delegates/SqlServerDelegate.cs +++ b/Shoko.Server/Scheduling/Delegates/SqlServerDelegate.cs @@ -84,13 +84,13 @@ private static string GetSelectPartInTypes(int index) FROM {TablePrefixSubst}{TableTriggers} t WITH(NOLOCK) WHERE t.{ColumnSchedulerName} = @schedulerName AND {ColumnTriggerState} = '{StateWaiting}' AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan))"; - private const string SelectBlockedTypeCountsSql= @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count + private const string SelectBlockedTypeCountsSql = @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count FROM {TablePrefixSubst}{TableTriggers} t WITH(NOLOCK) JOIN {TablePrefixSubst}{TableJobDetails} jd ON (jd.{ColumnSchedulerName} = t.{ColumnSchedulerName} AND jd.{ColumnJobGroup} = t.{ColumnJobGroup} AND jd.{ColumnJobName} = t.{ColumnJobName}) WHERE t.{ColumnSchedulerName} = @schedulerName AND (({ColumnTriggerState} = '{StateWaiting}' AND jd.{ColumnJobClass} IN (@types)) OR {ColumnTriggerState} = '{StateBlocked}') AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan)) GROUP BY jd.{ColumnJobClass} HAVING COUNT(1) > 0"; - private const string SelectJobClassesAndCountSql= @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count + private const string SelectJobClassesAndCountSql = @$"SELECT jd.{ColumnJobClass}, COUNT(jd.{ColumnJobClass}) AS Count FROM {TablePrefixSubst}{TableTriggers} t WITH(NOLOCK) JOIN {TablePrefixSubst}{TableJobDetails} jd WITH(NOLOCK) ON (jd.{ColumnSchedulerName} = t.{ColumnSchedulerName} AND jd.{ColumnJobGroup} = t.{ColumnJobGroup} AND jd.{ColumnJobName} = t.{ColumnJobName}) WHERE t.{ColumnSchedulerName} = @schedulerName AND {ColumnTriggerState} = '{StateWaiting}' AND {ColumnNextFireTime} <= @noLaterThan AND ({ColumnMifireInstruction} = -1 OR ({ColumnMifireInstruction} <> -1 AND {ColumnNextFireTime} >= @noEarlierThan)) @@ -486,11 +486,10 @@ public virtual async ValueTask<Dictionary<Type, int>> SelectJobTypeCounts(Connec details.Stop(); var blocked = GetBooleanFromDbValue(rs[Blocked]); - var job = new JobDetail + var job = new JobDetail(jobType) { Name = jobName, Group = jobGroup!, - JobType = new JobType(jobType), Description = description, RequestsRecovery = requestsRecovery, JobDataMap = jobDataMap! @@ -574,7 +573,7 @@ private async Task<IDictionary> GetMapFromProperties(DbDataReader rs, int idx) var map = ConvertFromProperty(properties); return map; } - + private static string GetString(IDataReader reader, string columnName) { var columnValue = reader[columnName]; @@ -582,7 +581,7 @@ private static string GetString(IDataReader reader, string columnName) { return null; } - return (string) columnValue; + return (string)columnValue; } /// <summary> diff --git a/Shoko.Server/Scheduling/GenericJobBuilder/IJobConfigurator.cs b/Shoko.Server/Scheduling/GenericJobBuilder/IJobConfigurator.cs index 6b2497b2a..67dc04c96 100644 --- a/Shoko.Server/Scheduling/GenericJobBuilder/IJobConfigurator.cs +++ b/Shoko.Server/Scheduling/GenericJobBuilder/IJobConfigurator.cs @@ -1,6 +1,7 @@ using System; using Quartz; +#nullable enable namespace Shoko.Server.Scheduling.GenericJobBuilder; public interface IJobConfigurator { } diff --git a/Shoko.Server/Scheduling/GenericJobBuilder/IdentityExtensions.cs b/Shoko.Server/Scheduling/GenericJobBuilder/IdentityExtensions.cs index cc4e22758..544345243 100644 --- a/Shoko.Server/Scheduling/GenericJobBuilder/IdentityExtensions.cs +++ b/Shoko.Server/Scheduling/GenericJobBuilder/IdentityExtensions.cs @@ -1,5 +1,6 @@ using Quartz; +#nullable enable namespace Shoko.Server.Scheduling.GenericJobBuilder; public static class IdentityExtensions @@ -20,7 +21,7 @@ public static IJobConfiguratorWithDataAndIdentity<T> WithGeneratedIdentity<T>(th var key = JobKeyBuilder<T>.Create().WithGroup(group).UsingJobData(jobConfigurator.GetJobData()).Build(); return jobConfigurator.WithIdentity(key); } - + /// <summary> /// Generate a <see cref="JobKey" /> to identify the JobDetail from the set JobDataMap using <see cref="JobKey"/> on members. /// If none are marked, then all public properties will be considered, in the default order, with the member names. @@ -49,7 +50,7 @@ public static IJobConfiguratorWithGeneratedIdentity<T> WithGeneratedIdentity<T>( /// <param name="jobConfigurator"></param> /// <param name="name">the name element for the Job's JobKey</param> /// <returns>the updated JobBuilder</returns> - /// <seealso cref="JobKey" /> + /// <seealso cref="JobKey" /> /// <seealso cref="IJobDetail.Key" /> public static IJobConfiguratorWithIdentity<T> WithIdentity<T>(this IJobConfigurator<T> jobConfigurator, string name) where T : class, IJob @@ -109,7 +110,7 @@ public static IJobConfiguratorWithIdentity<T> WithIdentity<T>(this IJobConfigura /// <param name="jobConfigurator"></param> /// <param name="name">the name element for the Job's JobKey</param> /// <returns>the updated JobBuilder</returns> - /// <seealso cref="JobKey" /> + /// <seealso cref="JobKey" /> /// <seealso cref="IJobDetail.Key" /> public static IJobConfiguratorWithDataAndIdentity<T> WithIdentity<T>(this IJobConfiguratorWithData<T> jobConfigurator, string name) where T : class, IJob diff --git a/Shoko.Server/Scheduling/GenericJobBuilder/JobBuilder.cs b/Shoko.Server/Scheduling/GenericJobBuilder/JobBuilder.cs index 02bde81fc..99fe2768a 100644 --- a/Shoko.Server/Scheduling/GenericJobBuilder/JobBuilder.cs +++ b/Shoko.Server/Scheduling/GenericJobBuilder/JobBuilder.cs @@ -1,9 +1,8 @@ using System; -using System.Reflection; using Quartz; using Quartz.Impl; -using Shoko.Server.Scheduling.GenericJobBuilder.Utils; +#nullable enable namespace Shoko.Server.Scheduling.GenericJobBuilder; public class JobBuilder<T> : IJobConfiguratorWithDataAndIdentity<T>, IJobConfiguratorWithGeneratedIdentity<T> where T : class, IJob @@ -50,12 +49,11 @@ public IJobDetail Build() { var key = Key ?? new JobKey(Guid.NewGuid().ToString()); - var job = new JobDetail + var job = new JobDetail(typeof(T)) { Name = key.Name, Group = key.Group, Description = _description, - JobType = new JobType(typeof(T)), RequestsRecovery = _shouldRecover, JobDataMap = _jobDataMap }; diff --git a/Shoko.Server/Scheduling/GenericJobBuilder/JobKeyBuilder.cs b/Shoko.Server/Scheduling/GenericJobBuilder/JobKeyBuilder.cs index 378ef5c9b..f5112917a 100644 --- a/Shoko.Server/Scheduling/GenericJobBuilder/JobKeyBuilder.cs +++ b/Shoko.Server/Scheduling/GenericJobBuilder/JobKeyBuilder.cs @@ -13,17 +13,19 @@ namespace Shoko.Server.Scheduling.GenericJobBuilder; public class JobKeyBuilder<T> where T : class, IJob { - private static readonly HashSet<Type> _allowedTypes = new() - { + private static readonly HashSet<Type> _allowedTypes = + [ typeof(string), typeof(DateTime), typeof(DateTimeOffset), typeof(TimeSpan), typeof(bool), typeof(byte), typeof(sbyte), typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(char), typeof(double), typeof(float), typeof(decimal) - }; - private JobDataMap _jobDataMap = new JobDataMap(); + ]; + + private readonly JobDataMap _jobDataMap = new(); + private string? _group; - + /// <summary> - /// Create a JobKeyBuilder with which to define a <see cref="JobKey" /> that matches <see cref="IdentityExtensions.WithGeneratedIdentity{T}(string)"/> + /// Create a JobKeyBuilder with which to define a <see cref="JobKey" /> that matches <see cref="IdentityExtensions.WithGeneratedIdentity{T}(IJobConfiguratorWithData{T}, string?)"/> /// </summary> /// <returns>a new JobKeyBuilder</returns> public static JobKeyBuilder<T> Create() @@ -148,7 +150,7 @@ public JobKeyBuilder<T> UsingJobData(Action<T> ctor) UsingJobData(map); return this; } - + public JobKey Build() { var type = typeof(T); diff --git a/Shoko.Server/Scheduling/GenericJobBuilder/Utils/TypePropertyCache.cs b/Shoko.Server/Scheduling/GenericJobBuilder/Utils/TypePropertyCache.cs index 0debdbd49..bfd0a6886 100644 --- a/Shoko.Server/Scheduling/GenericJobBuilder/Utils/TypePropertyCache.cs +++ b/Shoko.Server/Scheduling/GenericJobBuilder/Utils/TypePropertyCache.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; +#nullable enable namespace Shoko.Server.Scheduling.GenericJobBuilder.Utils; /// <summary> @@ -10,12 +11,13 @@ namespace Shoko.Server.Scheduling.GenericJobBuilder.Utils; /// </summary> public static class TypePropertyCache { - private static readonly ConcurrentDictionary<Type, PropertyInfo[]> propertiesCache = new(); - private static readonly ConcurrentDictionary<(Type Type, string Name), PropertyInfo?> propertyCache = new(); + private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertiesCache = new(); + + private static readonly ConcurrentDictionary<(Type Type, string Name), PropertyInfo?> _propertyCache = new(); public static PropertyInfo[] Get(Type type) { - return propertiesCache.GetOrAdd(type, t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(a => + return _propertiesCache.GetOrAdd(type, t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(a => { if (!a.CanRead) return false; var setMethod = a.SetMethod; @@ -25,41 +27,40 @@ public static PropertyInfo[] Get(Type type) public static bool ContainsKey(Type type) { - return propertiesCache.ContainsKey(type); + return _propertiesCache.ContainsKey(type); } public static PropertyInfo[] GetOrAdd(Type type, Func<Type, PropertyInfo[]> getter) { - return propertiesCache.GetOrAdd(type, getter); + return _propertiesCache.GetOrAdd(type, getter); } public static PropertyInfo? Get(Type type, string name) { - return propertyCache.GetOrAdd((type, name), + return _propertyCache.GetOrAdd((type, name), t => t.Type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) .FirstOrDefault(a => a.Name == name)); } public static bool ContainsKey(Type type, string name) { - return propertyCache.ContainsKey((type, name)); + return _propertyCache.ContainsKey((type, name)); } public static PropertyInfo? GetOrAdd(Type type, string name, Func<(Type Type, string Name), PropertyInfo?> getter) { - return propertyCache.GetOrAdd((type, name), getter); + return _propertyCache.GetOrAdd((type, name), getter); } public static T? Get<T>(string name, object arg) where T : class { - return propertyCache.GetOrAdd((arg.GetType(), name), + return _propertyCache.GetOrAdd((arg.GetType(), name), _ => GetProperty<T>(arg, name))?.GetValue(arg) as T; } private static PropertyInfo? GetProperty<T>(object obj, string propertyName) { - if (obj == null) - throw new ArgumentNullException(nameof(obj)); + ArgumentNullException.ThrowIfNull(obj, nameof(obj)); var property = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) .FirstOrDefault(a => a.Name.Split('.').LastOrDefault() == propertyName); diff --git a/Shoko.Server/Scheduling/JobDetail.cs b/Shoko.Server/Scheduling/JobDetail.cs index 19aaa3e6a..c14ca2f36 100644 --- a/Shoko.Server/Scheduling/JobDetail.cs +++ b/Shoko.Server/Scheduling/JobDetail.cs @@ -1,25 +1,24 @@ -#nullable enable using System; using System.Globalization; using Quartz; using Quartz.Impl; +#nullable enable namespace Shoko.Server.Scheduling; -public class JobDetail : IJobDetail +public class JobDetail(Type type) : IJobDetail, IEquatable<JobDetail> { - private string name = null!; - private string group = SchedulerConstants.DefaultGroup; - private string? description; - private JobDataMap jobDataMap = null!; - private readonly Type jobType = null!; + private string _name = string.Empty; + private string _group = SchedulerConstants.DefaultGroup; + private string? _description; + private JobDataMap? _jobDataMap = null; [NonSerialized] // we have the key in string fields - private JobKey key = null!; + private JobKey? _key = null; public string Name { - get => name; + get => _name; init { @@ -28,7 +27,7 @@ public string Name throw new ArgumentException("Job name cannot be empty."); } - name = value; + _name = value; } } @@ -41,7 +40,7 @@ public string Name /// </exception> public string Group { - get => group; + get => _group; init { if (value != null && value.Trim().Length == 0) @@ -49,12 +48,9 @@ public string Group throw new ArgumentException("Group name cannot be empty."); } - if (value == null) - { - value = SchedulerConstants.DefaultGroup; - } + value ??= SchedulerConstants.DefaultGroup; - group = value; + _group = value; } } @@ -62,7 +58,7 @@ public string Group /// Returns the 'full name' of the <see cref="ITrigger" /> in the format /// "group.name". /// </summary> - public string FullName => group + "." + name; + public string FullName => _group + "." + _name; /// <summary> /// Gets the key. @@ -72,16 +68,16 @@ public JobKey Key { get { - if (key == null) + if (_key == null) { if (Name == null) { return null!; } - key = new JobKey(Name, Group); + _key = new JobKey(Name, Group); } - return key; + return _key; } init { @@ -89,7 +85,7 @@ public JobKey Key Name = value.Name; Group = value.Group; - key = value; + _key = value; } } @@ -103,11 +99,11 @@ public JobKey Key /// </remarks> public string? Description { - get => description; - init => description = value; + get => _description; + init => _description = value; } - public JobType JobType { get; init; } + public JobType JobType { get; private set; } = new(type); /// <summary> /// Get or set the <see cref="JobDataMap" /> that is associated with the <see cref="IJob" />. @@ -116,10 +112,10 @@ public JobDataMap JobDataMap { get { - return jobDataMap ??= new JobDataMap(); + return _jobDataMap ??= []; } - init => jobDataMap = value; + init => _jobDataMap = value; } /// <summary> @@ -162,28 +158,14 @@ public override string ToString() /// </returns> public IJobDetail Clone() { - var copy = (JobDetail) MemberwiseClone(); - if (jobDataMap != null) + var copy = (JobDetail)MemberwiseClone(); + if (_jobDataMap != null) { - copy.jobDataMap = (JobDataMap) jobDataMap.Clone(); + copy._jobDataMap = (JobDataMap)_jobDataMap.Clone(); } return copy; } - /// <summary> - /// Determines whether the specified detail is equal to this instance. - /// </summary> - /// <param name="detail">The detail to examine.</param> - /// <returns> - /// <c>true</c> if the specified detail is equal; otherwise, <c>false</c>. - /// </returns> - private bool IsEqual(JobDetail? detail) - { - //doesn't consider job's saved data, - //durability etc - return detail != null && detail.Name == Name && detail.Group == Group && detail.JobType.Equals(JobType); - } - /// <summary> /// Determines whether the specified <see cref="T:System.Object"/> is equal to the current <see cref="T:System.Object"/>. /// </summary> @@ -194,12 +176,7 @@ private bool IsEqual(JobDetail? detail) /// </returns> public override bool Equals(object? obj) { - if (!(obj is JobDetail jd)) - { - return false; - } - - return IsEqual(jd); + return obj is JobDetail jd && Equals(jd); } /// <summary> @@ -207,9 +184,9 @@ public override bool Equals(object? obj) /// </summary> /// <param name="detail">The detail to compare this instance with.</param> /// <returns></returns> - public bool Equals(JobDetail detail) + public bool Equals(JobDetail? detail) { - return IsEqual(detail); + return detail is not null && detail.Name == Name && detail.Group == Group && detail.JobType.Equals(JobType); } /// <summary> @@ -233,7 +210,7 @@ public JobBuilder GetJobBuilder() .UsingJobData(JobDataMap) .DisallowConcurrentExecution(ConcurrentExecutionDisallowed) .PersistJobDataAfterExecution(PersistJobDataAfterExecution) - .WithDescription(description) + .WithDescription(_description) .WithIdentity(Key); } } diff --git a/Shoko.Server/Scheduling/Jobs/Actions/ImportJob.cs b/Shoko.Server/Scheduling/Jobs/Actions/ImportJob.cs index d9ff8379b..d68e6822d 100644 --- a/Shoko.Server/Scheduling/Jobs/Actions/ImportJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Actions/ImportJob.cs @@ -24,14 +24,11 @@ public override async Task Process() // drop folder await _service.RunImport_DropFolders(); - // TvDB association checks - await _service.RunImport_ScanTvDB(); - // Trakt association checks _service.RunImport_ScanTrakt(); - // MovieDB association checks - await _service.RunImport_ScanMovieDB(); + // TMDB association checks + await _service.RunImport_ScanTMDB(); // Check for missing images await _service.RunImport_GetImages(); diff --git a/Shoko.Server/Scheduling/Jobs/Actions/RefreshAnimeStatsJob.cs b/Shoko.Server/Scheduling/Jobs/Actions/RefreshAnimeStatsJob.cs index 9c3555095..cb23e8bf7 100644 --- a/Shoko.Server/Scheduling/Jobs/Actions/RefreshAnimeStatsJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Actions/RefreshAnimeStatsJob.cs @@ -6,6 +6,8 @@ using Shoko.Server.Scheduling.Attributes; using Shoko.Server.Services; +#pragma warning disable CS8618 +#nullable enable namespace Shoko.Server.Scheduling.Jobs.Actions; [DatabaseRequired] @@ -18,7 +20,8 @@ public class RefreshAnimeStatsJob : BaseJob private readonly AnimeGroupService _groupService; public int AnimeID { get; set; } - private string _anime; + + private string? _anime; public override string TypeName => "Refresh Anime Stats"; public override string Title => "Refreshing Anime Stats"; @@ -38,8 +41,21 @@ public override Task Process() { _logger.LogInformation("Processing {Job} for {Anime}", nameof(RefreshAnimeStatsJob), _anime); var anime = _animeRepo.GetByAnimeID(AnimeID); + if (anime == null) + { + _logger.LogWarning("AniDB_Anime not found: {AnimeID}", AnimeID); + return Task.CompletedTask; + } _animeRepo.Save(anime); var series = _seriesRepo.GetByAnimeID(AnimeID); + + if (series is not null) + { + series.ResetAnimeTitles(); + series.ResetPreferredTitle(); + series.ResetPreferredOverview(); + } + // Updating stats saves everything and updates groups _seriesService.UpdateStats(series, true, true); _groupService.UpdateStatsFromTopLevel(series?.AnimeGroup?.TopLevelAnimeGroup, true, true); diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/AcknowledgeAniDBNotifyJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/AcknowledgeAniDBNotifyJob.cs new file mode 100644 index 000000000..73dab2ef9 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/AniDB/AcknowledgeAniDBNotifyJob.cs @@ -0,0 +1,61 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.User; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; +using Shoko.Server.Server; + +namespace Shoko.Server.Scheduling.Jobs.AniDB; + +[DatabaseRequired] +[AniDBUdpRateLimited] +[DisallowConcurrencyGroup(ConcurrencyGroups.AniDB_UDP)] +[JobKeyGroup(JobKeyGroup.AniDB)] +public class AcknowledgeAniDBNotifyJob : BaseJob +{ + private readonly IRequestFactory _requestFactory; + public int NotifyID { get; set; } + public AniDBNotifyType NotifyType { get; set; } + + public override string TypeName => "Acknowledge AniDB Notify"; + + public override string Title => "Acknowledging AniDB Notify"; + + public override Task Process() + { + _logger.LogInformation("Processing {Job}: {Type} {ID}", nameof(AcknowledgeAniDBNotifyJob), NotifyType.ToString(), NotifyID); + + var requestAck = _requestFactory.Create<RequestAcknowledgeNotify>( + r => + { + r.Type = NotifyType; + r.ID = NotifyID; + } + ); + var responseAck = requestAck.Send(); + + // successful, set the read flag + if (NotifyType == AniDBNotifyType.Message) + { + var message = RepoFactory.AniDB_Message.GetByMessageId(NotifyID); + if (message != null) + { + message.IsReadOnAniDB = true; + RepoFactory.AniDB_Message.Save(message); + } + } + return Task.CompletedTask; + } + + public AcknowledgeAniDBNotifyJob(IRequestFactory requestFactory) + { + _requestFactory = requestFactory; + } + + protected AcknowledgeAniDBNotifyJob() + { + } +} diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs index 20f6e382f..9b89b534d 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs @@ -186,7 +186,7 @@ await _watchedService.SetWatchedStatus(_videoLocal, false, false, null, false, j } // if we don't have xrefs, then no series or eps. - var series = _videoLocal.EpisodeCrossRefs.Select(a => a.AnimeID).Distinct().ToArray(); + var series = _videoLocal.EpisodeCrossRefs.Select(a => a.AnimeID).Distinct().Except([0]).ToArray(); if (series.Length <= 0) { return; diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/DownloadAniDBImageJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/DownloadAniDBImageJob.cs index e1b83d293..1cb208b5e 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/DownloadAniDBImageJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/DownloadAniDBImageJob.cs @@ -1,10 +1,5 @@ -using System; using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Shoko.Models.Enums; -using Shoko.Server.ImageDownload; -using Shoko.Server.Providers.AniDB; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Scheduling.Acquisition.Attributes; using Shoko.Server.Scheduling.Attributes; using Shoko.Server.Scheduling.Concurrency; @@ -15,106 +10,34 @@ namespace Shoko.Server.Scheduling.Jobs.AniDB; [NetworkRequired] [LimitConcurrency(8, 16)] [JobKeyGroup(JobKeyGroup.AniDB)] -public class DownloadAniDBImageJob : BaseJob, IImageDownloadJob +public class DownloadAniDBImageJob : DownloadImageBaseJob { - private readonly AniDBImageHandler _imageHandler; - public string Anime { get; set; } - public int ImageID { get; set; } - public bool ForceDownload { get; set; } + public override DataSourceEnum Source => DataSourceEnum.AniDB; - public ImageEntityType ImageType { get; set; } - - public override string TypeName => "Download AniDB Image"; - - public override string Title => "Downloading AniDB Image"; - public override Dictionary<string, object> Details + public override Dictionary<string, object> Details => ImageType switch { - get + ImageEntityType.Poster when ParentName is not null => new() { - return ImageType switch - { - ImageEntityType.AniDB_Cover when Anime != null => new() - { - { - "Type", ImageType.ToString().Replace("_", " ") - }, - { - "Anime", Anime - }, - }, - ImageEntityType.AniDB_Cover when Anime == null => new() - { - { - "Type", ImageType.ToString().Replace("_", " ") - }, - { - "AnimeID", ImageID - }, - }, - _ => new() - { - { - "Anime", Anime - }, - { - "Type", ImageType.ToString().Replace("_", " ") - }, - { - "ImageID", ImageID - } - } - }; - } - } - - public override async Task Process() - { - _logger.LogInformation("Processing {Job} for {Anime} -> Type: {ImageType} | ImageID: {EntityID}", nameof(DownloadAniDBImageJob), Anime, ImageType, ImageID); - - var (downloadUrl, filePath) = _imageHandler.GetPaths(ImageType, ImageID); - - if (string.IsNullOrEmpty(downloadUrl) || string.IsNullOrEmpty(filePath)) + { "Anime", ParentName }, + { "Type", "AniDB Poster" }, + }, + ImageEntityType.Poster when ParentName is null => new() { - _logger.LogWarning("Image failed to download for {Anime}: No paths found for {ImageType} and {EntityID}", Anime, ImageType, ImageID); - return; - } - - try + { "Anime", $"AniDB Anime {ImageID}" }, + { "Type", "AniDB Poster" }, + }, + _ when ParentName is not null => new() { - // If this has any issues, it will throw an exception, so the catch below will handle it. - var result = await _imageHandler.DownloadImage(downloadUrl, filePath, ForceDownload); - switch (result) - { - case ImageDownloadResult.Success: - _logger.LogInformation("Image downloaded for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.Cached: - _logger.LogDebug("Image already in cache for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.Failure: - _logger.LogWarning("Image failed to download for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.RemovedResource: - _logger.LogWarning("Image failed to download for {Anime} and the local entry has been removed: {FilePath} from {DownloadUrl}", Anime, - filePath, downloadUrl); - break; - case ImageDownloadResult.InvalidResource: - _logger.LogWarning("Image failed to download for {Anime} and the local entry could not be removed: {FilePath} from {DownloadUrl}", - Anime, filePath, downloadUrl); - break; - } - } - catch (Exception e) + { "Anime", ParentName }, + { "Type", $"AniDB {ImageType}".Replace("Person", "Creator") }, + { "ImageID", ImageID } + }, + _ => new() { - _logger.LogWarning("Error processing {Job} for {Anime}: {Url} ({EntityID}) - {Message}", nameof(DownloadAniDBImageJob), Anime, downloadUrl, - ImageID, e.Message); + { "Type", $"AniDB {ImageType}".Replace("Person", "Creator") }, + { "ImageID", ImageID } } - } - - public DownloadAniDBImageJob(AniDBImageHandler imageHandler) - { - _imageHandler = imageHandler; - } + }; - protected DownloadAniDBImageJob() { } + public DownloadAniDBImageJob() : base() { } } diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBAnimeJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBAnimeJob.cs index c3e2a3f52..1e9c5a313 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBAnimeJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBAnimeJob.cs @@ -19,7 +19,6 @@ using Shoko.Server.Scheduling.Jobs.Shoko; using Shoko.Server.Scheduling.Jobs.TMDB; using Shoko.Server.Scheduling.Jobs.Trakt; -using Shoko.Server.Scheduling.Jobs.TvDB; using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Tasks; @@ -86,7 +85,7 @@ public override async Task<SVR_AniDB_Anime> Process() }; } - var scheduler = await _schedulerFactory.GetScheduler(); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); var anime = RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); var update = RepoFactory.AniDB_AnimeUpdate.GetByAnimeID(AnimeID); var animeRecentlyUpdated = AnimeRecentlyUpdated(anime, update); @@ -99,13 +98,13 @@ public override async Task<SVR_AniDB_Anime> Process() ResponseGetAnime response; if (!CacheOnly && !animeRecentlyUpdated && !_handler.IsBanned && (ForceRefresh || anime == null)) { - response = await GetResponse(anime); + response = await GetResponse(anime).ConfigureAwait(false); if (response == null) return null; } // Else, try to load a cached xml file. else { - var (success, xml) = await TryGetXmlFromCache(); + var (success, xml) = await TryGetXmlFromCache().ConfigureAwait(false); if (!success) return null; try @@ -126,7 +125,7 @@ await scheduler.StartJob<GetAniDBAnimeJob>(c => c.CacheOnly = false; c.ForceRefresh = true; c.CreateSeriesEntry = CreateSeriesEntry; - }); + }).ConfigureAwait(false); } throw; } @@ -135,12 +134,13 @@ await scheduler.StartJob<GetAniDBAnimeJob>(c => // Create or update the anime record, anime ??= new SVR_AniDB_Anime(); var isNew = anime.AniDB_AnimeID == 0; - var (isUpdated, episodesToMove) = await _animeCreator.CreateAnime(response, anime, RelDepth); + var (isUpdated, titlesUpdated, descriptionUpdated, animeEpisodeChanges) = await _animeCreator.CreateAnime(response, anime, RelDepth).ConfigureAwait(false); // then conditionally create the series record if it doesn't exist, var series = RepoFactory.AnimeSeries.GetByAnimeID(AnimeID); var seriesIsNew = series == null; var seriesUpdated = false; + var seriesEpisodeChanges = new Dictionary<SVR_AnimeEpisode, UpdateReason>(); if (series == null && CreateSeriesEntry) { series = await CreateAnimeSeriesAndGroup(anime); @@ -150,11 +150,11 @@ await scheduler.StartJob<GetAniDBAnimeJob>(c => // existing series record. if (series != null) { - seriesUpdated = await _seriesService.CreateAnimeEpisodes(series); + (seriesUpdated, seriesEpisodeChanges) = await _seriesService.CreateAnimeEpisodes(series).ConfigureAwait(false); RepoFactory.AnimeSeries.Save(series, true, false); } - await _jobFactory.CreateJob<RefreshAnimeStatsJob>(x => x.AnimeID = AnimeID).Process(); + await _jobFactory.CreateJob<RefreshAnimeStatsJob>(x => x.AnimeID = AnimeID).Process().ConfigureAwait(false); // Request an image download var imagesJob = _jobFactory.CreateJob<GetAniDBImagesJob>(job => @@ -162,31 +162,82 @@ await scheduler.StartJob<GetAniDBAnimeJob>(c => job.AnimeID = AnimeID; job.OnlyPosters = series == null; }); - await imagesJob.Process(); + await imagesJob.Process().ConfigureAwait(false); // Emit anidb anime updated event. - if (isUpdated) - ShokoEventHandler.Instance.OnSeriesUpdated(anime, isNew ? UpdateReason.Added : UpdateReason.Updated); + if (isNew || isUpdated || animeEpisodeChanges.Count > 0) + ShokoEventHandler.Instance.OnSeriesUpdated(anime, isNew ? UpdateReason.Added : UpdateReason.Updated, animeEpisodeChanges); + + // Reset the cached preferred title if anime titles were updated. + if (titlesUpdated) + anime.ResetPreferredTitle(); + + // Reset the cached titles if anime titles were updated or if series is new. + if ((titlesUpdated || seriesIsNew) && series is not null) + { + series.ResetPreferredTitle(); + series.ResetAnimeTitles(); + } + + // Reset the cached description if anime description was updated or if series is new. + if ((descriptionUpdated || seriesIsNew) && series is not null) + { + series.ResetPreferredOverview(); + } // Emit shoko series updated event. - if (series != null && (seriesUpdated || seriesIsNew)) - ShokoEventHandler.Instance.OnSeriesUpdated(series, seriesIsNew ? UpdateReason.Added : UpdateReason.Updated); + if (series is not null && (seriesIsNew || seriesUpdated || seriesEpisodeChanges.Count > 0)) + ShokoEventHandler.Instance.OnSeriesUpdated(series, seriesIsNew ? UpdateReason.Added : UpdateReason.Updated, seriesEpisodeChanges); // Re-schedule the videos to move/rename as required if something changed. - if (episodesToMove.Count > 0) + if (isNew || isUpdated || animeEpisodeChanges.Count > 0 || seriesIsNew || seriesUpdated || seriesEpisodeChanges.Count > 0) { - var videos = episodesToMove.SelectMany(RepoFactory.CrossRef_File_Episode.GetByEpisodeID) - .WhereNotNull() - .Select(a => a.VideoLocal) - .WhereNotNull() - .DistinctBy(a => a.VideoLocalID) - .ToList(); + var videos = new List<SVR_VideoLocal>(); + if (isNew || seriesIsNew || isUpdated || seriesUpdated) + { + videos.AddRange( + RepoFactory.CrossRef_File_Episode.GetByAnimeID(AnimeID) + .WhereNotNull() + .Select(a => a.VideoLocal) + .WhereNotNull() + .DistinctBy(a => a.VideoLocalID) + ); + } + else + { + if (animeEpisodeChanges.Count > 0) + videos.AddRange( + animeEpisodeChanges.Keys + .SelectMany(a => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(a.EpisodeID)) + .WhereNotNull() + .Select(a => a.VideoLocal) + .WhereNotNull() + .DistinctBy(a => a.VideoLocalID) + ); + if (seriesEpisodeChanges.Count > 0) + videos.AddRange( + seriesEpisodeChanges.Keys + .SelectMany(a => RepoFactory.CrossRef_File_Episode.GetByEpisodeID(a.AniDB_EpisodeID)) + .WhereNotNull() + .Select(a => a.VideoLocal) + .WhereNotNull() + .DistinctBy(a => a.VideoLocalID) + ); + } foreach (var video in videos) - await scheduler.StartJob<RenameMoveFileJob>(job => job.VideoLocalID = video.VideoLocalID); + await scheduler.StartJob<RenameMoveFileJob>(job => job.VideoLocalID = video.VideoLocalID).ConfigureAwait(false); + + if (isNew || animeEpisodeChanges.Count > 0) + foreach (var xref in anime.TmdbShowCrossReferences) + await scheduler.StartJob<UpdateTmdbShowJob>(job => + { + job.TmdbShowID = xref.TmdbShowID; + job.DownloadImages = true; + }).ConfigureAwait(false); } - await ProcessRelations(response); + await ProcessRelations(response).ConfigureAwait(false); return anime; } @@ -218,7 +269,7 @@ private async Task<ResponseGetAnime> GetResponse(SVR_AniDB_Anime anime) // If the anime record doesn't exist yet then try to load it // from the cache. A stall record is better than no record // in most cases. - var (success, xml) = await TryGetXmlFromCache(); + var (success, xml) = await TryGetXmlFromCache().ConfigureAwait(false); if (!success) throw; try @@ -229,7 +280,8 @@ private async Task<ResponseGetAnime> GetResponse(SVR_AniDB_Anime anime) { _logger.LogTrace("Failed to parse the cached AnimeDoc_{AnimeID}.xml file", AnimeID); // Queue the command to get the data when we're no longer banned if there is no anime record. - await (await _schedulerFactory.GetScheduler()).StartJob<GetAniDBAnimeJob>(c => + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + await scheduler.StartJob<GetAniDBAnimeJob>(c => { c.AnimeID = AnimeID; c.DownloadRelations = DownloadRelations; @@ -237,7 +289,7 @@ private async Task<ResponseGetAnime> GetResponse(SVR_AniDB_Anime anime) c.CacheOnly = false; c.ForceRefresh = true; c.CreateSeriesEntry = CreateSeriesEntry; - }); + }).ConfigureAwait(false); throw; } @@ -264,7 +316,7 @@ private bool AnimeRecentlyUpdated(SVR_AniDB_Anime anime, AniDB_AnimeUpdate updat private async Task<(bool success, string xml)> TryGetXmlFromCache() { - var xml = await _xmlUtils.LoadAnimeHTTPFromFile(AnimeID); + var xml = await _xmlUtils.LoadAnimeHTTPFromFile(AnimeID).ConfigureAwait(false); if (xml != null) return (true, xml); if (!CacheOnly && _handler.IsBanned) _logger.LogTrace("We're HTTP Banned and unable to find a cached AnimeDoc_{AnimeID}.xml file", AnimeID); @@ -273,7 +325,8 @@ private bool AnimeRecentlyUpdated(SVR_AniDB_Anime anime, AniDB_AnimeUpdate updat if (!CacheOnly) { // Queue the command to get the data when we're no longer banned if there is no anime record. - await (await _schedulerFactory.GetScheduler()).StartJob<GetAniDBAnimeJob>(c => + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + await scheduler.StartJob<GetAniDBAnimeJob>(c => { c.AnimeID = AnimeID; c.DownloadRelations = DownloadRelations; @@ -281,14 +334,13 @@ private bool AnimeRecentlyUpdated(SVR_AniDB_Anime anime, AniDB_AnimeUpdate updat c.CacheOnly = false; c.ForceRefresh = true; c.CreateSeriesEntry = CreateSeriesEntry; - }); + }).ConfigureAwait(false); } return (false, null); } public async Task<SVR_AnimeSeries> CreateAnimeSeriesAndGroup(SVR_AniDB_Anime anime) { - var scheduler = await _schedulerFactory.GetScheduler(); // Create a new AnimeSeries record var series = new SVR_AnimeSeries { @@ -305,17 +357,14 @@ public async Task<SVR_AnimeSeries> CreateAnimeSeriesAndGroup(SVR_AniDB_Anime ani // Populate before making a group to ensure IDs and stats are set for group filters. RepoFactory.AnimeSeries.Save(series, false, false); - // check for TvDB associations - if (anime.Restricted == 0) - { - if (_settings.TvDB.AutoLink && !series.IsTvDBAutoMatchingDisabled) await scheduler.StartJob<SearchTvDBSeriesJob>(c => c.AnimeID = AnimeID); - - // check for Trakt associations - if (_settings.TraktTv.Enabled && !string.IsNullOrEmpty(_settings.TraktTv.AuthToken) && !series.IsTraktAutoMatchingDisabled) - await scheduler.StartJob<SearchTraktSeriesJob>(c => c.AnimeID = AnimeID); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + if (_settings.TMDB.AutoLink && !series.IsTMDBAutoMatchingDisabled) + await scheduler.StartJob<SearchTmdbJob>(c => c.AnimeID = AnimeID).ConfigureAwait(false); - if (anime.AnimeType == (int)AnimeType.Movie && !series.IsTMDBAutoMatchingDisabled) - await scheduler.StartJob<SearchTMDBSeriesJob>(c => c.AnimeID = AnimeID); + if (!anime.IsRestricted) + { + if (_settings.TraktTv.Enabled && _settings.TraktTv.AutoLink && !string.IsNullOrEmpty(_settings.TraktTv.AuthToken) && !series.IsTraktAutoMatchingDisabled) + await scheduler.StartJob<SearchTraktSeriesJob>(c => c.AnimeID = AnimeID).ConfigureAwait(false); } return series; @@ -325,9 +374,9 @@ private async Task ProcessRelations(ResponseGetAnime response) { if (!DownloadRelations) return; if (_settings.AniDb.MaxRelationDepth <= 0) return; - if (RelDepth > _settings.AniDb.MaxRelationDepth) return; + if (RelDepth >= _settings.AniDb.MaxRelationDepth) return; if (!_settings.AutoGroupSeries && !_settings.AniDb.DownloadRelatedAnime) return; - var scheduler = await _schedulerFactory.GetScheduler(); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); // Queue or process the related series. foreach (var relation in response.Relations) @@ -342,7 +391,9 @@ private async Task ProcessRelations(ResponseGetAnime response) // the local anime record was last updated (be it from a fresh // online xml file or from a cached xml file). var update = RepoFactory.AniDB_AnimeUpdate.GetByAnimeID(relation.RelatedAnimeID); +#pragma warning disable CS0618 var updatedAt = ForceRefresh && !_handler.IsBanned && update != null ? update.UpdatedAt : anime.DateTimeUpdated; +#pragma warning restore CS0618 var ts = DateTime.Now - updatedAt; if (ts.TotalHours < _settings.AniDb.MinimumHoursToRedownloadAnimeInfo) continue; } @@ -356,7 +407,7 @@ await scheduler.StartJobNow<GetAniDBAnimeJob>(c => c.CacheOnly = !ForceRefresh && CacheOnly; c.ForceRefresh = ForceRefresh; c.CreateSeriesEntry = CreateSeriesEntry && _settings.AniDb.AutomaticallyImportSeries; - }); + }).ConfigureAwait(false); } } diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBCalendarJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBCalendarJob.cs index 824a559e1..9b46b7811 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBCalendarJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBCalendarJob.cs @@ -37,13 +37,13 @@ public override async Task Process() var settings = _settingsProvider.GetSettings(); // we will always assume that an anime was downloaded via http first - var sched = - RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBCalendar); - if (sched == null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBCalendar); + if (schedule is null) { - sched = new ScheduledUpdate + schedule = new ScheduledUpdate { - UpdateType = (int)ScheduledUpdateType.AniDBCalendar, UpdateDetails = string.Empty + UpdateType = (int)ScheduledUpdateType.AniDBCalendar, + UpdateDetails = string.Empty, }; } else @@ -51,21 +51,21 @@ public override async Task Process() var freqHours = Utils.GetScheduledHours(settings.AniDb.Calendar_UpdateFrequency); // if we have run this in the last 12 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; + var tsLastRun = DateTime.Now - schedule.LastUpdate; if (tsLastRun.TotalHours < freqHours) { if (!ForceRefresh) return; } } - sched.LastUpdate = DateTime.Now; + schedule.LastUpdate = DateTime.Now; var request = _requestFactory.Create<RequestCalendar>(); var response = request.Send(); - RepoFactory.ScheduledUpdate.Save(sched); + RepoFactory.ScheduledUpdate.Save(schedule); var scheduler = await _schedulerFactory.GetScheduler(); - if (response.Response?.Next25Anime != null) + if (response.Response?.Next25Anime is not null) { foreach (var cal in response.Response.Next25Anime) { @@ -74,7 +74,7 @@ public override async Task Process() } } - if (response.Response?.Previous25Anime == null) return; + if (response.Response?.Previous25Anime is null) return; foreach (var cal in response.Response.Previous25Anime) { @@ -83,11 +83,11 @@ public override async Task Process() } } - private async Task GetAnime(IScheduler scheduler, ResponseCalendar.CalendarEntry cal, IServerSettings settings) + private static async Task GetAnime(IScheduler scheduler, ResponseCalendar.CalendarEntry cal, IServerSettings settings) { var anime = RepoFactory.AniDB_Anime.GetByAnimeID(cal.AnimeID); var update = RepoFactory.AniDB_AnimeUpdate.GetByAnimeID(cal.AnimeID); - if (anime != null && update != null) + if (anime is not null && update is not null) { // don't update if the local data is less 2 days old var ts = DateTime.Now - update.UpdatedAt; @@ -110,7 +110,7 @@ await scheduler.StartJob<GetAniDBAnimeJob>( anime.AirDate = cal.ReleaseDate; RepoFactory.AniDB_Anime.Save(anime); var ser = RepoFactory.AnimeSeries.GetByAnimeID(anime.AnimeID); - if (ser != null) RepoFactory.AnimeSeries.Save(ser, true, false); + if (ser is not null) RepoFactory.AnimeSeries.Save(ser, true, false); } } else @@ -125,7 +125,7 @@ await scheduler.StartJob<GetAniDBAnimeJob>( }); } } - + public GetAniDBCalendarJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider) { diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBCreatorJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBCreatorJob.cs new file mode 100644 index 000000000..60d0db5d8 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBCreatorJob.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Extensions; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.Info; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; +using Shoko.Server.Utilities; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.Scheduling.Jobs.AniDB; + +[DatabaseRequired] +[AniDBUdpRateLimited] +[DisallowConcurrencyGroup(ConcurrencyGroups.AniDB_UDP)] +[JobKeyGroup(JobKeyGroup.AniDB)] +public class GetAniDBCreatorJob : BaseJob +{ + private readonly IRequestFactory _requestFactory; + + private readonly ISchedulerFactory _schedulerFactory; + + private string? _creatorName; + + public int CreatorID { get; set; } + + public override string TypeName => "Fetch AniDB Creator Details"; + + public override string Title => "Fetching AniDB Creator Details"; + + public override void PostInit() + { + // We have the title helper. May as well use it to provide better info for the user + _creatorName = RepoFactory.AniDB_Creator?.GetByCreatorID(CreatorID)?.OriginalName; + } + public override Dictionary<string, object> Details => string.IsNullOrEmpty(_creatorName) + ? new() + { + { "CreatorID", CreatorID }, + } + : new() + { + { "Creator", _creatorName }, + { "CreatorID", CreatorID }, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing {Job}", nameof(GetAniDBCreatorJob)); + + var request = _requestFactory.Create<RequestGetCreator>(r => r.CreatorID = CreatorID); + var response = request.Send().Response; + if (response is null) + { + _logger.LogError("Unable to find an AniDB Creator with the given ID: {CreatorID}", CreatorID); + return; + } + + _logger.LogInformation("Found AniDB Creator: {Creator} (ID={CreatorID},Type={Type})", response.Name, response.ID, response.Type.ToString()); + var creator = RepoFactory.AniDB_Creator.GetByCreatorID(CreatorID) ?? new(); + creator.CreatorID = response.ID; + if (!string.IsNullOrEmpty(response.Name)) + creator.Name = response.Name; + if (!string.IsNullOrEmpty(response.OriginalName)) + creator.OriginalName = response.OriginalName; + creator.Type = response.Type; + creator.ImagePath = response.ImagePath; + creator.EnglishHomepageUrl = response.EnglishHomepageUrl; + creator.JapaneseHomepageUrl = response.JapaneseHomepageUrl; + creator.EnglishWikiUrl = response.EnglishWikiUrl; + creator.JapaneseWikiUrl = response.JapaneseWikiUrl; + creator.LastUpdatedAt = response.LastUpdateAt; + RepoFactory.AniDB_Creator.Save(creator); + + if (RepoFactory.AnimeStaff.GetByAniDBID(creator.CreatorID) is { } staff) + { + var creatorBasePath = ImageUtils.GetBaseAniDBCreatorImagesPath() + Path.DirectorySeparatorChar; + staff.Name = creator.Name; + staff.AlternateName = creator.OriginalName; + staff.ImagePath = creator.GetFullImagePath()?.Replace(creatorBasePath, ""); + RepoFactory.AnimeStaff.Save(staff); + } + + if (!string.IsNullOrEmpty(creator.ImagePath) && (!creator.GetImageMetadata()?.IsLocalAvailable ?? false)) + { + _logger.LogInformation("Image not found locally, queuing image download for {Creator} (ID={CreatorID},Type={Type})", response.Name, response.ID, response.Type.ToString()); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + await scheduler.StartJob<DownloadAniDBImageJob>(c => + { + c.ImageType = ImageEntityType.Person; + c.ImageID = creator.CreatorID; + }); + } + } + + public GetAniDBCreatorJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory) + { + _requestFactory = requestFactory; + _schedulerFactory = schedulerFactory; + } + + protected GetAniDBCreatorJob() + { + } +} diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBFileJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBFileJob.cs index d1fb38373..ec3377407 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBFileJob.cs @@ -46,9 +46,9 @@ public override void PostInit() public override string Title => "Getting AniDB File Data"; public override Dictionary<string, object> Details => new() { - { - "Filename", _vlocal?.FileName ?? VideoLocalID.ToString() - } +#pragma warning disable CS0618 + { "Filename", _vlocal?.FileName ?? VideoLocalID.ToString() } +#pragma warning restore CS0618 }; public override async Task<SVR_AniDB_File> Process() @@ -59,7 +59,8 @@ public override async Task<SVR_AniDB_File> Process() { throw new AniDBBannedException { - BanType = UpdateType.UDPBan, BanExpires = _handler.BanTime?.AddHours(_handler.BanTimerResetLength) + BanType = UpdateType.UDPBan, + BanExpires = _handler.BanTime?.AddHours(_handler.BanTimerResetLength), }; } @@ -116,12 +117,14 @@ public override async Task<SVR_AniDB_File> Process() RepoFactory.AniDB_File.Save(aniFile, false); await CreateLanguages(response.Response); +#pragma warning disable CS0618 await CreateXrefs(_vlocal.FileName, response.Response); +#pragma warning restore CS0618 var anime = RepoFactory.AniDB_Anime.GetByAnimeID(response.Response.AnimeID); if (anime != null) { - RepoFactory.AniDB_Anime.Save(anime, false); + RepoFactory.AniDB_Anime.Save(anime); } var series = RepoFactory.AnimeSeries.GetByAnimeID(response.Response.AnimeID); @@ -150,7 +153,8 @@ await BaseRepository.Lock(session, async s => .Where(lang => lang.Length > 0) .Select(lang => new CrossRef_Languages_AniDB_File { - LanguageName = lang, FileID = response.FileID + LanguageName = lang, + FileID = response.FileID, }) .ToList(); await RepoFactory.CrossRef_Languages_AniDB_File.SaveWithOpenTransactionAsync(s, toSave); @@ -167,7 +171,8 @@ await BaseRepository.Lock(session, async s => .Where(lang => lang.Length > 0) .Select(lang => new CrossRef_Subtitles_AniDB_File { - LanguageName = lang, FileID = response.FileID + LanguageName = lang, + FileID = response.FileID, }) .ToList(); await RepoFactory.CrossRef_Subtitles_AniDB_File.SaveWithOpenTransactionAsync(s, toSave); @@ -233,14 +238,12 @@ await BaseRepository.Lock(fileEps, async x => } } - if (epAnimeID == null) continue; - epOrder++; fileEps.Add(new SVR_CrossRef_File_Episode { Hash = _vlocal.Hash, CrossRefSource = (int)CrossRefSource.AniDB, - AnimeID = epAnimeID.Value, + AnimeID = epAnimeID ?? 0, EpisodeID = episode.EpisodeID, Percentage = episode.Percentage, EpisodeOrder = epOrder, diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBImagesJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBImagesJob.cs index 7eb94d981..746b31460 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBImagesJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBImagesJob.cs @@ -4,9 +4,9 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Quartz; -using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Extensions; using Shoko.Server.Models; -using Shoko.Server.Providers.AniDB; using Shoko.Server.Providers.AniDB.Titles; using Shoko.Server.Repositories; using Shoko.Server.Scheduling.Acquisition.Attributes; @@ -21,7 +21,6 @@ public class GetAniDBImagesJob : BaseJob { private SVR_AniDB_Anime _anime; private string _title; - private readonly AniDBImageHandler _imageHandler; private readonly AniDBTitleHelper _titleHelper; private readonly ISettingsProvider _settingsProvider; private readonly ISchedulerFactory _schedulerFactory; @@ -66,11 +65,11 @@ public override async Task Process() // cover var scheduler = await _schedulerFactory.GetScheduler(); - if (ForceDownload || !_imageHandler.IsImageCached(ImageEntityType.AniDB_Cover, _anime.AnimeID)) + if (ForceDownload || !_anime.GetImageMetadata().IsLocalAvailable) await scheduler.StartJobNow<DownloadAniDBImageJob>(a => { a.ImageID = _anime.AnimeID; - a.ImageType = ImageEntityType.AniDB_Cover; + a.ImageType = ImageEntityType.Poster; a.ForceDownload = ForceDownload; }); @@ -85,14 +84,14 @@ await scheduler.StartJobNow<DownloadAniDBImageJob>(a => .Where(a => !string.IsNullOrEmpty(a?.PicName)) .DistinctBy(a => a.CharID) .ToList(); - if (characters.Any()) + if (characters.Count is not 0) requests.AddRange(characters - .Where(a => ForceDownload || !_imageHandler.IsImageCached(ImageEntityType.AniDB_Character, a.CharID)) + .Where(a => ForceDownload || !(a.GetImageMetadata()?.IsLocalAvailable ?? false)) .Select(c => new Action<DownloadAniDBImageJob>(a => { - a.Anime = _title; + a.ParentName = _title; a.ImageID = c.CharID; - a.ImageType = ImageEntityType.AniDB_Character; + a.ImageType = ImageEntityType.Character; a.ForceDownload = ForceDownload; }))); else @@ -104,27 +103,27 @@ await scheduler.StartJobNow<DownloadAniDBImageJob>(a => { // Get all voice-actors working on this anime. var voiceActors = RepoFactory.AniDB_Anime_Character.GetByAnimeID(AnimeID) - .SelectMany(xref => RepoFactory.AniDB_Character_Seiyuu.GetByCharID(xref.CharID)) - .Select(xref => RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(xref.SeiyuuID)) - .Where(va => !string.IsNullOrEmpty(va?.PicName)); + .SelectMany(xref => RepoFactory.AniDB_Character_Creator.GetByCharacterID(xref.CharID)) + .Select(xref => RepoFactory.AniDB_Creator.GetByCreatorID(xref.CreatorID)) + .Where(va => !string.IsNullOrEmpty(va?.ImagePath)); // Get all staff members working on this anime. var staffMembers = RepoFactory.AniDB_Anime_Staff.GetByAnimeID(AnimeID) - .Select(xref => RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(xref.CreatorID)) - .Where(staff => !string.IsNullOrEmpty(staff?.PicName)); + .Select(xref => RepoFactory.AniDB_Creator.GetByCreatorID(xref.CreatorID)) + .Where(staff => !string.IsNullOrEmpty(staff?.ImagePath)); // Concatenate the streams into a single list. var creators = voiceActors .Concat(staffMembers) - .DistinctBy(creator => creator.SeiyuuID) + .DistinctBy(creator => creator.CreatorID) .ToList(); - if (creators.Any()) + if (creators.Count is not 0) requests.AddRange(creators - .Where(a => ForceDownload || !_imageHandler.IsImageCached(ImageEntityType.AniDB_Creator, a.SeiyuuID)) + .Where(a => ForceDownload || !(a.GetImageMetadata()?.IsLocalAvailable ?? false)) .Select(va => new Action<DownloadAniDBImageJob>(a => { - a.Anime = _title; - a.ImageID = va.SeiyuuID; - a.ImageType = ImageEntityType.AniDB_Creator; + a.ParentName = _title; + a.ImageID = va.CreatorID; + a.ImageType = ImageEntityType.Person; a.ForceDownload = ForceDownload; }))); else @@ -137,12 +136,11 @@ await scheduler.StartJobNow<DownloadAniDBImageJob>(a => } } - public GetAniDBImagesJob(AniDBTitleHelper aniDBTitleHelper, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, AniDBImageHandler imageHandler) + public GetAniDBImagesJob(AniDBTitleHelper aniDBTitleHelper, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory) { _titleHelper = aniDBTitleHelper; _settingsProvider = settingsProvider; _schedulerFactory = schedulerFactory; - _imageHandler = imageHandler; } protected GetAniDBImagesJob() { } diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBMessageJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBMessageJob.cs new file mode 100644 index 000000000..e73b3afc8 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBMessageJob.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.User; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; +using Shoko.Server.Scheduling.Jobs.Shoko; +using Shoko.Server.Server; +using Shoko.Server.Settings; + +namespace Shoko.Server.Scheduling.Jobs.AniDB; + +[DatabaseRequired] +[AniDBUdpRateLimited] +[DisallowConcurrencyGroup(ConcurrencyGroups.AniDB_UDP)] +[JobKeyGroup(JobKeyGroup.AniDB)] +public class GetAniDBMessageJob : BaseJob +{ + private readonly IRequestFactory _requestFactory; + private readonly ISchedulerFactory _schedulerFactory; + private readonly ISettingsProvider _settingsProvider; + public int MessageID { get; set; } + + public override string TypeName => "Get AniDB Message Content"; + + public override string Title => "Getting AniDB Message Content"; + + public override async Task Process() + { + _logger.LogInformation("Processing {Job}: {MessageID}", nameof(GetAniDBMessageJob), MessageID); + + var message = RepoFactory.AniDB_Message.GetByMessageId(MessageID); + if (message is not null) return; // message content has already been fetched + + var request = _requestFactory.Create<RequestGetMessageContent>(r => r.ID = MessageID); + var response = request.Send(); + if (response?.Response == null) return; + + message = new() + { + MessageID = MessageID, + FromUserId = response.Response.SenderID, + FromUserName = response.Response.SenderName, + SentAt = response.Response.SentTime, + FetchedAt = DateTime.Now, + Type = response.Response.Type, + Title = response.Response.Title, + Body = response.Response.Body, + Flags = AniDBMessageFlags.None + }; + + // set flag if its a file moved system message + if (message.Type == AniDBMessageType.System && message.Title.ToLower().StartsWith("file moved:")) + { + message.IsFileMoved = true; + } + + // save to db and remove from queue + RepoFactory.AniDB_Message.Save(message); + RepoFactory.AniDB_NotifyQueue.DeleteForTypeID(AniDBNotifyType.Message, MessageID); + + var settings = _settingsProvider.GetSettings(); + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJob<AcknowledgeAniDBNotifyJob>( + r => + { + r.NotifyType = AniDBNotifyType.Message; + r.NotifyID = MessageID; + } + ); + + if (message.IsFileMoved && settings.AniDb.Notification_HandleMovedFiles) + { + await scheduler.StartJob<ProcessFileMovedMessageJob>(c => c.MessageID = message.MessageID); + } + } + + public GetAniDBMessageJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider) + { + _requestFactory = requestFactory; + _schedulerFactory = schedulerFactory; + _settingsProvider = settingsProvider; + } + + protected GetAniDBMessageJob() + { + } +} diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBNotifyJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBNotifyJob.cs new file mode 100644 index 000000000..e55325934 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBNotifyJob.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.User; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; +using Shoko.Server.Server; +using Shoko.Server.Services; + +namespace Shoko.Server.Scheduling.Jobs.AniDB; + +[DatabaseRequired] +[AniDBUdpRateLimited] +[DisallowConcurrencyGroup(ConcurrencyGroups.AniDB_UDP)] +[JobKeyGroup(JobKeyGroup.AniDB)] +public class GetAniDBNotifyJob : BaseJob +{ + private readonly IRequestFactory _requestFactory; + private readonly ISchedulerFactory _schedulerFactory; + + public override string TypeName => "Fetch Unread AniDB Messages List"; + + public override string Title => "Fetching Unread AniDB Messages List"; + + public override async Task Process() + { + _logger.LogInformation("Processing {Job}", nameof(GetAniDBNotifyJob)); + + var requestCount = _requestFactory.Create<RequestGetNotifyCount>(r => r.Buddies = false); // we do not care about the number of online buddies + var responseCount = requestCount.Send(); + if (responseCount?.Response == null) return; + + var unreadCount = responseCount.Response.Files + responseCount.Response.Messages; + if (unreadCount > 0) + { + _logger.LogInformation("There are {Count} unread notifications and messages", unreadCount); + + // request an ID list of all unread messages and notifications + var request = _requestFactory.Create<RequestGetNotifyList>(); + var response = request.Send(); + if (response?.Response == null) return; + + foreach (var notify in response.Response) + { + var type = RepoFactory.AniDB_NotifyQueue.GetByTypeID(notify.Type, notify.ID); + if (type is not null) continue; // if we already have it in the queue + + if (notify.Type == AniDBNotifyType.Message) + { + var msg = RepoFactory.AniDB_Message.GetByMessageId(notify.ID); + if (msg is not null) continue; // if the message content was already fetched + } + + // save to db queue + type = new() + { + Type = notify.Type, + ID = notify.ID, + AddedAt = DateTime.Now + }; + RepoFactory.AniDB_NotifyQueue.Save(type); + } + } + + // fetch the content of all messages currently in the queue + var messages = RepoFactory.AniDB_NotifyQueue.GetByType(AniDBNotifyType.Message); + if (messages.Count > 0) + { + var scheduler = await _schedulerFactory.GetScheduler(); + foreach (var msg in messages) + await scheduler.StartJob<GetAniDBMessageJob>(r => r.MessageID = msg.ID); + } + } + + public GetAniDBNotifyJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory) + { + _requestFactory = requestFactory; + _schedulerFactory = schedulerFactory; + } + + protected GetAniDBNotifyJob() + { + } +} diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupJob.cs index 9bb3699c5..82d78af9b 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupJob.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Shoko.Models.Server; using Shoko.Server.Providers.AniDB.Interfaces; using Shoko.Server.Providers.AniDB.UDP.Info; using Shoko.Server.Repositories; @@ -43,7 +42,7 @@ public override Task Process() if (response?.Response == null) return Task.CompletedTask; - relGroup ??= new AniDB_ReleaseGroup(); + relGroup ??= new(); relGroup.GroupID = response.Response.ID; relGroup.Rating = (int)(response.Response.Rating * 100); relGroup.Votes = response.Response.Votes; @@ -58,7 +57,7 @@ public override Task Process() RepoFactory.AniDB_ReleaseGroup.Save(relGroup); return Task.CompletedTask; } - + public GetAniDBReleaseGroupJob(IRequestFactory requestFactory) { _requestFactory = requestFactory; diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupStatusJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupStatusJob.cs index 3a39440fa..15b016347 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupStatusJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetAniDBReleaseGroupStatusJob.cs @@ -104,7 +104,7 @@ public override async Task Process() { // update the anime with a record of the latest subbed episode anime.LatestEpisodeNumber = maxEpisode; - RepoFactory.AniDB_Anime.Save(anime, false); + RepoFactory.AniDB_Anime.Save(anime); // check if we have this episode in the database // if not get it now by updating the anime record @@ -126,7 +126,7 @@ await scheduler.StartJobNow<GetAniDBAnimeJob>(c => if (settings.AniDb.DownloadReleaseGroups && response is { Response.Count: > 0 }) { // shouldn't need the where, but better safe than sorry. - foreach(var g in response.Response.DistinctBy(a => a.GroupID).Where(a => a.GroupID != 0)) + foreach (var g in response.Response.DistinctBy(a => a.GroupID).Where(a => a.GroupID != 0)) { await scheduler.StartJob<GetAniDBReleaseGroupJob>(c => c.GroupID = g.GroupID); } diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/GetUpdatedAniDBAnimeJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/GetUpdatedAniDBAnimeJob.cs index dd352ffb4..9461505a0 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/GetUpdatedAniDBAnimeJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/GetUpdatedAniDBAnimeJob.cs @@ -4,6 +4,7 @@ using Quartz; using Shoko.Models.Server; using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.Titles; using Shoko.Server.Providers.AniDB.UDP.Generic; using Shoko.Server.Providers.AniDB.UDP.Info; using Shoko.Server.Repositories; @@ -26,6 +27,7 @@ public class GetUpdatedAniDBAnimeJob : BaseJob private readonly IRequestFactory _requestFactory; private readonly ISchedulerFactory _schedulerFactory; private readonly ISettingsProvider _settingsProvider; + private readonly AniDBTitleHelper _titleHelper; public bool ForceRefresh { get; set; } @@ -38,51 +40,51 @@ public override async Task Process() _logger.LogInformation("Processing {Job}", nameof(GetUpdatedAniDBAnimeJob)); // check the automated update table to see when the last time we ran this command - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBUpdates); - if (sched != null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBUpdates); + if (schedule is not null) { var settings = _settingsProvider.GetSettings(); var freqHours = Utils.GetScheduledHours(settings.AniDb.Anime_UpdateFrequency); // if we have run this in the last 12 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; + var tsLastRun = DateTime.Now - schedule.LastUpdate; if (tsLastRun.TotalHours < freqHours && !ForceRefresh) return; } DateTime webUpdateTime; - if (sched == null) + if (schedule is null) { // if this is the first time, lets ask for last 3 days webUpdateTime = DateTime.UtcNow.AddDays(-3); - sched = new ScheduledUpdate { UpdateType = (int)ScheduledUpdateType.AniDBUpdates }; + schedule = new ScheduledUpdate { UpdateType = (int)ScheduledUpdateType.AniDBUpdates }; } else { - _logger.LogTrace("Last AniDB info update was : {UpdateDetails}", sched.UpdateDetails); - webUpdateTime = DateTime.UnixEpoch.AddSeconds(long.Parse(sched.UpdateDetails)); + _logger.LogTrace("Last AniDB info update was : {UpdateDetails}", schedule.UpdateDetails); + webUpdateTime = DateTime.UnixEpoch.AddSeconds(long.Parse(schedule.UpdateDetails)); _logger.LogInformation("{UpdateTime} since last UPDATED command", DateTime.UtcNow - webUpdateTime); } - var (response, countAnime, countSeries) = await Update(webUpdateTime, sched, 0, 0); + var (response, countAnime, countSeries) = await Update(webUpdateTime, schedule, 0, 0); while (response?.Response?.Count > 200) { - (response, countAnime, countSeries) = await Update(response.Response.LastUpdated, sched, countAnime, countSeries); + (response, countAnime, countSeries) = await Update(response.Response.LastUpdated, schedule, countAnime, countSeries); } _logger.LogInformation("Updating {Count} anime records, and {CountSeries} group status records", countAnime, countSeries); } - private async Task<(UDPResponse<ResponseUpdatedAnime> response, int countAnime, int countSeries)> Update(DateTime webUpdateTime, ScheduledUpdate sched, int countAnime, int countSeries) + private async Task<(UDPResponse<ResponseUpdatedAnime> response, int countAnime, int countSeries)> Update(DateTime webUpdateTime, ScheduledUpdate schedule, int countAnime, int countSeries) { // get a list of updates from AniDB // startTime will contain the date/time from which the updates apply to var request = _requestFactory.Create<RequestUpdatedAnime>(r => r.LastUpdated = webUpdateTime); var response = request.Send(); - if (response?.Response == null) + if (response?.Response is null) { return (null, countAnime, countSeries); } @@ -91,9 +93,9 @@ public override async Task Process() // now save the update time from AniDB // we will use this next time as a starting point when querying the web cache - sched.LastUpdate = DateTime.Now; - sched.UpdateDetails = ((int)(response.Response.LastUpdated - DateTime.UnixEpoch).TotalSeconds).ToString(); - RepoFactory.ScheduledUpdate.Save(sched); + schedule.LastUpdate = DateTime.Now; + schedule.UpdateDetails = ((int)(response.Response.LastUpdated - DateTime.UnixEpoch).TotalSeconds).ToString(); + RepoFactory.ScheduledUpdate.Save(schedule); if (animeIDsToUpdate.Count == 0) { @@ -106,13 +108,27 @@ public override async Task Process() { // update the anime from HTTP var anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID); - if (anime == null) + if (anime is null) { - _logger.LogTrace("No local record found for Anime ID: {AnimeID}, so skipping...", animeID); + var name = _titleHelper.SearchAnimeID(animeID)?.MainTitle ?? "<Unknown>"; + if (settings.AniDb.AutomaticallyImportSeries) + { + _logger.LogInformation("Scheduling update for anime: {AnimeTitle} ({AnimeID})", name, animeID); + await (await _schedulerFactory.GetScheduler()).StartJob<GetAniDBAnimeJob>(c => + { + c.AnimeID = animeID; + c.CreateSeriesEntry = true; + }); + countAnime++; + } + else + { + _logger.LogTrace("Skipping update for anime because it's not in the local collection: {AnimeTitle} ({AnimeID})", name, animeID); + } continue; } - _logger.LogInformation("Scheduling Update for {AnimeID} ", anime.MainTitle); + _logger.LogInformation("Scheduling update for anime: {AnimeTitle} ({AnimeID})", anime.MainTitle, animeID); var update = RepoFactory.AniDB_AnimeUpdate.GetByAnimeID(animeID); // but only if it hasn't been recently updated @@ -131,7 +147,7 @@ public override async Task Process() // this will allow us to determine which anime has missing episodes // we only get by an anime where we also have an associated series var ser = RepoFactory.AnimeSeries.GetByAnimeID(animeID); - if (ser == null) continue; + if (ser is null) continue; await (await _schedulerFactory.GetScheduler()).StartJob<GetAniDBReleaseGroupStatusJob>(c => { @@ -143,12 +159,13 @@ public override async Task Process() return (response, countAnime, countSeries); } - - public GetUpdatedAniDBAnimeJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider) + + public GetUpdatedAniDBAnimeJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, AniDBTitleHelper titleHelper) { _requestFactory = requestFactory; _schedulerFactory = schedulerFactory; _settingsProvider = settingsProvider; + _titleHelper = titleHelper; } protected GetUpdatedAniDBAnimeJob() diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/UpdateMyListFileStatusJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/UpdateMyListFileStatusJob.cs index cc1ba015d..40bcf31fd 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/UpdateMyListFileStatusJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/UpdateMyListFileStatusJob.cs @@ -27,7 +27,7 @@ public class UpdateMyListFileStatusJob : BaseJob private string FullFileName { get; set; } public string Hash { get; set; } - public bool Watched { get; set; } + public bool? Watched { get; set; } public bool UpdateSeriesStats { get; set; } public DateTime? WatchedDate { get; set; } @@ -69,8 +69,7 @@ public override async Task Process() r.State = settings.AniDb.MyList_StorageState.GetMyList_State(); r.Hash = vid.Hash; r.Size = vid.FileSize; - if (!Watched || WatchedDate == null) return; - r.IsWatched = true; + r.IsWatched = Watched; r.WatchedDate = WatchedDate; } ); @@ -92,8 +91,7 @@ public override async Task Process() r.AnimeID = episode.AnimeID; r.EpisodeNumber = episode.EpisodeNumber; r.EpisodeType = (EpisodeType)episode.EpisodeType; - if (!Watched || WatchedDate == null) return; - r.IsWatched = true; + r.IsWatched = Watched; r.WatchedDate = WatchedDate; } ); diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/VoteAniDBAnimeJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/VoteAniDBAnimeJob.cs index e96a9806a..92706c73c 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/VoteAniDBAnimeJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/VoteAniDBAnimeJob.cs @@ -25,7 +25,7 @@ public class VoteAniDBAnimeJob : BaseJob public int AnimeID { get; set; } public AniDBVoteType VoteType { get; set; } - public decimal VoteValue { get; set; } + public double VoteValue { get; set; } public override void PostInit() { @@ -50,7 +50,7 @@ public override Task Process() r => { r.Temporary = VoteType == AniDBVoteType.AnimeTemp; - r.Value = Convert.ToDouble(VoteValue); + r.Value = VoteValue; r.AnimeID = AnimeID; } ); diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/VoteAniDBEpisodeJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/VoteAniDBEpisodeJob.cs new file mode 100644 index 000000000..b28fc577b --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/AniDB/VoteAniDBEpisodeJob.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Providers.AniDB.UDP.User; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; + +namespace Shoko.Server.Scheduling.Jobs.AniDB; + +[DatabaseRequired] +[AniDBUdpRateLimited] +[DisallowConcurrencyGroup(ConcurrencyGroups.AniDB_UDP)] +[JobKeyGroup(JobKeyGroup.AniDB)] +public class VoteAniDBEpisodeJob : BaseJob +{ + private readonly IRequestFactory _requestFactory; + private string _animeName; + private string _episodeName; + + public int EpisodeID { get; set; } + public double VoteValue { get; set; } + + public override void PostInit() + { + var episode = RepoFactory.AnimeEpisode.GetByID(EpisodeID); + _animeName = episode?.AnimeSeries?.PreferredTitle ?? EpisodeID.ToString(); + _episodeName = episode?.PreferredTitle ?? EpisodeID.ToString(); + } + + public override string TypeName => "Send AniDB Episode Rating"; + + public override string Title => "Sending AniDB Episode Rating"; + public override Dictionary<string, object> Details => new() + { + { "Anime", _animeName }, + { "Episode", _episodeName }, + { "Vote", VoteValue }, + }; + + public override Task Process() + { + _logger.LogInformation("Processing {Job} for {EpisodeID} | {Value}", nameof(VoteAniDBEpisodeJob), EpisodeID, VoteValue); + + var vote = _requestFactory.Create<RequestVoteEpisode>( + r => + { + r.EpisodeID = EpisodeID; + r.Value = VoteValue; + } + ); + vote.Send(); + return Task.CompletedTask; + } + + public VoteAniDBEpisodeJob(IRequestFactory requestFactory) + { + _requestFactory = requestFactory; + } + + protected VoteAniDBEpisodeJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/BaseJob.cs b/Shoko.Server/Scheduling/Jobs/BaseJob.cs index 5c0dd4dae..7464ac228 100644 --- a/Shoko.Server/Scheduling/Jobs/BaseJob.cs +++ b/Shoko.Server/Scheduling/Jobs/BaseJob.cs @@ -6,18 +6,44 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Quartz; +using Shoko.Server.Providers.AniDB; +using Shoko.Server.Providers.AniDB.UDP.Exceptions; namespace Shoko.Server.Scheduling.Jobs; [UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] public abstract class BaseJob : IJob { + [XmlIgnore, JsonIgnore] + public ILogger _logger; + + [XmlIgnore, JsonIgnore] + public abstract string TypeName { get; } + + [XmlIgnore, JsonIgnore] + public abstract string Title { get; } + + [XmlIgnore, JsonIgnore] + public virtual Dictionary<string, object> Details { get; } = []; + public async ValueTask Execute(IJobExecutionContext context) { try { await Process(); } + catch (NotLoggedInException) + { + await context.RescheduleJob(); + } + catch (LoginFailedException) + { + await context.RescheduleJob(); + } + catch (AniDBBannedException) + { + await context.RescheduleJob(); + } catch (Exception ex) { // _logger.LogError(ex, "Job threw an error on Execution: {Job} | Error -> {Ex}", context.JobDetail.Key, ex); @@ -26,11 +52,6 @@ public async ValueTask Execute(IJobExecutionContext context) } public abstract Task Process(); - - [XmlIgnore] [JsonIgnore] public ILogger _logger; - [XmlIgnore] [JsonIgnore] public abstract string TypeName { get; } - [XmlIgnore] [JsonIgnore] public abstract string Title { get; } - [XmlIgnore] [JsonIgnore] public virtual Dictionary<string, object> Details { get; } = new(); public virtual void PostInit() { } } diff --git a/Shoko.Server/Scheduling/Jobs/DownloadImageBaseJob.cs b/Shoko.Server/Scheduling/Jobs/DownloadImageBaseJob.cs new file mode 100644 index 000000000..c9423366d --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/DownloadImageBaseJob.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Utilities; + +#nullable enable +namespace Shoko.Server.Scheduling.Jobs; + +[DatabaseRequired] +[NetworkRequired] +[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] +public abstract class DownloadImageBaseJob : BaseJob, IImageDownloadJob +{ + public string? ParentName { get; set; } + + public int ImageID { get; set; } + + public bool ForceDownload { get; set; } + + public abstract DataSourceEnum Source { get; } + + public virtual ImageEntityType ImageType { get; set; } + + public override string TypeName => $"Download {Source} Image"; + + public override string Title => $"Downloading {Source} Image"; + + public override Dictionary<string, object> Details => new() + { + { "Type", ImageType.ToString() }, + { "ImageID", ImageID }, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing {Job} for {Parent} -> Image Type: {ImageType} | ImageID: {EntityID}", GetType().Name, ParentName, ImageType, ImageID); + + var imageType = $"{Source} {ImageType.ToString().Replace("_", " ")}"; + var image = ImageUtils.GetImageMetadata(Source, ImageType, ImageID); + if (image is null) + { + _logger.LogWarning("Image failed to download: Can\'t find valid {ImageType} with ID: {ImageID}", imageType, ImageID); + return; + } + if (string.IsNullOrEmpty(image.LocalPath) || string.IsNullOrEmpty(image.RemoteURL)) + { + _logger.LogWarning("Image failed to download: Can\'t find valid {ImageType} with ID: {ImageID}", imageType, ImageID); + return; + } + + try + { + var previouslyDownloaded = image.IsLocalAvailable; + var result = await image.DownloadImage(ForceDownload); + if (result && (ForceDownload || !previouslyDownloaded)) + { + _logger.LogInformation("Image downloaded for {Parent}: {FilePath} from {DownloadUrl}", ParentName, image.LocalPath, image.RemoteURL); + } + else if (result) + { + _logger.LogDebug("Image already in cache for {Parent}: {FilePath} from {DownloadUrl}", ParentName, image.LocalPath, image.RemoteURL); + } + else + { + _logger.LogWarning("Image failed to download for {Parent}: {FilePath} from {DownloadUrl}", ParentName, image.LocalPath, image.RemoteURL); + } + } + catch (Exception e) + { + switch (e) + { + case HttpRequestException hre when hre.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden or HttpStatusCode.ExpectationFailed: + if (RemoveRecord()) + _logger.LogWarning("Image failed to download for {Parent} and the local entry has been removed: {FilePath} from {DownloadUrl}", ParentName, image.LocalPath, image.RemoteURL); + else + _logger.LogWarning("Image failed to download for {Parent} and the local entry could not be removed: {FilePath} from {DownloadUrl}", ParentName, image.LocalPath, image.RemoteURL); + break; + + default: + _logger.LogWarning("Error processing {Job} for {Parent}: {Url} - {Message}", GetType().Name, ParentName, image.RemoteURL, e.Message); + break; + } + } + } + + protected virtual bool RemoveRecord() + { + return false; + } + + protected DownloadImageBaseJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/IImageDownloadJob.cs b/Shoko.Server/Scheduling/Jobs/IImageDownloadJob.cs index bc1582641..1f2095af4 100644 --- a/Shoko.Server/Scheduling/Jobs/IImageDownloadJob.cs +++ b/Shoko.Server/Scheduling/Jobs/IImageDownloadJob.cs @@ -1,12 +1,16 @@ using Quartz; -using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +#nullable enable namespace Shoko.Server.Scheduling.Jobs; public interface IImageDownloadJob : IJob { - string Anime { get; set; } - int ImageID { get; set; } + string? ParentName { get; set; } + bool ForceDownload { get; set; } + + int ImageID { get; set; } + ImageEntityType ImageType { get; set; } } diff --git a/Shoko.Server/Scheduling/Jobs/JobKeyGroup.cs b/Shoko.Server/Scheduling/Jobs/JobKeyGroup.cs index 982101479..851cb3167 100644 --- a/Shoko.Server/Scheduling/Jobs/JobKeyGroup.cs +++ b/Shoko.Server/Scheduling/Jobs/JobKeyGroup.cs @@ -4,7 +4,6 @@ public static class JobKeyGroup { public const string Import = "Import"; public const string AniDB = "AniDB"; - public const string TvDB = "TvDB"; public const string TMDB = "TMDB"; public const string Trakt = "TraktTv"; public const string Legacy = "Legacy"; diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/DeleteImportFolderJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/DeleteImportFolderJob.cs index bb703d2f4..1b8e1a7f9 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/DeleteImportFolderJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/DeleteImportFolderJob.cs @@ -5,6 +5,7 @@ using Shoko.Server.Scheduling.Attributes; using Shoko.Server.Services; +#pragma warning disable CS8618 namespace Shoko.Server.Scheduling.Jobs.Shoko; [DatabaseRequired] diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs index 15986ee74..901b18801 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs @@ -15,6 +15,8 @@ using Shoko.Server.Settings; using Shoko.Server.Utilities; +#pragma warning disable CS8618 +#pragma warning disable CS0618 namespace Shoko.Server.Scheduling.Jobs.Shoko; [DatabaseRequired] @@ -83,8 +85,7 @@ public override async Task Process() var scheduler = await _schedulerFactory.GetScheduler(); // if !shouldHash, then we definitely have a hash - var hasXrefs = vlocal.EpisodeCrossRefs.Any(a => - RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(a.EpisodeID) != null && RepoFactory.AnimeSeries.GetByAnimeID(a.AnimeID) != null); + var hasXrefs = vlocal.EpisodeCrossRefs.Any(a => a.AnimeEpisode is not null && a.AnimeSeries is not null); if (!shouldHash && hasXrefs && !vlocal.DateTimeImported.HasValue) { vlocal.DateTimeImported = DateTime.Now; @@ -153,10 +154,10 @@ await scheduler.StartJobNow<HashFileJob>(a => return default; } - var nshareID = folder.ImportFolderID; + var importFolderID = folder.ImportFolderID; // check if we have already processed this file - var vlocalplace = RepoFactory.VideoLocalPlace.GetByFilePathAndImportFolderID(filePath, nshareID); + var vlocalplace = RepoFactory.VideoLocalPlace.GetByFilePathAndImportFolderID(filePath, importFolderID); SVR_VideoLocal vlocal = null; var filename = Path.GetFileName(filePath); @@ -205,19 +206,21 @@ await scheduler.StartJobNow<HashFileJob>(a => _logger.LogTrace("No existing VideoLocal_Place, creating a new record"); vlocalplace = new SVR_VideoLocal_Place { - FilePath = filePath, ImportFolderID = nshareID, ImportFolderType = folder.ImportFolderType + FilePath = filePath, + ImportFolderID = importFolderID, + ImportFolderType = folder.ImportFolderType, }; if (vlocal.VideoLocalID != 0) vlocalplace.VideoLocalID = vlocal.VideoLocalID; } - + return (vlocal, vlocalplace); } - + private bool TrySetHashFromXrefs(string filename, SVR_VideoLocal vlocal) { var crossRefs = RepoFactory.CrossRef_File_Episode.GetByFileNameAndSize(filename, vlocal.FileSize); - if (!crossRefs.Any()) return false; + if (crossRefs.Count == 0) return false; vlocal.Hash = crossRefs[0].Hash; vlocal.HashSource = (int)HashSource.DirectHash; @@ -228,28 +231,28 @@ private bool TrySetHashFromXrefs(string filename, SVR_VideoLocal vlocal) private bool TrySetHashFromFileNameHash(string filename, SVR_VideoLocal vlocal) { // TODO support reading MD5 and SHA1 from files via the standard way - var fnhashes = RepoFactory.FileNameHash.GetByFileNameAndSize(filename, vlocal.FileSize); - if (fnhashes is { Count: > 1 }) + var hashes = RepoFactory.FileNameHash.GetByFileNameAndSize(filename, vlocal.FileSize); + if (hashes is { Count: > 1 }) { // if we have more than one record it probably means there is some sort of corruption // lets delete the local records - foreach (var fnh in fnhashes) + foreach (var fnh in hashes) { RepoFactory.FileNameHash.Delete(fnh.FileNameHashID); } } // reinit this to check if we erased them - fnhashes = RepoFactory.FileNameHash.GetByFileNameAndSize(filename, vlocal.FileSize); + hashes = RepoFactory.FileNameHash.GetByFileNameAndSize(filename, vlocal.FileSize); - if (fnhashes is not { Count: 1 }) return false; + if (hashes is not { Count: 1 }) return false; - _logger.LogTrace("Got hash from LOCAL cache: {Filename} ({Hash})", FilePath, fnhashes[0].Hash); - vlocal.Hash = fnhashes[0].Hash; + _logger.LogTrace("Got hash from LOCAL cache: {Filename} ({Hash})", FilePath, hashes[0].Hash); + vlocal.Hash = hashes[0].Hash; vlocal.HashSource = (int)HashSource.FileNameCache; return true; } - + private static bool FillHashesAgainstVideoLocalRepo(SVR_VideoLocal v) { var changed = false; @@ -336,7 +339,7 @@ private static bool FillHashesAgainstVideoLocalRepo(SVR_VideoLocal v) return false; } - + private (bool needEd2k, bool needCRC32, bool needMD5, bool needSHA1) ShouldHash(SVR_VideoLocal vlocal) { var hasherSettings = _settingsProvider.GetSettings().Import.Hasher; @@ -348,7 +351,7 @@ private static bool FillHashesAgainstVideoLocalRepo(SVR_VideoLocal v) } /// <summary> - /// + /// /// </summary> /// <param name="vlocal"></param> /// <param name="vlocalplace"></param> diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs index 5f5b109f0..708b9b207 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs @@ -19,6 +19,8 @@ using Shoko.Server.Settings; using Shoko.Server.Utilities; +#pragma warning disable CS8618 +#pragma warning disable CS0618 #nullable enable namespace Shoko.Server.Scheduling.Jobs.Shoko; @@ -389,13 +391,13 @@ private async Task<bool> ProcessDuplicates(SVR_VideoLocal vlocal, SVR_VideoLocal // remove missing files var preps = vlocal.Places.Where(a => { - if (vlocalplace.FullServerPath.Equals(a.FullServerPath)) return false; + if (string.Equals(a.FullServerPath, vlocalplace.FullServerPath)) return false; if (a.FullServerPath == null) return true; return !File.Exists(a.FullServerPath); }).ToList(); RepoFactory.VideoLocalPlace.Delete(preps); - var dupPlace = vlocal.Places.FirstOrDefault(a => !vlocalplace.FullServerPath.Equals(a.FullServerPath)); + var dupPlace = vlocal.Places.FirstOrDefault(a => !string.Equals(a.FullServerPath, vlocalplace.FullServerPath)); if (dupPlace == null) return false; _logger.LogWarning("Found Duplicate File"); diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/ProcessFileJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/ProcessFileJob.cs index c60cfabe9..7b2106376 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/ProcessFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/ProcessFileJob.cs @@ -16,6 +16,7 @@ using Shoko.Server.Scheduling.Concurrency; using Shoko.Server.Scheduling.Jobs.Actions; using Shoko.Server.Scheduling.Jobs.AniDB; +using Shoko.Server.Scheduling.Jobs.TMDB; using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -78,7 +79,7 @@ public override async Task Process() .Join(','); // Process and get the AniDB file entry. - var aniFile = await ProcessFile_AniDB(); + var aniFile = await ProcessFile_AniDB().ConfigureAwait(false); // Check if an AniDB file is now available and if the cross-references changed. var newXRefs = _vlocal.EpisodeCrossRefs @@ -105,8 +106,8 @@ public override async Task Process() } // Rename and/or move the physical file(s) if needed. - var scheduler = await _schedulerFactory.GetScheduler(); - await scheduler.StartJob<RenameMoveFileJob>(job => job.VideoLocalID = _vlocal.VideoLocalID); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + await scheduler.StartJob<RenameMoveFileJob>(job => job.VideoLocalID = _vlocal.VideoLocalID).ConfigureAwait(false); } private async Task<SVR_AniDB_File> ProcessFile_AniDB() @@ -118,10 +119,10 @@ private async Task<SVR_AniDB_File> ProcessFile_AniDB() var aniFile = GetLocalAniDBFile(_vlocal); if (aniFile == null || aniFile.FileSize == 0) - aniFile ??= await TryGetAniDBFileFromAniDB(animeIDs); + aniFile ??= await TryGetAniDBFileFromAniDB(animeIDs).ConfigureAwait(false); if (aniFile == null) return null; - await PopulateAnimeForFile(_vlocal, aniFile.EpisodeCrossRefs, animeIDs); + await PopulateAnimeForFile(_vlocal, aniFile.EpisodeCrossRefs, animeIDs).ConfigureAwait(false); // We do this inside, as the info will not be available as needed otherwise var videoLocals = @@ -134,7 +135,7 @@ private async Task<SVR_AniDB_File> ProcessFile_AniDB() GetWatchedStateIfNeeded(_vlocal, videoLocals); // update stats for groups and series. The series are not saved until here, so it's absolutely necessary!! - await Task.WhenAll(animeIDs.Keys.Select(a => _jobFactory.CreateJob<RefreshAnimeStatsJob>(b => b.AnimeID = a).Process())); + await Task.WhenAll(animeIDs.Keys.Select(a => _jobFactory.CreateJob<RefreshAnimeStatsJob>(b => b.AnimeID = a).Process())).ConfigureAwait(false); if (_settings.FileQualityFilterEnabled) { @@ -156,9 +157,7 @@ private async Task<SVR_AniDB_File> ProcessFile_AniDB() videoLocals = videoLocals.Where(a => !FileQualityFilter.CheckFileKeep(a)).ToList(); foreach (var place in videoLocals.SelectMany(a => a.Places)) - { - await _vlPlaceService.RemoveRecordAndDeletePhysicalFile(place); - } + await _vlPlaceService.RemoveRecordAndDeletePhysicalFile(place).ConfigureAwait(false); } // we have an AniDB File, so check the release group info @@ -170,18 +169,19 @@ private async Task<SVR_AniDB_File> ProcessFile_AniDB() // may as well download it immediately. We can change it later if it becomes an issue // this will only happen if it's null, and most people grab mostly the same release groups var groupCommand = _jobFactory.CreateJob<GetAniDBReleaseGroupJob>(c => c.GroupID = aniFile.GroupID); - await groupCommand.Process(); + await groupCommand.Process().ConfigureAwait(false); } } // Add this file to the users list if (_settings.AniDb.MyList_AddFiles && !SkipMyList && _vlocal.MyListID <= 0) { - await (await _schedulerFactory.GetScheduler()).StartJob<AddFileToMyListJob>(c => + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + await scheduler.StartJob<AddFileToMyListJob>(c => { c.Hash = _vlocal.Hash; c.ReadStates = true; - }); + }).ConfigureAwait(false); } return aniFile; @@ -288,7 +288,7 @@ private async Task PopulateAnimeForFile(SVR_VideoLocal vidLocal, List<SVR_CrossR // even if we are missing episode info, don't get data more than once every `x` hours // this is to prevent banning - var scheduler = await _schedulerFactory.GetScheduler(); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); if (missingEpisodes) { _logger.LogInformation("Queuing immediate GET for AniDB_Anime: {AnimeID}", animeID); @@ -299,7 +299,7 @@ await scheduler.StartJobNow<GetAniDBAnimeJob>(c => c.ForceRefresh = true; c.DownloadRelations = _settings.AutoGroupSeries || _settings.AniDb.DownloadRelatedAnime; c.CreateSeriesEntry = true; - }); + }).ConfigureAwait(false); } else if (!animeRecentlyUpdated) { @@ -310,8 +310,16 @@ await scheduler.StartJob<GetAniDBAnimeJob>(c => c.AnimeID = animeID; c.ForceRefresh = true; c.DownloadRelations = _settings.AutoGroupSeries || _settings.AniDb.DownloadRelatedAnime; - }); + }).ConfigureAwait(false); } + + var tmdbShowXrefs = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(animeID); + foreach (var xref in tmdbShowXrefs) + await scheduler.StartJob<UpdateTmdbShowJob>(job => + { + job.TmdbShowID = xref.TmdbShowID; + job.DownloadImages = true; + }).ConfigureAwait(false); } } @@ -335,19 +343,20 @@ private async Task<SVR_AniDB_File> TryGetAniDBFileFromAniDB(Dictionary<int, bool { c.VideoLocalID = _vlocal.VideoLocalID; c.ForceAniDB = true; - }).Process(); + }).Process().ConfigureAwait(false); } catch (AniDBBannedException) { // We're banned, so queue it for later _logger.LogError("We are banned. Re-queuing for later: {FileName}", _fileName); - await (await _schedulerFactory.GetScheduler()).StartJob<ProcessFileJob>( + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + await scheduler.StartJob<ProcessFileJob>( c => { c.VideoLocalID = _vlocal.VideoLocalID; c.ForceAniDB = true; - }); + }).ConfigureAwait(false); } } diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/ProcessFileMovedMessageJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/ProcessFileMovedMessageJob.cs new file mode 100644 index 000000000..c439e5ac4 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/Shoko/ProcessFileMovedMessageJob.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; + +namespace Shoko.Server.Scheduling.Jobs.Shoko; + +[DatabaseRequired] +[JobKeyGroup(JobKeyGroup.Actions)] +public class ProcessFileMovedMessageJob : BaseJob +{ + private readonly ISchedulerFactory _schedulerFactory; + public override string TypeName => "Handle Moved File Message"; + public override string Title => "Handling Moved File Message"; + public int MessageID { get; set; } + + public override async Task Process() + { + _logger.LogInformation("Processing {Job}: {MessageId}", nameof(ProcessFileMovedMessageJob), MessageID); + + var message = RepoFactory.AniDB_Message.GetByMessageId(MessageID); + if (message == null) return; + + if (message.IsFileMoveHandled) + { + _logger.LogInformation("File moved message already handled: {MessageId}", message.MessageID); + return; + } + + // title should be in the format "file moved: <fileID>" + if (!int.TryParse(message.Title[12..].Trim(), out var fileId)) + { + throw new Exception("Could not parse file ID from message title"); + } + + var file = RepoFactory.AniDB_File.GetByFileID(fileId); + if (file == null) + { + _logger.LogWarning("Could not find file with AniDB ID: {ID}", fileId); + return; + } + + var vlocal = RepoFactory.VideoLocal.GetByHash(file.Hash); + if (vlocal == null) + { + _logger.LogWarning("Could not find VideoLocal for file with AniDB ID and Hash: {ID} {Hash}", fileId, file.Hash); + return; + } + + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJob<ProcessFileJob>( + c => + { + c.VideoLocalID = vlocal.VideoLocalID; + c.ForceAniDB = true; + } + ).ContinueWith(t => + { + if (!t.IsFaulted) + { + // This runs after ProcessFileJob is successfully done, which might be at a later point in time + // Let us refetch the message to make sure we have the latest data and then mark it as handled + var msg = RepoFactory.AniDB_Message.GetByMessageId(MessageID); + if (msg == null) return; + + msg.IsFileMoveHandled = true; + RepoFactory.AniDB_Message.Save(msg); + } + }); + } + + public ProcessFileMovedMessageJob(ISchedulerFactory schedulerFactory) + { + _schedulerFactory = schedulerFactory; + } + + protected ProcessFileMovedMessageJob() + { + } +} diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/RenameMoveFileJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/RenameMoveFileJob.cs index 50aae1315..28ede0013 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/RenameMoveFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/RenameMoveFileJob.cs @@ -49,7 +49,11 @@ public override async Task Process() var places = _vlocal.Places; foreach (var place in places) - await _vlPlaceService.RenameAndMoveAsRequired(place); + { + var result = await _vlPlaceService.AutoRelocateFile(place); + if (!result.Success) + _logger.LogTrace(result.Exception, "Unable to move/rename file; {ErrorMessage}", result.ErrorMessage); + } } public RenameMoveFileJob(VideoLocal_PlaceService vlPlaceService) diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/ValidateAllImagesJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/ValidateAllImagesJob.cs index 1b82b7f37..9eda0f664 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/ValidateAllImagesJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/ValidateAllImagesJob.cs @@ -1,18 +1,20 @@ +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Quartz; using Shoko.Commons.Utils; -using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Extensions; using Shoko.Server.Repositories; using Shoko.Server.Scheduling.Acquisition.Attributes; using Shoko.Server.Scheduling.Attributes; using Shoko.Server.Scheduling.Jobs.AniDB; using Shoko.Server.Scheduling.Jobs.TMDB; -using Shoko.Server.Scheduling.Jobs.TvDB; using Shoko.Server.Settings; +#pragma warning disable CS8618 +#nullable enable namespace Shoko.Server.Scheduling.Jobs.Shoko; [DatabaseRequired] @@ -21,13 +23,15 @@ public class ValidateAllImagesJob : BaseJob { private const string ScanForType = "Scanning {EntityType} for corrupted images..."; private const string FoundCorruptedOfType = "Found {Count} corrupted {EntityType}"; - private const string CorruptImageFound = "Corrupt image found! Attempting Redownload: {FullImagePath}"; - private const string ReQueueingForDownload = "Deleting and queueing for redownload {CurrentCount}/{TotalCount}"; + private const string CorruptImageFound = "Corrupt image found! Attempting re-download: {FullImagePath}"; + private const string ReQueueingForDownload = "Deleting and queueing for re-download {CurrentCount}/{TotalCount}"; private readonly ISchedulerFactory _schedulerFactory; + private readonly IServerSettings _settings; public override string TypeName => "Validate All Images"; + public override string Title => "Validating All Images"; public override async Task Process() @@ -35,119 +39,35 @@ public override async Task Process() _logger.LogInformation("Processing {Job}", nameof(ValidateAllImagesJob)); var count = 0; - UpdateProgress(" - TvDB Episodes"); - _logger.LogInformation(ScanForType, "TvDB episodes"); - var episodes = RepoFactory.TvDB_Episode.GetAll() - .Where(episode => !Misc.IsImageValid(episode.GetFullImagePath())) - .ToList(); - - _logger.LogInformation(FoundCorruptedOfType, episodes.Count, episodes.Count == 1 ? "TvDB episode thumbnail" : "TvDB episode thumbnails"); - foreach (var episode in episodes) - { - _logger.LogTrace(CorruptImageFound, episode.GetFullImagePath()); - await RemoveImageAndQueueRedownload<DownloadTvDBImageJob>(ImageEntityType.TvDB_Episode, episode.TvDB_EpisodeID); - if (++count % 10 != 0) continue; - _logger.LogInformation(ReQueueingForDownload, count, episodes.Count); - UpdateProgress($" - TvDB Episodes - {count}/{episodes.Count}"); - } - - if (_settings.TvDB.AutoPosters) - { - count = 0; - UpdateProgress(" - TvDB Posters"); - _logger.LogInformation(ScanForType, "TvDB posters"); - var posters = RepoFactory.TvDB_ImagePoster.GetAll() - .Where(poster => !Misc.IsImageValid(poster.GetFullImagePath())) - .ToList(); - - _logger.LogInformation(FoundCorruptedOfType, posters.Count, posters.Count == 1 ? "TvDB poster" : "TvDB posters"); - foreach (var poster in posters) - { - _logger.LogTrace(CorruptImageFound, poster.GetFullImagePath()); - await RemoveImageAndQueueRedownload<DownloadTvDBImageJob>(ImageEntityType.TvDB_Cover, poster.TvDB_ImagePosterID); - if (++count % 10 != 0) continue; - _logger.LogInformation(ReQueueingForDownload, count, posters.Count); - UpdateProgress($" - TvDB Posters - {count}/{posters.Count}"); - } - } - - if (_settings.TvDB.AutoFanart) - { - count = 0; - UpdateProgress(" - TvDB Fanart"); - _logger.LogInformation(ScanForType, "TvDB fanart"); - var fanartList = RepoFactory.TvDB_ImageFanart.GetAll() - .Where(fanart => !Misc.IsImageValid(fanart.GetFullImagePath())) - .ToList(); - - _logger.LogInformation(FoundCorruptedOfType, fanartList.Count, "TvDB fanart"); - foreach (var fanart in fanartList) - { - _logger.LogTrace(CorruptImageFound, fanart.GetFullImagePath()); - await RemoveImageAndQueueRedownload<DownloadTvDBImageJob>(ImageEntityType.TvDB_FanArt, fanart.TvDB_ImageFanartID); - if (++count % 10 != 0) continue; - _logger.LogInformation(ReQueueingForDownload, count, fanartList.Count); - UpdateProgress($" - TvDB Fanart - {count}/{fanartList.Count}"); - } - } - - if (_settings.TvDB.AutoWideBanners) - { - count = 0; - _logger.LogInformation(ScanForType, "TvDB wide-banners"); - UpdateProgress(" - TvDB Banners"); - var wideBanners = RepoFactory.TvDB_ImageWideBanner.GetAll() - .Where(wideBanner => !Misc.IsImageValid(wideBanner.GetFullImagePath())) - .ToList(); - - _logger.LogInformation(FoundCorruptedOfType, wideBanners.Count, wideBanners.Count == 1 ? "TvDB wide-banner" : "TvDB wide-banners"); - foreach (var wideBanner in wideBanners) - { - _logger.LogTrace(CorruptImageFound, wideBanner.GetFullImagePath()); - await RemoveImageAndQueueRedownload<DownloadTvDBImageJob>(ImageEntityType.TvDB_Banner, wideBanner.TvDB_ImageWideBannerID); - if (++count % 10 != 0) continue; - _logger.LogInformation(ReQueueingForDownload, count, wideBanners.Count); - UpdateProgress($" - TvDB Banners - {count}/{wideBanners.Count}"); - } - } - - if (_settings.MovieDb.AutoPosters) - { - count = 0; - UpdateProgress(" - TMDB Posters"); - _logger.LogInformation(ScanForType, "TMDB posters"); - var posters = RepoFactory.MovieDB_Poster.GetAll() - .Where(poster => !Misc.IsImageValid(poster.GetFullImagePath())) - .ToList(); - - _logger.LogInformation(FoundCorruptedOfType, posters.Count, posters.Count == 1 ? "TMDB poster" : "TMDB posters"); - foreach (var poster in posters) - { - _logger.LogTrace(CorruptImageFound, poster.GetFullImagePath()); - await RemoveImageAndQueueRedownload<DownloadTMDBImageJob>(ImageEntityType.MovieDB_Poster, poster.MovieDB_PosterID); - if (++count % 10 != 0) continue; - _logger.LogInformation(ReQueueingForDownload, count, posters.Count); - UpdateProgress($" - TMDB Posters - {count}/{posters.Count}"); - } - } - - if (_settings.MovieDb.AutoFanart) + List<(ImageEntityType, bool)> tmdbTypes = [ + (ImageEntityType.Poster, _settings.TMDB.AutoDownloadPosters), + (ImageEntityType.Backdrop, _settings.TMDB.AutoDownloadBackdrops), + (ImageEntityType.Person, _settings.TMDB.AutoDownloadStaffImages), + (ImageEntityType.Logo, _settings.TMDB.AutoDownloadLogos), + (ImageEntityType.Art, _settings.TMDB.AutoDownloadStudioImages), + (ImageEntityType.Thumbnail, _settings.TMDB.AutoDownloadThumbnails), + ]; + foreach (var (imageType, enabled) in tmdbTypes) { - UpdateProgress(" - TMDB Fanart"); + if (!enabled) continue; + var pluralUpper = $"TMDB {(imageType is ImageEntityType.Person ? "People" : imageType.ToString())}"; + var pluralLower = $"TMDB {pluralUpper[5..].ToLowerInvariant()}"; + var singularLower = $"TMDB {imageType.ToString().ToLowerInvariant()}"; count = 0; - _logger.LogInformation(ScanForType, "TMDB fanart"); - var fanartList = RepoFactory.MovieDB_Fanart.GetAll() - .Where(fanart => !Misc.IsImageValid(fanart.GetFullImagePath())) + UpdateProgress($" - {pluralUpper}"); + _logger.LogInformation(ScanForType, pluralLower); + var images = RepoFactory.TMDB_Image.GetByType(imageType) + .Where(image => !image.IsLocalAvailable) .ToList(); - _logger.LogInformation(FoundCorruptedOfType, fanartList.Count, "TMDB fanart"); - foreach (var fanart in fanartList) + _logger.LogInformation(FoundCorruptedOfType, images.Count, images.Count == 1 ? singularLower : pluralLower); + foreach (var image in images) { - _logger.LogTrace(CorruptImageFound, fanart.GetFullImagePath()); - await RemoveImageAndQueueRedownload<DownloadTMDBImageJob>(ImageEntityType.MovieDB_FanArt, fanart.MovieDB_FanartID); + _logger.LogTrace(CorruptImageFound, image.LocalPath); + await RemoveImageAndQueueDownload<DownloadTmdbImageJob>(image.ImageType, image.TMDB_ImageID); if (++count % 10 != 0) continue; - _logger.LogInformation(ReQueueingForDownload, count, fanartList.Count); - UpdateProgress($" - TMDB Fanart - {count}/{fanartList.Count}"); + _logger.LogInformation(ReQueueingForDownload, count, images.Count); + UpdateProgress($" - {pluralUpper} - {count}/{images.Count}"); } } @@ -155,14 +75,14 @@ public override async Task Process() UpdateProgress(" - AniDB Posters"); _logger.LogInformation(ScanForType, "AniDB posters"); var animeList = RepoFactory.AniDB_Anime.GetAll() - .Where(anime => !Misc.IsImageValid(anime.PosterPath)) + .Where(anime => !anime.GetImageMetadata().IsLocalAvailable) .ToList(); _logger.LogInformation(FoundCorruptedOfType, animeList.Count, animeList.Count == 1 ? "AniDB poster" : "AniDB posters"); foreach (var anime in animeList) { _logger.LogTrace(CorruptImageFound, anime.PosterPath); - await RemoveImageAndQueueRedownload<DownloadAniDBImageJob>(ImageEntityType.AniDB_Cover, anime.AnimeID, anime.AnimeID); + await RemoveImageAndQueueDownload<DownloadAniDBImageJob>(ImageEntityType.Poster, anime.AnimeID, anime.MainTitle); if (++count % 10 != 0) continue; _logger.LogInformation(ReQueueingForDownload, count, animeList.Count); UpdateProgress($" - AniDB Posters - {count}/{animeList.Count}"); @@ -174,14 +94,14 @@ public override async Task Process() UpdateProgress(" - AniDB Characters"); _logger.LogInformation(ScanForType, "AniDB characters"); var characters = RepoFactory.AniDB_Character.GetAll() - .Where(character => !Misc.IsImageValid(character.GetPosterPath())) + .Where(character => !Misc.IsImageValid(character.GetFullImagePath())) .ToList(); _logger.LogInformation(FoundCorruptedOfType, characters.Count, characters.Count == 1 ? "AniDB Character" : "AniDB Characters"); foreach (var character in characters) { - _logger.LogTrace(CorruptImageFound, character.GetPosterPath()); - await RemoveImageAndQueueRedownload<DownloadAniDBImageJob>(ImageEntityType.AniDB_Character, character.CharID); + _logger.LogTrace(CorruptImageFound, character.GetFullImagePath()); + await RemoveImageAndQueueDownload<DownloadAniDBImageJob>(ImageEntityType.Character, character.CharID); if (++count % 10 != 0) continue; _logger.LogInformation(ReQueueingForDownload, count, characters.Count); UpdateProgress($" - AniDB Characters - {count}/{characters.Count}"); @@ -192,16 +112,16 @@ public override async Task Process() { count = 0; UpdateProgress(" - AniDB Creators"); - _logger.LogInformation(ScanForType, "AniDB Seiyuu"); - var staff = RepoFactory.AniDB_Seiyuu.GetAll() - .Where(va => !Misc.IsImageValid(va.GetPosterPath())) + _logger.LogInformation(ScanForType, "AniDB Creator"); + var staff = RepoFactory.AniDB_Creator.GetAll() + .Where(va => !Misc.IsImageValid(va.GetFullImagePath())) .ToList(); - _logger.LogInformation(FoundCorruptedOfType, staff.Count, "AniDB Seiyuu"); + _logger.LogInformation(FoundCorruptedOfType, staff.Count, "AniDB Creator"); foreach (var seiyuu in staff) { - _logger.LogTrace(CorruptImageFound, seiyuu.GetPosterPath()); - await RemoveImageAndQueueRedownload<DownloadAniDBImageJob>(ImageEntityType.AniDB_Creator, seiyuu.SeiyuuID); + _logger.LogTrace(CorruptImageFound, seiyuu.GetFullImagePath()); + await RemoveImageAndQueueDownload<DownloadAniDBImageJob>(ImageEntityType.Person, seiyuu.CreatorID); if (++count % 10 != 0) continue; _logger.LogInformation(ReQueueingForDownload, count, staff.Count); @@ -210,13 +130,13 @@ public override async Task Process() } } - private async Task RemoveImageAndQueueRedownload<T>(ImageEntityType entityTypeEnum, int entityID, int animeID = 0) where T : class, IImageDownloadJob + private async Task RemoveImageAndQueueDownload<T>(ImageEntityType entityTypeEnum, int entityID, string? parentName = null) where T : class, IImageDownloadJob { var scheduler = await _schedulerFactory.GetScheduler(); await scheduler.StartJob<T>( c => { - c.Anime = RepoFactory.AniDB_Anime.GetByAnimeID(animeID)?.PreferredTitle; + c.ParentName = parentName; c.ImageID = entityID; c.ImageType = entityTypeEnum; c.ForceDownload = true; diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTMDBImageJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTMDBImageJob.cs deleted file mode 100644 index e89c841fd..000000000 --- a/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTMDBImageJob.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Polly; -using Shoko.Commons.Utils; -using Shoko.Models.Enums; -using Shoko.Server.Extensions; -using Shoko.Server.ImageDownload; -using Shoko.Server.Providers; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling.Acquisition.Attributes; -using Shoko.Server.Scheduling.Attributes; -using Shoko.Server.Scheduling.Concurrency; -using Shoko.Server.Server; - -namespace Shoko.Server.Scheduling.Jobs.TMDB; - -[DatabaseRequired] -[NetworkRequired] -[LimitConcurrency(8, 16)] -[JobKeyGroup(JobKeyGroup.TMDB)] -public class DownloadTMDBImageJob : BaseJob, IImageDownloadJob -{ - private const string FailedToDownloadNoID = "Image failed to download: Can\'t find valid {ImageType} with ID: {ImageID}"; - private const string FailedToDownloadNoImpl = "Image failed to download: No implementation found for {ImageType}"; - - private readonly ImageHttpClientFactory _clientFactory; - public string Anime { get; set; } - public int ImageID { get; set; } - public bool ForceDownload { get; set; } - - public ImageEntityType ImageType { get; set; } - - public override string TypeName => "Download TMDB Image"; - public override string Title => "Downloading TMDB Image"; - public override Dictionary<string, object> Details => Anime == null ? new() - { - { "Type", ImageType.ToString().Replace("_", " ") }, - { "ImageID", ImageID } - } : new() - { - { "Anime", Anime }, - { "Type", ImageType.ToString().Replace("_", " ") }, - { "ImageID", ImageID } - }; - - public override async Task Process() - { - _logger.LogInformation("Processing {Job} for {Anime} -> Image Type: {ImageType} | ImageID: {EntityID}", nameof(DownloadTMDBImageJob), Anime, ImageType, ImageID); - - var imageType = ImageType.ToString().Replace("_", " "); - string downloadUrl = null; - string filePath = null; - switch (ImageType) - { - case ImageEntityType.MovieDB_Poster: - var moviePoster = RepoFactory.MovieDB_Poster.GetByID(ImageID); - if (string.IsNullOrEmpty(moviePoster?.URL)) - { - _logger.LogWarning(FailedToDownloadNoID, imageType, ImageID); - RemoveImageRecord(); - return; - } - - downloadUrl = string.Format(Constants.URLS.MovieDB_Images, moviePoster.URL); - filePath = moviePoster.GetFullImagePath(); - break; - - case ImageEntityType.MovieDB_FanArt: - var movieFanart = RepoFactory.MovieDB_Fanart.GetByID(ImageID); - if (string.IsNullOrEmpty(movieFanart?.URL)) - { - _logger.LogWarning(FailedToDownloadNoID, imageType, ImageID); - RemoveImageRecord(); - return; - } - - downloadUrl = string.Format(Constants.URLS.MovieDB_Images, movieFanart.URL); - filePath = movieFanart.GetFullImagePath(); - break; - } - - if (downloadUrl == null || filePath == null) - { - _logger.LogWarning(FailedToDownloadNoImpl, imageType); - return; - } - - try - { - // If this has any issues, it will throw an exception, so the catch below will handle it. - var result = await DownloadNow(downloadUrl, filePath); - switch (result) - { - case ImageDownloadResult.Success: - _logger.LogInformation("Image downloaded for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.Cached: - _logger.LogDebug("Image already in cache for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.Failure: - _logger.LogWarning("Image failed to download for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.RemovedResource: - _logger.LogWarning("Image failed to download for {Anime} and the local entry has been removed: {FilePath} from {DownloadUrl}", Anime, - filePath, downloadUrl); - break; - case ImageDownloadResult.InvalidResource: - _logger.LogWarning("Image failed to download for {Anime} and the local entry could not be removed: {FilePath} from {DownloadUrl}", - Anime, filePath, downloadUrl); - break; - } - } - catch (WebException e) - { - _logger.LogWarning("Error processing {Job} for {Anime}: {Url} ({EntityID}) - {Message}", nameof(DownloadTMDBImageJob), Anime, downloadUrl, - ImageID, e.Message); - } - } - - private async Task<ImageDownloadResult> DownloadNow(string downloadUrl, string filePath) - { - var retryPolicy = Policy - .Handle<HttpRequestException>() - .Or<WebException>() - .OrResult(ImageDownloadResult.InvalidResource) - .OrResult(ImageDownloadResult.RemovedResource) - .WaitAndRetryAsync(new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5) }, (result, _) => - { - switch (result.Exception) - { - // if the server is just having issues, we can try again later - case HttpRequestException httpRequestException when IsRetryableError(httpRequestException.StatusCode): - case WebException: - return; - default: - // else it's a situation where the image will never work - RemoveImageRecord(); - break; - } - }); - - return await retryPolicy.ExecuteAsync(async () => - { - // Abort if the download URL or final destination is not available. - if (string.IsNullOrEmpty(downloadUrl) || string.IsNullOrEmpty(filePath)) - return ImageDownloadResult.Failure; - - var imageValid = File.Exists(filePath) && Misc.IsImageValid(filePath); - if (imageValid && !ForceDownload) - return ImageDownloadResult.Cached; - - var tempPath = Path.Combine(ImageUtils.GetImagesTempFolder(), Path.GetFileName(filePath)); - - try - { - // Download the image using custom HttpClient factory. - var client = _clientFactory.CreateClient("TMDBClient"); - var bytes = await client.GetByteArrayAsync(downloadUrl); - - // Validate the downloaded image. - if (bytes.Length < 4) - throw new WebException("The image download stream returned less than 4 bytes (a valid image has 2-4 bytes in the header)"); - - if (Misc.GetImageFormat(bytes) == null) - throw new WebException("The image download stream returned an invalid image"); - - // Write the image data to the temp file. - if (File.Exists(tempPath)) - File.Delete(tempPath); - - await using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write)) - fs.Write(bytes, 0, bytes.Length); - - // Ensure directory structure exists. - var dirPath = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dirPath) && !Directory.Exists(dirPath)) - Directory.CreateDirectory(dirPath); - - // Delete existing file if re-downloading. - if (File.Exists(filePath)) - File.Delete(filePath); - - // Move the temp file to its final destination. - File.Move(tempPath, filePath); - - return ImageDownloadResult.Success; - } - catch (Exception ex) - { - throw new InvalidOperationException("Image download failed.", ex); - } - }); - } - - private static bool IsRetryableError(HttpStatusCode? statusCode) - { - return statusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.BadGateway or HttpStatusCode.GatewayTimeout - or HttpStatusCode.InternalServerError; - } - - private void RemoveImageRecord() - { - switch (ImageType) - { - case ImageEntityType.MovieDB_FanArt: - { - var fanart = RepoFactory.MovieDB_Fanart.GetByID(ImageID); - if (fanart == null) - return; - RepoFactory.MovieDB_Fanart.Delete(fanart); - return; - } - case ImageEntityType.MovieDB_Poster: - { - var poster = RepoFactory.MovieDB_Poster.GetByID(ImageID); - if (poster == null) - return; - RepoFactory.MovieDB_Poster.Delete(poster); - return; - } - } - } - - public DownloadTMDBImageJob(ImageHttpClientFactory clientFactory) - { - _clientFactory = clientFactory; - } - - protected DownloadTMDBImageJob() { } -} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbImageJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbImageJob.cs new file mode 100644 index 000000000..63731ea31 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbImageJob.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; + +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(12, 24)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public class DownloadTmdbImageJob : DownloadImageBaseJob +{ + public override DataSourceEnum Source => DataSourceEnum.TMDB; + + public override Dictionary<string, object> Details => new() + { + { "Type", ImageType.ToString().Replace("_", " ") }, + { "ImageID", ImageID } + }; + + public DownloadTmdbImageJob() : base() { } + + protected override bool RemoveRecord() + { + if (RepoFactory.TMDB_Image.GetByID(ImageID) is { } image) + RepoFactory.TMDB_Image.Delete(image); + return true; + } +} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbMovieImagesJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbMovieImagesJob.cs new file mode 100644 index 000000000..79d71c971 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbMovieImagesJob.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(1, 16)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public class DownloadTmdbMovieImagesJob : BaseJob +{ + private readonly TmdbMetadataService _tmdbService; + + public virtual int TmdbMovieID { get; set; } + + public virtual bool ForceDownload { get; set; } = true; + + public virtual string? MovieTitle { get; set; } + + public override void PostInit() + { + MovieTitle ??= RepoFactory.TMDB_Movie.GetByTmdbMovieID(TmdbMovieID)?.EnglishTitle; + } + + public override string TypeName => "Download Images for TMDB Movie"; + + public override string Title => "Downloading Images for TMDB Movie"; + + public override Dictionary<string, object> Details => string.IsNullOrEmpty(MovieTitle) + ? new() + { + {"MovieID", TmdbMovieID}, + } + : new() + { + {"Movie", MovieTitle}, + {"MovieID", TmdbMovieID}, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing DownloadTmdbMovieImagesJob: {TmdbMovieId}", TmdbMovieID); + await Task.Run(() => _tmdbService.DownloadAllMovieImages(TmdbMovieID, ForceDownload)).ConfigureAwait(false); + } + + public DownloadTmdbMovieImagesJob(TmdbMetadataService tmdbService) + { + _tmdbService = tmdbService; + } + + protected DownloadTmdbMovieImagesJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbShowImagesJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbShowImagesJob.cs new file mode 100644 index 000000000..30f98255c --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/DownloadTmdbShowImagesJob.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(1, 16)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public class DownloadTmdbShowImagesJob : BaseJob +{ + private readonly TmdbMetadataService _tmdbService; + + public virtual int TmdbShowID { get; set; } + + public virtual bool ForceDownload { get; set; } = true; + + public virtual string? ShowTitle { get; set; } + + public override void PostInit() + { + ShowTitle ??= RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID)?.EnglishTitle; + } + + public override string TypeName => "Download Images for TMDB Show"; + + public override string Title => "Downloading Images for TMDB Show"; + + public override Dictionary<string, object> Details => string.IsNullOrEmpty(ShowTitle) + ? new() + { + {"ShowID", TmdbShowID}, + } + : new() + { + {"Show", ShowTitle}, + {"ShowID", TmdbShowID}, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing DownloadTmdbShowImagesJob: {TmdbShowId}", TmdbShowID); + await Task.Run(() => _tmdbService.DownloadAllShowImages(TmdbShowID, ForceDownload)).ConfigureAwait(false); + } + + public DownloadTmdbShowImagesJob(TmdbMetadataService tmdbService) + { + _tmdbService = tmdbService; + } + + protected DownloadTmdbShowImagesJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/PurgeTmdbMovieJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/PurgeTmdbMovieJob.cs new file mode 100644 index 000000000..9c782f46b --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/PurgeTmdbMovieJob.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(1, 12)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public class PurgeTmdbMovieJob : BaseJob +{ + private readonly TmdbMetadataService _tmdbService; + + public virtual int TmdbMovieID { get; set; } + + public virtual bool RemoveImageFiles { get; set; } = true; + + public virtual string? MovieTitle { get; set; } + + public override void PostInit() + { + MovieTitle ??= RepoFactory.TMDB_Movie.GetByTmdbMovieID(TmdbMovieID)?.EnglishTitle; + } + + public override string TypeName => "Purge TMDB Movie"; + + public override string Title => "Purging TMDB Movie"; + + public override Dictionary<string, object> Details => string.IsNullOrEmpty(MovieTitle) + ? new() + { + {"MovieID", TmdbMovieID}, + } + : new() + { + {"Movie", MovieTitle}, + {"MovieID", TmdbMovieID}, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing PurgeTmdbMovieJob: {TmdbMovieId}", TmdbMovieID); + await _tmdbService.PurgeMovie(TmdbMovieID, RemoveImageFiles).ConfigureAwait(false); + } + + public PurgeTmdbMovieJob(TmdbMetadataService tmdbService) + { + _tmdbService = tmdbService; + } + + protected PurgeTmdbMovieJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/PurgeTmdbShowJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/PurgeTmdbShowJob.cs new file mode 100644 index 000000000..432ebd202 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/PurgeTmdbShowJob.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(1, 12)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public class PurgeTmdbShowJob : BaseJob +{ + private readonly TmdbMetadataService _tmdbService; + + public virtual int TmdbShowID { get; set; } + + public virtual bool RemoveImageFiles { get; set; } = true; + + public virtual string? ShowTitle { get; set; } + + public override void PostInit() + { + ShowTitle ??= RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID)?.EnglishTitle; + } + + public override string TypeName => "Purge TMDB Show"; + + public override string Title => "Purging TMDB Show"; + + public override Dictionary<string, object> Details => string.IsNullOrEmpty(ShowTitle) + ? new() + { + {"ShowID", TmdbShowID}, + } + : new() + { + {"Show", ShowTitle}, + {"ShowID", TmdbShowID}, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing PurgeTmdbShowJob: {TmdbShowId}", TmdbShowID); + await _tmdbService.PurgeShow(TmdbShowID, RemoveImageFiles).ConfigureAwait(false); + } + + public PurgeTmdbShowJob(TmdbMetadataService tmdbService) + { + _tmdbService = tmdbService; + } + + protected PurgeTmdbShowJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/SearchTMDBSeriesJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/SearchTMDBSeriesJob.cs deleted file mode 100644 index 4f140cc7f..000000000 --- a/Shoko.Server/Scheduling/Jobs/TMDB/SearchTMDBSeriesJob.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Shoko.Plugin.Abstractions.DataModels; -using Shoko.Server.Providers.MovieDB; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling.Acquisition.Attributes; -using Shoko.Server.Scheduling.Attributes; -using Shoko.Server.Scheduling.Concurrency; -using Shoko.Server.Settings; - -namespace Shoko.Server.Scheduling.Jobs.TMDB; - -[DatabaseRequired] -[NetworkRequired] -[DisallowConcurrencyGroup(ConcurrencyGroups.TMDB)] -[JobKeyGroup(JobKeyGroup.TMDB)] -public class SearchTMDBSeriesJob : BaseJob -{ - private readonly MovieDBHelper _helper; - private readonly ISettingsProvider _settingsProvider; - private string _animeTitle; - public int AnimeID { get; set; } - - public override void PostInit() - { - _animeTitle = RepoFactory.AniDB_Anime?.GetByAnimeID(AnimeID)?.PreferredTitle ?? AnimeID.ToString(); - } - - public override string TypeName => "Search for TMDB Series"; - public override string Title => "Searching for TMDB Series"; - public override Dictionary<string, object> Details => new() { { "Anime", _animeTitle } }; - - public override async Task Process() - { - _logger.LogInformation("Processing {Job} -> Anime: {Anime}", nameof(SearchTMDBSeriesJob), _animeTitle); - - // Use TvDB setting - var settings = _settingsProvider.GetSettings(); - if (!settings.TvDB.AutoLink) - { - return; - } - - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); - if (anime == null) - { - return; - } - - var searchCriteria = anime.PreferredTitle; - - // if not wanting to use web cache, or no match found on the web cache go to TvDB directly - var results = await _helper.Search(searchCriteria); - _logger.LogTrace("Found {Count} moviedb results for {Criteria} on MovieDB", results.Count, searchCriteria); - if (await ProcessSearchResults(results, searchCriteria)) - { - return; - } - - - if (results.Count != 0) - { - return; - } - - foreach (var title in anime.Titles) - { - if (title.TitleType != TitleType.Official) - { - continue; - } - - if (string.Equals(searchCriteria, title.Title, StringComparison.CurrentCultureIgnoreCase)) - { - continue; - } - - results = await _helper.Search(title.Title); - _logger.LogTrace("Found {Count} moviedb results for search on {Title}", results.Count, title.Title); - if (await ProcessSearchResults(results, title.Title)) - { - return; - } - } - } - - private async Task<bool> ProcessSearchResults(List<MovieDB_Movie_Result> results, string searchCriteria) - { - if (results.Count == 1) - { - // since we are using this result, lets download the info - _logger.LogTrace("Found 1 moviedb results for search on {SearchCriteria} --- Linked to {Name} ({ID})", - searchCriteria, - results[0].MovieName, results[0].MovieID); - - var movieID = results[0].MovieID; - await _helper.UpdateMovieInfo(movieID, true); - await _helper.LinkAniDBMovieDB(AnimeID, movieID, false); - return true; - } - - return false; - } - - public SearchTMDBSeriesJob(MovieDBHelper helper, ISettingsProvider settingsProvider) - { - _helper = helper; - _settingsProvider = settingsProvider; - } - - protected SearchTMDBSeriesJob() { } -} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/SearchTmdbJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/SearchTmdbJob.cs new file mode 100644 index 000000000..6e5646582 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/SearchTmdbJob.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.Models; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; + +#nullable enable +#pragma warning disable CS8618 +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(8, 24)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public partial class SearchTmdbJob : BaseJob +{ + private readonly TmdbLinkingService _tmdbLinkingService; + + private readonly TmdbMetadataService _tmdbMetadataService; + + private readonly TmdbSearchService _tmdbSearchService; + + private string _animeTitle; + + public int AnimeID { get; set; } + + public virtual bool ForceRefresh { get; set; } + + public override void PostInit() + { + _animeTitle = RepoFactory.AniDB_Anime?.GetByAnimeID(AnimeID)?.PreferredTitle ?? AnimeID.ToString(); + } + + public override string TypeName => "Search for TMDB Match"; + public override string Title => "Searching for TMDB Match"; + public override Dictionary<string, object> Details => new() { { "Anime", _animeTitle } }; + + public override async Task Process() + { + _logger.LogInformation("Processing SearchTmdbJob for {Anime}: AniDB ID {ID}", _animeTitle ?? AnimeID.ToString(), AnimeID); + var anime = RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); + if (anime == null) + { + _logger.LogWarning("Anime not found locally: {AnimeID}", AnimeID); + return; + } + + if (anime.TmdbShowCrossReferences is { Count: > 0 } || anime.TmdbMovieCrossReferences is { Count: > 0 }) + { + _logger.LogInformation("Anime already has TMDB links: {AnimeID}", AnimeID); + return; + } + + var results = await _tmdbSearchService.SearchForAutoMatch(anime); + foreach (var result in results) + { + if (result.IsMovie) + { + _logger.LogInformation("Linking anime {AnimeName} ({AnimeID}), episode {EpisodeName} ({EpisodeID}) to movie {MovieName} ({MovieID})", result.AnidbAnime.PreferredTitle, result.AnidbAnime.AnimeID, result.AnidbEpisode.PreferredTitle, result.AnidbEpisode.EpisodeID, result.TmdbMovie.OriginalTitle, result.TmdbMovie.Id); + await _tmdbLinkingService.AddMovieLinkForEpisode(result.AnidbEpisode.EpisodeID, result.TmdbMovie.Id, additiveLink: true, isAutomatic: true).ConfigureAwait(false); + await _tmdbMetadataService.ScheduleUpdateOfMovie(result.TmdbMovie.Id, forceRefresh: ForceRefresh, downloadImages: true).ConfigureAwait(false); + } + else + { + _logger.LogInformation("Linking anime {AnimeName} ({AnimeID}) to show {ShowName} ({ShowID})", result.AnidbAnime.PreferredTitle, result.AnidbAnime.AnimeID, result.TmdbShow.OriginalName, result.TmdbShow.Id); + await _tmdbLinkingService.AddShowLink(result.AnidbAnime.AnimeID, result.TmdbShow.Id, additiveLink: true, isAutomatic: true).ConfigureAwait(false); + await _tmdbMetadataService.ScheduleUpdateOfShow(result.TmdbShow.Id, forceRefresh: ForceRefresh, downloadImages: true).ConfigureAwait(false); + } + } + } + + public SearchTmdbJob( + TmdbLinkingService tmdbLinkingService, + TmdbMetadataService metadataService, + TmdbSearchService searchService + ) + { + _tmdbLinkingService = tmdbLinkingService; + _tmdbMetadataService = metadataService; + _tmdbSearchService = searchService; + } + + protected SearchTmdbJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/UpdateTmdbMovieJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/UpdateTmdbMovieJob.cs new file mode 100644 index 000000000..f17377610 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/UpdateTmdbMovieJob.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; +using Shoko.Server.Settings; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(1, 12)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public class UpdateTmdbMovieJob : BaseJob +{ + private readonly TmdbMetadataService _tmdbService; + + private readonly ISettingsProvider _settingsProvider; + + public virtual int TmdbMovieID { get; set; } + + public virtual bool DownloadImages { get; set; } + + public virtual bool? DownloadCrewAndCast { get; set; } + + public virtual bool? DownloadCollections { get; set; } + + public virtual bool ForceRefresh { get; set; } + + public virtual string? MovieTitle { get; set; } + + public override void PostInit() + { + MovieTitle ??= RepoFactory.TMDB_Movie.GetByTmdbMovieID(TmdbMovieID)?.EnglishTitle; + } + + public override string TypeName => string.IsNullOrEmpty(MovieTitle) + ? "Download TMDB Movie" + : "Update TMDB Movie"; + + public override string Title => string.IsNullOrEmpty(MovieTitle) + ? "Downloading TMDB Movie" + : "Updating TMDB Movie"; + + public override Dictionary<string, object> Details => string.IsNullOrEmpty(MovieTitle) + ? new() + { + {"MovieID", TmdbMovieID}, + } + : new() + { + {"Movie", MovieTitle}, + {"MovieID", TmdbMovieID}, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing UpdateTmdbMovieJob: {TmdbMovieId}", TmdbMovieID); + var settings = _settingsProvider.GetSettings(); + await _tmdbService.UpdateMovie(TmdbMovieID, ForceRefresh, DownloadImages, DownloadCrewAndCast ?? settings.TMDB.AutoDownloadCrewAndCast, DownloadCollections ?? settings.TMDB.AutoDownloadCollections).ConfigureAwait(false); + } + + public UpdateTmdbMovieJob(TmdbMetadataService tmdbService, ISettingsProvider settingsProvider) + { + _tmdbService = tmdbService; + _settingsProvider = settingsProvider; + } + + protected UpdateTmdbMovieJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/UpdateTmdbShowJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/UpdateTmdbShowJob.cs new file mode 100644 index 000000000..0308aee73 --- /dev/null +++ b/Shoko.Server/Scheduling/Jobs/TMDB/UpdateTmdbShowJob.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shoko.Server.Providers.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling.Acquisition.Attributes; +using Shoko.Server.Scheduling.Attributes; +using Shoko.Server.Scheduling.Concurrency; +using Shoko.Server.Settings; + +#pragma warning disable CS8618 +#nullable enable +namespace Shoko.Server.Scheduling.Jobs.TMDB; + +[DatabaseRequired] +[NetworkRequired] +[LimitConcurrency(1, 12)] +[JobKeyGroup(JobKeyGroup.TMDB)] +public class UpdateTmdbShowJob : BaseJob +{ + private readonly TmdbMetadataService _tmdbService; + + private readonly ISettingsProvider _settingsProvider; + + public virtual int TmdbShowID { get; set; } + + public virtual bool DownloadImages { get; set; } + + public virtual bool? DownloadCrewAndCast { get; set; } + + public virtual bool? DownloadAlternateOrdering { get; set; } + + public virtual bool ForceRefresh { get; set; } + + public virtual string? ShowTitle { get; set; } + + public override void PostInit() + { + ShowTitle ??= RepoFactory.TMDB_Show.GetByTmdbShowID(TmdbShowID)?.EnglishTitle; + } + + public override string TypeName => string.IsNullOrEmpty(ShowTitle) + ? "Download TMDB Show" + : "Update TMDB Show"; + + public override string Title => string.IsNullOrEmpty(ShowTitle) + ? "Downloading TMDB Show" + : "Updating TMDB Show"; + + public override Dictionary<string, object> Details => string.IsNullOrEmpty(ShowTitle) + ? new() + { + {"ShowID", TmdbShowID}, + } + : new() + { + {"Show", ShowTitle}, + {"ShowID", TmdbShowID}, + }; + + public override async Task Process() + { + _logger.LogInformation("Processing UpdateTmdbShowJob: {TmdbShowId}", TmdbShowID); + var settings = _settingsProvider.GetSettings(); + await _tmdbService.UpdateShow(TmdbShowID, ForceRefresh, DownloadImages, DownloadCrewAndCast ?? settings.TMDB.AutoDownloadCrewAndCast, DownloadAlternateOrdering ?? settings.TMDB.AutoDownloadAlternateOrdering).ConfigureAwait(false); + } + + public UpdateTmdbShowJob(TmdbMetadataService tmdbService, ISettingsProvider settingsProvider) + { + _tmdbService = tmdbService; + _settingsProvider = settingsProvider; + } + + protected UpdateTmdbShowJob() { } +} diff --git a/Shoko.Server/Scheduling/Jobs/Trakt/SearchTraktSeriesJob.cs b/Shoko.Server/Scheduling/Jobs/Trakt/SearchTraktSeriesJob.cs index 5f3937741..57fe2d7d1 100644 --- a/Shoko.Server/Scheduling/Jobs/Trakt/SearchTraktSeriesJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Trakt/SearchTraktSeriesJob.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NHibernate; @@ -48,51 +49,46 @@ public override Task Process() var sessionWrapper = session.Wrap(); var doReturn = false; - // let's try to see locally if we have a tvDB link for this anime - // Trakt allows the use of TvDB ID's or their own Trakt ID's - var xrefTvDBs = RepoFactory.CrossRef_AniDB_TvDB.GetV2LinksFromAnime(AnimeID); - if (xrefTvDBs is { Count: > 0 }) + // let's try to see locally if we have a tmdb link for this anime + // Trakt allows the use of tmdb ID's or their own Trakt ID's + if (RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(AnimeID) is { Count: > 0 } tmdbXrefs) { - foreach (var tvXRef in xrefTvDBs) + foreach (var tmdbXref in tmdbXrefs) { - // first search for this show by the TvDB ID - var searchResults = - _helper.SearchShowByIDV2(TraktSearchIDType.tvdb, - tvXRef.TvDBID.ToString()); - if (searchResults == null || searchResults.Count <= 0) continue; - - // since we are searching by ID, there will only be one 'show' result - TraktV2Show resShow = null; - foreach (var res in searchResults) - { - if (res.ResultType != SearchIDType.Show) continue; - - resShow = res.show; - break; - } - + // first search for this show by the tmdb ID + var searchResults = _helper.SearchShowByTmdbId(tmdbXref.TmdbShowID); + var resShow = searchResults.FirstOrDefault()?.Show; if (resShow == null) continue; - var showInfo = _helper.GetShowInfoV2(resShow.ids.slug); - if (showInfo?.ids == null) continue; + var showInfo = _helper.GetShowInfoV2(resShow.IDs.TraktSlug); + if (showInfo?.IDs == null) continue; - // make sure the season specified by TvDB also exists on Trakt - var traktShow = - RepoFactory.Trakt_Show.GetByTraktSlug(session, showInfo.ids.slug); + // make sure the season specified by tmdb also exists on Trakt + var traktShow = RepoFactory.Trakt_Show.GetByTraktSlug(session, showInfo.IDs.TraktSlug); if (traktShow == null) continue; + var episodeXrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetAllByAnidbAnimeAndTmdbShowIDs(AnimeID, tmdbXref.TmdbShowID) + .Select(xref => new { xref, tmdb = xref.TmdbEpisode, anidb = xref.AnidbEpisode }) + .Where(xref => xref.tmdb != null && xref.anidb != null) + .ToList(); + var seasonNumber = episodeXrefs.GroupBy(x => x.tmdb.SeasonNumber).MaxBy(x => x.Count())?.Key ?? -1; + if (seasonNumber == -1) continue; var traktSeason = RepoFactory.Trakt_Season.GetByShowIDAndSeason( session, traktShow.Trakt_ShowID, - tvXRef.TvDBSeasonNumber); + seasonNumber); if (traktSeason == null) continue; - _logger.LogTrace("Found trakt match using TvDBID locally {AnimeID} - id = {Title}", - AnimeID, showInfo.title); + var firstEpisode = episodeXrefs.Where(x => x.tmdb.SeasonNumber == seasonNumber).MinBy(x => x.tmdb.EpisodeNumber); + if (firstEpisode == null) continue; + + _logger.LogTrace("Found trakt match using local TMDB Show: {Trakt Title} (AnidbAnime={AnidbAnimeID},TmdbShow={TmdbShowID})", showInfo.Title, AnimeID, tmdbXref.TmdbShowID); _helper.LinkAniDBTrakt(AnimeID, - (EpisodeType)tvXRef.AniDBStartEpisodeType, - tvXRef.AniDBStartEpisodeNumber, showInfo.ids.slug, - tvXRef.TvDBSeasonNumber, tvXRef.TvDBStartEpisodeNumber, + firstEpisode.anidb.EpisodeTypeEnum, + firstEpisode.anidb.EpisodeNumber, + showInfo.IDs.TraktSlug, + firstEpisode.tmdb.SeasonNumber, + firstEpisode.tmdb.EpisodeNumber, true); doReturn = true; } @@ -100,16 +96,13 @@ public override Task Process() if (doReturn) return Task.CompletedTask; } - // Use TvDB setting due to similarity - if (!settings.TvDB.AutoLink) return Task.CompletedTask; - // finally lets try searching Trakt directly var anime = RepoFactory.AniDB_Anime.GetByAnimeID(sessionWrapper, AnimeID); if (anime == null) return Task.CompletedTask; var searchCriteria = anime.MainTitle; - // if not wanting to use web cache, or no match found on the web cache go to TvDB directly + // if not wanting to use web cache, or no match found on the web cache go to tmdb directly var results = _helper.SearchShowV2(searchCriteria); _logger.LogTrace("Found {Count} trakt results for {Criteria} ", results.Count, searchCriteria); if (ProcessSearchResults(session, results, searchCriteria)) return Task.CompletedTask; @@ -126,7 +119,7 @@ public override Task Process() _logger.LogTrace("Found {Count} trakt results for search on {Title}", results.Count, title.Title); if (ProcessSearchResults(session, results, title.Title)) return Task.CompletedTask; } - + return Task.CompletedTask; } @@ -135,16 +128,16 @@ private bool ProcessSearchResults(ISession session, List<TraktV2SearchShowResult { if (results.Count == 1) { - if (results[0].show != null) + if (results[0].Show != null) { // since we are using this result, lets download the info _logger.LogTrace("Found 1 trakt results for search on {Query} --- Linked to {Title} ({ID})", searchCriteria, - results[0].show.Title, results[0].show.ids.slug); - var showInfo = _helper.GetShowInfoV2(results[0].show.ids.slug); + results[0].Show.Title, results[0].Show.IDs.TraktSlug); + var showInfo = _helper.GetShowInfoV2(results[0].Show.IDs.TraktSlug); if (showInfo != null) { _helper.LinkAniDBTrakt(session, AnimeID, EpisodeType.Episode, 1, - results[0].show.ids.slug, 1, 1, + results[0].Show.IDs.TraktSlug, 1, 1, true); return true; } diff --git a/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktCollectionSeriesJob.cs b/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktCollectionSeriesJob.cs index 4bd7a55e8..b422d09d2 100644 --- a/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktCollectionSeriesJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktCollectionSeriesJob.cs @@ -26,7 +26,7 @@ public class SyncTraktCollectionSeriesJob : BaseJob public override void PostInit() { - _seriesName = RepoFactory.AnimeSeries?.GetByID(AnimeSeriesID)?.SeriesName ?? AnimeSeriesID.ToString(); + _seriesName = RepoFactory.AnimeSeries?.GetByID(AnimeSeriesID)?.PreferredTitle ?? AnimeSeriesID.ToString(); } public override Dictionary<string, object> Details => new() { { "Anime", _seriesName } }; diff --git a/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktEpisodeHistoryJob.cs b/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktEpisodeHistoryJob.cs index d9abdf3b4..4abf06dc4 100644 --- a/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktEpisodeHistoryJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Trakt/SyncTraktEpisodeHistoryJob.cs @@ -38,7 +38,7 @@ public override void PostInit() { "EpisodeID", AnimeEpisodeID } } : new() { - { "Anime", RepoFactory.AniDB_Anime.GetByAnimeID(_episode.AnimeID) }, + { "Anime", RepoFactory.AniDB_Anime.GetByAnimeID(_episode.AnimeID)?.PreferredTitle }, { "Episode Type", ((EpisodeType)_episode.EpisodeType).ToString() }, { "Episode Number", _episode.EpisodeNumber }, { "Sync Action", Action.ToString() } diff --git a/Shoko.Server/Scheduling/Jobs/TvDB/DownloadTvDBImageJob.cs b/Shoko.Server/Scheduling/Jobs/TvDB/DownloadTvDBImageJob.cs deleted file mode 100644 index 2c109d9fd..000000000 --- a/Shoko.Server/Scheduling/Jobs/TvDB/DownloadTvDBImageJob.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Polly; -using Shoko.Commons.Utils; -using Shoko.Models.Enums; -using Shoko.Server.Extensions; -using Shoko.Server.ImageDownload; -using Shoko.Server.Providers; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling.Acquisition.Attributes; -using Shoko.Server.Scheduling.Attributes; -using Shoko.Server.Scheduling.Concurrency; -using Shoko.Server.Server; - -namespace Shoko.Server.Scheduling.Jobs.TvDB; - -[DatabaseRequired] -[NetworkRequired] -[LimitConcurrency(8, 16)] -[JobKeyGroup(JobKeyGroup.TvDB)] -public class DownloadTvDBImageJob : BaseJob, IImageDownloadJob -{ - private const string FailedToDownloadNoID = "Image failed to download: Can\'t find valid {ImageType} with ID: {ImageID}"; - private const string FailedToDownloadNoImpl = "Image failed to download: No implementation found for {ImageType}"; - - private readonly ImageHttpClientFactory _clientFactory; - public string Anime { get; set; } - public int ImageID { get; set; } - public bool ForceDownload { get; set; } - - public ImageEntityType ImageType { get; set; } - - public override string TypeName => "Download TvDB Image"; - public override string Title => "Downloading TvDB Image"; - public override Dictionary<string, object> Details => Anime == null ? new() - { - { "Type", ImageType.ToString().Replace("_", " ") }, - { "ImageID", ImageID } - } : new() - { - { "Anime", Anime }, - { "Type", ImageType.ToString().Replace("_", " ") }, - { "ImageID", ImageID } - }; - - public override async Task Process() - { - _logger.LogInformation("Processing {Job} for {Anime} -> Image Type: {ImageType} | ImageID: {EntityID}", nameof(DownloadTvDBImageJob), Anime, ImageType, ImageID); - - var imageType = ImageType.ToString().Replace("_", " "); - string downloadUrl = null; - string filePath = null; - switch (ImageType) - { - case ImageEntityType.TvDB_Episode: - var ep = RepoFactory.TvDB_Episode.GetByID(ImageID); - if (string.IsNullOrEmpty(ep?.Filename)) - { - _logger.LogWarning(FailedToDownloadNoID, imageType, ImageID); - return; - } - - downloadUrl = string.Format(Constants.URLS.TvDB_Episode_Images, ep.Filename); - filePath = ep.GetFullImagePath(); - break; - - case ImageEntityType.TvDB_FanArt: - var fanart = RepoFactory.TvDB_ImageFanart.GetByID(ImageID); - if (string.IsNullOrEmpty(fanart?.BannerPath)) - { - _logger.LogWarning(FailedToDownloadNoID, imageType, ImageID); - RemoveImageRecord(); - return; - } - - downloadUrl = string.Format(Constants.URLS.TvDB_Images, fanart.BannerPath); - filePath = fanart.GetFullImagePath(); - break; - - case ImageEntityType.TvDB_Cover: - var poster = RepoFactory.TvDB_ImagePoster.GetByID(ImageID); - if (string.IsNullOrEmpty(poster?.BannerPath)) - { - _logger.LogWarning(FailedToDownloadNoID, imageType, ImageID); - RemoveImageRecord(); - return; - } - - downloadUrl = string.Format(Constants.URLS.TvDB_Images, poster.BannerPath); - filePath = poster.GetFullImagePath(); - break; - - case ImageEntityType.TvDB_Banner: - var wideBanner = RepoFactory.TvDB_ImageWideBanner.GetByID(ImageID); - if (string.IsNullOrEmpty(wideBanner?.BannerPath)) - { - _logger.LogWarning(FailedToDownloadNoID, imageType, ImageID); - RemoveImageRecord(); - return; - } - - downloadUrl = string.Format(Constants.URLS.TvDB_Images, wideBanner.BannerPath); - filePath = wideBanner.GetFullImagePath(); - break; - } - - if (downloadUrl == null || filePath == null) - { - _logger.LogWarning(FailedToDownloadNoImpl, imageType); - return; - } - - try - { - // If this has any issues, it will throw an exception, so the catch below will handle it. - var result = await DownloadNow(downloadUrl, filePath); - switch (result) - { - case ImageDownloadResult.Success: - _logger.LogInformation("Image downloaded for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.Cached: - _logger.LogDebug("Image already in cache for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.Failure: - _logger.LogWarning("Image failed to download for {Anime}: {FilePath} from {DownloadUrl}", Anime, filePath, downloadUrl); - break; - case ImageDownloadResult.RemovedResource: - _logger.LogWarning("Image failed to download for {Anime} and the local entry has been removed: {FilePath} from {DownloadUrl}", Anime, - filePath, downloadUrl); - break; - case ImageDownloadResult.InvalidResource: - _logger.LogWarning("Image failed to download for {Anime} and the local entry could not be removed: {FilePath} from {DownloadUrl}", - Anime, filePath, downloadUrl); - break; - } - } - catch (WebException e) - { - _logger.LogWarning("Error processing {Job} for {Anime}: {Url} ({EntityID}) - {Message}", nameof(DownloadTvDBImageJob), Anime, downloadUrl, - ImageID, e.Message); - } - } - - private async Task<ImageDownloadResult> DownloadNow(string downloadUrl, string filePath) - { - var retryPolicy = Policy - .Handle<HttpRequestException>() - .Or<WebException>() - .OrResult(ImageDownloadResult.InvalidResource) - .OrResult(ImageDownloadResult.RemovedResource) - .WaitAndRetryAsync(new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5) }, (result, _) => - { - switch (result.Exception) - { - // if the server is just having issues, we can try again later - case HttpRequestException httpRequestException when IsRetryableError(httpRequestException.StatusCode): - case WebException: - return; - default: - // else it's a situation where the image will never work - RemoveImageRecord(); - break; - } - }); - - return await retryPolicy.ExecuteAsync(async () => - { - // Abort if the download URL or final destination is not available. - if (string.IsNullOrEmpty(downloadUrl) || string.IsNullOrEmpty(filePath)) - return ImageDownloadResult.Failure; - - var imageValid = File.Exists(filePath) && Misc.IsImageValid(filePath); - if (imageValid && !ForceDownload) - return ImageDownloadResult.Cached; - - var tempPath = Path.Combine(ImageUtils.GetImagesTempFolder(), Path.GetFileName(filePath)); - - try - { - // Download the image using custom HttpClient factory. - var client = _clientFactory.CreateClient("TvDBClient"); - var bytes = await client.GetByteArrayAsync(downloadUrl); - - // Validate the downloaded image. - if (bytes.Length < 4) - throw new WebException("The image download stream returned less than 4 bytes (a valid image has 2-4 bytes in the header)"); - - if (Misc.GetImageFormat(bytes) == null) - throw new WebException("The image download stream returned an invalid image"); - - // Write the image data to the temp file. - if (File.Exists(tempPath)) - File.Delete(tempPath); - - await using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write)) - fs.Write(bytes, 0, bytes.Length); - - // Ensure directory structure exists. - var dirPath = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dirPath)) - Directory.CreateDirectory(dirPath); - - // Delete existing file if re-downloading. - if (File.Exists(filePath)) - File.Delete(filePath); - - // Move the temp file to its final destination. - File.Move(tempPath, filePath); - - return ImageDownloadResult.Success; - } - catch (Exception ex) - { - throw new AggregateException("Image download failed.", ex); - } - }); - } - - private static bool IsRetryableError(HttpStatusCode? statusCode) - { - return statusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.BadGateway or HttpStatusCode.GatewayTimeout - or HttpStatusCode.InternalServerError; - } - - private void RemoveImageRecord() - { - switch (ImageType) - { - case ImageEntityType.TvDB_FanArt: - { - var fanart = RepoFactory.TvDB_ImageFanart.GetByID(ImageID); - if (fanart == null) - return; - - RepoFactory.TvDB_ImageFanart.Delete(fanart); - return; - } - case ImageEntityType.TvDB_Cover: - { - var poster = RepoFactory.TvDB_ImagePoster.GetByID(ImageID); - if (poster == null) - return; - - RepoFactory.TvDB_ImagePoster.Delete(poster); - return; - } - case ImageEntityType.TvDB_Banner: - { - var wideBanner = RepoFactory.TvDB_ImageWideBanner.GetByID(ImageID); - if (wideBanner == null) - return; - - RepoFactory.TvDB_ImageWideBanner.Delete(wideBanner); - break; - } - } - } - - public DownloadTvDBImageJob(ImageHttpClientFactory clientFactory) - { - _clientFactory = clientFactory; - } - - protected DownloadTvDBImageJob() { } -} diff --git a/Shoko.Server/Scheduling/Jobs/TvDB/GetTvDBImagesJob.cs b/Shoko.Server/Scheduling/Jobs/TvDB/GetTvDBImagesJob.cs deleted file mode 100644 index e337e3855..000000000 --- a/Shoko.Server/Scheduling/Jobs/TvDB/GetTvDBImagesJob.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Shoko.Server.Providers.TvDB; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling.Acquisition.Attributes; -using Shoko.Server.Scheduling.Attributes; -using Shoko.Server.Scheduling.Concurrency; - -namespace Shoko.Server.Scheduling.Jobs.TvDB; - -[DatabaseRequired] -[NetworkRequired] -[DisallowConcurrencyGroup(ConcurrencyGroups.TvDB)] -[JobKeyGroup(JobKeyGroup.TvDB)] -public class GetTvDBImagesJob : BaseJob -{ - private readonly TvDBApiHelper _helper; - private string _seriesName; - public int TvDBSeriesID { get; set; } - public bool ForceRefresh { get; set; } - - public override string TypeName => "Get TvDB Images for Series"; - public override string Title => "Getting TvDB Images for Series"; - public override void PostInit() - { - _seriesName = RepoFactory.TvDB_Series.GetByTvDBID(TvDBSeriesID)?.SeriesName; - } - - public override Dictionary<string, object> Details => new() - { - { - "Series", _seriesName ?? TvDBSeriesID.ToString() - } - }; - - public override async Task Process() - { - _logger.LogInformation("Processing {Job}: {ID}", nameof(GetTvDBImagesJob), TvDBSeriesID); - - await _helper.DownloadAutomaticImages(TvDBSeriesID, ForceRefresh); - } - - public GetTvDBImagesJob(TvDBApiHelper helper) - { - _helper = helper; - } - - protected GetTvDBImagesJob() { } -} diff --git a/Shoko.Server/Scheduling/Jobs/TvDB/GetTvDBSeriesJob.cs b/Shoko.Server/Scheduling/Jobs/TvDB/GetTvDBSeriesJob.cs deleted file mode 100644 index aa9fa93e2..000000000 --- a/Shoko.Server/Scheduling/Jobs/TvDB/GetTvDBSeriesJob.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Shoko.Models.Server; -using Shoko.Server.Providers.TvDB; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling.Acquisition.Attributes; -using Shoko.Server.Scheduling.Attributes; -using Shoko.Server.Scheduling.Concurrency; - -namespace Shoko.Server.Scheduling.Jobs.TvDB; - -[DatabaseRequired] -[NetworkRequired] -[DisallowConcurrencyGroup(ConcurrencyGroups.TvDB)] -[JobKeyGroup(JobKeyGroup.TvDB)] -public class GetTvDBSeriesJob : BaseJob<TvDB_Series> -{ - private readonly TvDBApiHelper _helper; - private string _seriesName; - public int TvDBSeriesID { get; set; } - public bool ForceRefresh { get; set; } - - public override string TypeName => "Get TvDB Series Data"; - - public override string Title => "Getting TvDB Series Data"; - public override void PostInit() - { - _seriesName = RepoFactory.TvDB_Series.GetByTvDBID(TvDBSeriesID)?.SeriesName; - } - - public override Dictionary<string, object> Details => new() - { - { - "Series", _seriesName ?? TvDBSeriesID.ToString() - } - }; - - public override async Task<TvDB_Series> Process() - { - _logger.LogInformation("Processing {Job}: {ID}", nameof(GetTvDBSeriesJob), TvDBSeriesID); - - return await _helper.UpdateSeriesInfoAndImages(TvDBSeriesID, ForceRefresh, true); - } - - public GetTvDBSeriesJob(TvDBApiHelper helper) - { - _helper = helper; - } - - protected GetTvDBSeriesJob() { } -} diff --git a/Shoko.Server/Scheduling/Jobs/TvDB/LinkTvDBSeriesJob.cs b/Shoko.Server/Scheduling/Jobs/TvDB/LinkTvDBSeriesJob.cs deleted file mode 100644 index fd18b10db..000000000 --- a/Shoko.Server/Scheduling/Jobs/TvDB/LinkTvDBSeriesJob.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Shoko.Server.Providers.TvDB; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling.Acquisition.Attributes; -using Shoko.Server.Scheduling.Attributes; -using Shoko.Server.Scheduling.Concurrency; -using Shoko.Server.Scheduling.Jobs.Actions; - -namespace Shoko.Server.Scheduling.Jobs.TvDB; - -[DatabaseRequired] -[NetworkRequired] -[DisallowConcurrencyGroup(ConcurrencyGroups.TvDB)] -[JobKeyGroup(JobKeyGroup.TvDB)] -public class LinkTvDBSeriesJob : BaseJob -{ - private readonly TvDBApiHelper _helper; - private readonly JobFactory _jobFactory; - private string _animeName; - private string _seriesName; - public int AnimeID { get; set; } - public int TvDBID { get; set; } - public bool AdditiveLink { get; set; } - - public override string TypeName => "Link TvDB Series"; - public override string Title => "Linking TvDB Series"; - public override void PostInit() - { - _animeName = RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID)?.PreferredTitle; - _seriesName = RepoFactory.TvDB_Series.GetByTvDBID(TvDBID)?.SeriesName; - } - - public override Dictionary<string, object> Details => new() - { - { - "Anime", _animeName ?? AnimeID.ToString() - }, - { - "TvDB Series", _seriesName ?? TvDBID.ToString() - } - }; - - public override async Task Process() - { - _logger.LogInformation("Processing {Job} -> TvDB: {TvDB} | AniDB: {AniDB} | Additive: {Additive}", nameof(LinkTvDBSeriesJob), TvDBID, AnimeID, - AdditiveLink); - - await _helper.LinkAniDBTvDB(AnimeID, TvDBID, AdditiveLink); - await _jobFactory.CreateJob<RefreshAnimeStatsJob>(x => x.AnimeID = AnimeID).Process(); - } - - public LinkTvDBSeriesJob(TvDBApiHelper helper, JobFactory jobFactory) - { - _helper = helper; - _jobFactory = jobFactory; - } - - protected LinkTvDBSeriesJob() { } -} diff --git a/Shoko.Server/Scheduling/Jobs/TvDB/SearchTvDBSeriesJob.cs b/Shoko.Server/Scheduling/Jobs/TvDB/SearchTvDBSeriesJob.cs deleted file mode 100644 index f0c50c085..000000000 --- a/Shoko.Server/Scheduling/Jobs/TvDB/SearchTvDBSeriesJob.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Shoko.Models.Enums; -using Shoko.Models.Server; -using Shoko.Models.TvDB; -using Shoko.Plugin.Abstractions.DataModels; -using Shoko.Server.Providers.TvDB; -using Shoko.Server.Repositories; -using Shoko.Server.Scheduling.Acquisition.Attributes; -using Shoko.Server.Scheduling.Attributes; -using Shoko.Server.Scheduling.Concurrency; -using Shoko.Server.Scheduling.Jobs.Actions; -using Shoko.Server.Settings; - -namespace Shoko.Server.Scheduling.Jobs.TvDB; - -[DatabaseRequired] -[NetworkRequired] -[DisallowConcurrencyGroup(ConcurrencyGroups.TvDB)] -[JobKeyGroup(JobKeyGroup.TvDB)] -public class SearchTvDBSeriesJob : BaseJob -{ - private readonly ISettingsProvider _settingsProvider; - private readonly JobFactory _jobFactory; - private readonly TvDBApiHelper _helper; - private string _title; - - public int AnimeID { get; set; } - public bool ForceRefresh { get; set; } - - public override string TypeName => "Search for TvDB Series"; - public override string Title => "Searching for TvDB Series"; - - public override void PostInit() - { - _title = RepoFactory.AniDB_Anime?.GetByAnimeID(AnimeID)?.PreferredTitle ?? AnimeID.ToString(); - } - - public override Dictionary<string, object> Details => new() { { "Anime", _title } }; - - public override async Task Process() - { - _logger.LogInformation("Processing {Job}: {ID}", nameof(SearchTvDBSeriesJob), _title); - - var settings = _settingsProvider.GetSettings(); - if (!settings.TvDB.AutoLink) return; - - // try to pull a link from a prequel/sequel - var relations = RepoFactory.AniDB_Anime_Relation.GetFullLinearRelationTree(AnimeID); - var tvDBID = relations.SelectMany(a => RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeID(a)) - .FirstOrDefault(a => a != null)?.TvDBID; - - if (tvDBID != null) - { - await _helper.LinkAniDBTvDB(AnimeID, tvDBID.Value, true, true); - return; - } - - // search TvDB - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(AnimeID); - if (anime == null) return; - - var searchCriteria = CleanTitle(anime.MainTitle); - - // if not wanting to use web cache, or no match found on the web cache go to TvDB directly - var results = await _helper.SearchSeriesAsync(searchCriteria); - _logger.LogTrace("Found {Count} tvdb results for {Query} on TheTvDB", results.Count, searchCriteria); - if (await ProcessSearchResults(results, searchCriteria)) return; - if (results.Count != 0) return; - - var foundResult = false; - foreach (var title in anime.Titles) - { - if (title.TitleType != TitleType.Official) continue; - if (title.Language != TitleLanguage.English && title.Language != TitleLanguage.Romaji) continue; - - var cleanTitle = CleanTitle(title.Title); - if (string.Equals(searchCriteria, cleanTitle, StringComparison.InvariantCultureIgnoreCase)) continue; - - searchCriteria = cleanTitle; - results = await _helper.SearchSeriesAsync(searchCriteria); - if (results.Count > 0) foundResult = true; - - _logger.LogTrace("Found {Count} tvdb results for search on {Query}", results.Count, searchCriteria); - if (await ProcessSearchResults(results, searchCriteria)) return; - } - - if (!foundResult) - { - _logger.LogWarning("Unable to find a matching TvDB series for {Query}", _title); - } - } - - private async Task<bool> ProcessSearchResults(List<TVDB_Series_Search_Response> results, string searchCriteria) - { - switch (results.Count) - { - case 1: - // since we are using this result, lets download the info - _logger.LogTrace("Found 1 tvdb results for {Query} --- Linked to {Name} ({ID})", searchCriteria, results[0].SeriesName, results[0].SeriesID); - await _helper.GetSeriesInfoOnlineAsync(results[0].SeriesID, false); - await _helper.LinkAniDBTvDB(AnimeID, results[0].SeriesID, true, true); - - // add links for multiple seasons (for long shows) - AddCrossRef_AniDB_TvDBV2(AnimeID, results[0].SeriesID, CrossRefSource.Automatic); - await _jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = AnimeID).Process(); - return true; - case 0: - return false; - default: - _logger.LogTrace("Found multiple ({Count}) tvdb results for {Query}, so checking for english results", results.Count, searchCriteria); - foreach (var sres in results) - { - // since we are using this result, lets download the info - _logger.LogTrace("Found english result for {Query} --- Linked to {Name} ({ID})", searchCriteria, sres.SeriesName, sres.SeriesID); - await _helper.GetSeriesInfoOnlineAsync(results[0].SeriesID, false); - await _helper.LinkAniDBTvDB(AnimeID, sres.SeriesID, true, true); - - // add links for multiple seasons (for long shows) - AddCrossRef_AniDB_TvDBV2(AnimeID, results[0].SeriesID, CrossRefSource.Automatic); - await _jobFactory.CreateJob<RefreshAnimeStatsJob>(a => a.AnimeID = AnimeID).Process(); - return true; - } - - _logger.LogTrace("No english results found, so SKIPPING: {Query}", searchCriteria); - - return false; - } - } - - private static void AddCrossRef_AniDB_TvDBV2(int animeID, int tvdbID, CrossRefSource source) - { - var xref = - RepoFactory.CrossRef_AniDB_TvDB.GetByAniDBAndTvDBID(animeID, tvdbID); - if (xref != null) - { - return; - } - - xref = new CrossRef_AniDB_TvDB { AniDBID = animeID, TvDBID = tvdbID, CrossRefSource = source }; - RepoFactory.CrossRef_AniDB_TvDB.Save(xref); - } - - private static readonly Regex RemoveYear = new(@"(^.*)( \([0-9]+\)$)", RegexOptions.Compiled); - private static readonly Regex RemoveAfterColon = new(@"(^.*)(\:.*$)", RegexOptions.Compiled); - - private static string CleanTitle(string title) - { - var result = RemoveYear.Replace(title, "$1"); - result = RemoveAfterColon.Replace(result, "$1"); - return result; - } - - public SearchTvDBSeriesJob(TvDBApiHelper helper, ISettingsProvider settingsProvider, JobFactory jobFactory) - { - _helper = helper; - _settingsProvider = settingsProvider; - _jobFactory = jobFactory; - } - - protected SearchTvDBSeriesJob() { } -} diff --git a/Shoko.Server/Scheduling/QuartzExtensions.cs b/Shoko.Server/Scheduling/QuartzExtensions.cs index f4497ff79..9992a2b71 100644 --- a/Shoko.Server/Scheduling/QuartzExtensions.cs +++ b/Shoko.Server/Scheduling/QuartzExtensions.cs @@ -134,4 +134,16 @@ public static void AddArrayParameters<T>(this IDbCommand cmd, string paramNameRo cmd.CommandText = cmd.CommandText.Replace("@" + paramNameRoot, string.Join(",", parameterNames)); } + + public static async Task RescheduleJob(this IJobExecutionContext context) + { + var triggerKey = context.Trigger.Key; + var newKey = new TriggerKey(triggerKey.Name + "_Retry", triggerKey.Group); + + if (await context.Scheduler.GetTrigger(newKey) != null) return; + + var newTrigger = context.Trigger.GetTriggerBuilder(); + newTrigger.WithIdentity(newKey); + await context.Scheduler.ScheduleJob(newTrigger.Build(), context.CancellationToken); + } } diff --git a/Shoko.Server/Scheduling/QuartzStartup.cs b/Shoko.Server/Scheduling/QuartzStartup.cs index 1178ae412..5e224f207 100644 --- a/Shoko.Server/Scheduling/QuartzStartup.cs +++ b/Shoko.Server/Scheduling/QuartzStartup.cs @@ -30,7 +30,7 @@ public static async Task ScheduleRecurringJobs(bool replace) // Also give it a high priority, since it affects Acquisition Filters // StartJobNow gives a priority of 10. We'll give it 20 to be even higher priority await ScheduleRecurringJob<CheckNetworkAvailabilityJob>( - triggerConfig: t => t.WithPriority(20).WithSimpleSchedule(tr => tr.WithIntervalInMinutes(5).RepeatForever()).StartNow(), replace: true, keepSchedule: false); + triggerConfig: t => t.WithPriority(20).WithSimpleSchedule(tr => tr.WithIntervalInMinutes(30).RepeatForever()).StartNow(), replace: true, keepSchedule: false); // TODO the other schedule-based jobs that are on timers } @@ -100,7 +100,7 @@ internal static void AddQuartz(this IServiceCollection services) q.UseDefaultThreadPool(o => o.MaxConcurrency = threadPoolSize); q.UseDatabase(); - q.MaxBatchSize = 1; + q.MaxBatchSize = threadPoolSize; q.BatchTriggerAcquisitionFireAheadTimeWindow = TimeSpan.FromSeconds(0.5); q.UseJobFactory<JobFactory>(); q.AddSchedulerListener<SchedulerListener>(); diff --git a/Shoko.Server/Scheduling/QueueHandler.cs b/Shoko.Server/Scheduling/QueueHandler.cs index 288c9039d..8712d676e 100644 --- a/Shoko.Server/Scheduling/QueueHandler.cs +++ b/Shoko.Server/Scheduling/QueueHandler.cs @@ -137,7 +137,7 @@ public ValueTask<int> GetTotalWaitingJobCount() public async ValueTask<Dictionary<string, int>> GetJobCounts() { var jobs = await _jobStore.GetJobCounts(); - return jobs.Where(a => typeof(BaseJob).IsAssignableFrom(a.Key)).Select(a => (_jobFactory.CreateJob(new JobDetail{ Name = Guid.NewGuid().ToString(), JobType = new JobType(a.Key)})?.TypeName, a.Value)) + return jobs.Where(a => typeof(BaseJob).IsAssignableFrom(a.Key)).Select(a => (_jobFactory.CreateJob(new JobDetail(a.Key) { Name = Guid.NewGuid().ToString() })?.TypeName, a.Value)) .Where(a => a.TypeName != null) .ToDictionary(a => a.TypeName, a => a.Value); } diff --git a/Shoko.Server/Server/Constants.cs b/Shoko.Server/Server/Constants.cs index 0638e331c..ab6f75090 100644 --- a/Shoko.Server/Server/Constants.cs +++ b/Shoko.Server/Server/Constants.cs @@ -90,30 +90,30 @@ public struct FileRenameReserved public struct URLS { - public static readonly string MAL_Series = @"https://myanimelist.net/anime/{0}"; - public static readonly string AniDB_Series = @"https://anidb.net/perl-bin/animedb.pl?show=anime&aid={0}"; + public const string MAL_Series = @"https://myanimelist.net/anime/{0}"; + public const string AniDB_Series = @"https://anidb.net/perl-bin/animedb.pl?show=anime&aid={0}"; - public static readonly string AniDB_SeriesDiscussion = + public const string AniDB_SeriesDiscussion = @"https://anidb.net/perl-bin/animedb.pl?show=threads&do=anime&id={0}"; - public static readonly string AniDB_Images = @"https://{0}/images/main/{{0}}"; + public const string AniDB_Images = @"https://{0}/images/main/{{0}}"; // This is the fallback if the API response does not work. - public static readonly string AniDB_Images_Domain = @"cdn.anidb.net"; + public const string AniDB_Images_Domain = @"cdn.anidb.net"; - public static readonly string TvDB_Series = @"https://thetvdb.com/?tab=series&id={0}"; + public const string Trakt_Series = @"https://trakt.tv/show/{0}"; - //public static readonly string tvDBEpisodeURLPrefix = @"http://anidb.net/perl-bin/animedb.pl?show=ep&eid={0}"; - public static readonly string TvDB_Images = @"https://artworks.thetvdb.com/banners/{0}"; - public static readonly string TvDB_Episode_Images = @"https://thetvdb.com/banners/{0}"; - public static readonly string Trakt_Series = @"https://trakt.tv/show/{0}"; + public const string TMDB_Movie = @"https://www.themoviedb.org/movie/{0}"; - public static readonly string MovieDB_Images = @"https://image.tmdb.org/t/p/original{0}"; + public const string TMDB_Images = @"https://image.tmdb.org/t/p/original{0}"; + + public const string TMDB_Export = @"https://files.tmdb.org/p/exports/{0}_ids_{1}_{2}_{3}.json.gz"; } - public struct TvDB + public struct TMDB { - public static readonly string apiKey = "B178B8940CAF4A2C"; + // For local development, please replace the text below with your TMDB API key, or insert the key in your settings. + public const string ApiKey = "TMDB_API_KEY_GOES_HERE"; } } diff --git a/Shoko.Server/Server/Enums.cs b/Shoko.Server/Server/Enums.cs index 60e116921..0dabb2651 100644 --- a/Shoko.Server/Server/Enums.cs +++ b/Shoko.Server/Server/Enums.cs @@ -1,4 +1,8 @@ -namespace Shoko.Server.Server; +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Shoko.Server.Server; public enum HashSource { @@ -9,19 +13,13 @@ public enum HashSource public enum ScheduledUpdateType { AniDBCalendar = 1, - TvDBInfo = 2, AniDBUpdates = 3, - AniDBTitles = 4, AniDBMyListSync = 5, TraktSync = 6, TraktUpdate = 7, - MALUpdate = 8, - AniDBMylistStats = 9, AniDBFileUpdates = 10, - LogClean = 11, - AzureUserInfo = 12, TraktToken = 13, - DayFiltersUpdate = 14 + AniDBNotify = 15, } public enum TraktSyncAction @@ -29,3 +27,66 @@ public enum TraktSyncAction Add = 1, Remove = 2 } + +public enum AniDBNotifyType +{ + Message = 1, + Notification = 2, +} + +public enum AniDBMessageType +{ + Normal = 0, + Anonymous = 1, + System = 2, + Moderator = 3, +} + +/// <summary> +/// Read status of messages and notifications +/// </summary> +[Flags] +public enum AniDBMessageFlags +{ + /// <summary> + /// No flags + /// </summary> + None = 0, + + /// <summary> + /// Marked as read on AniDB + /// </summary> + ReadOnAniDB = 1, + + /// <summary> + /// Marked as read locally + /// </summary> + ReadOnShoko = 2, + + /// <summary> + /// Is a file moved notification + /// </summary> + FileMoved = 4, + + /// <summary> + /// Has the file move been handled + /// </summary> + FileMoveHandled = 8 +} + +[Flags] +[JsonConverter(typeof(StringEnumConverter))] +public enum ForeignEntityType +{ + None = 0, + Collection = 1, + Movie = 2, + Show = 4, + Season = 8, + Episode = 16, + Company = 32, + Studio = 64, + Network = 128, + Person = 256, + Character = 512, +} diff --git a/Shoko.Server/Server/ShokoEventHandler.cs b/Shoko.Server/Server/ShokoEventHandler.cs index d9c143847..9bd97912a 100644 --- a/Shoko.Server/Server/ShokoEventHandler.cs +++ b/Shoko.Server/Server/ShokoEventHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; @@ -6,7 +7,9 @@ using Shoko.Plugin.Abstractions; using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.Models; +using Shoko.Server.Models.TMDB; using Shoko.Server.Utilities; #nullable enable @@ -14,15 +17,15 @@ namespace Shoko.Server; public class ShokoEventHandler : IShokoEventHandler { - public event EventHandler<FileDeletedEventArgs>? FileDeleted; + public event EventHandler<FileEventArgs>? FileDeleted; public event EventHandler<FileDetectedEventArgs>? FileDetected; - public event EventHandler<FileHashedEventArgs>? FileHashed; + public event EventHandler<FileEventArgs>? FileHashed; public event EventHandler<FileNotMatchedEventArgs>? FileNotMatched; - public event EventHandler<FileMatchedEventArgs>? FileMatched; + public event EventHandler<FileEventArgs>? FileMatched; public event EventHandler<FileRenamedEventArgs>? FileRenamed; @@ -34,6 +37,8 @@ public class ShokoEventHandler : IShokoEventHandler public event EventHandler<EpisodeInfoUpdatedEventArgs>? EpisodeUpdated; + public event EventHandler<MovieInfoUpdatedEventArgs>? MovieUpdated; + public event EventHandler<SettingsSavedEventArgs>? SettingsSaved; public event EventHandler<AVDumpEventArgs>? AVDumpEvent; @@ -58,26 +63,20 @@ public void OnFileHashed(SVR_ImportFolder folder, SVR_VideoLocal_Place vlp, SVR_ var relativePath = vlp.FilePath; var xrefs = vl.EpisodeCrossRefs; var episodes = xrefs - .Select(x => x.AniDBEpisode) + .Select(x => x.AnimeEpisode) .WhereNotNull() .ToList(); var series = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var episodeInfo = episodes.Cast<IEpisode>().ToList(); - var animeInfo = series.Cast<IAnime>().ToList(); - var groupInfo = xrefs .DistinctBy(x => x.AnimeID) .Select(x => x.AnimeSeries) .WhereNotNull() + .ToList(); + var groups = series .DistinctBy(a => a.AnimeGroupID) .Select(a => a.AnimeGroup) .WhereNotNull() - .Cast<IGroup>() .ToList(); - FileHashed?.Invoke(null, new(relativePath, folder, vlp, vl, episodeInfo, animeInfo, groupInfo)); + FileHashed?.Invoke(null, new(relativePath, folder, vlp, vl, episodes, series, groups)); } public void OnFileDeleted(SVR_ImportFolder folder, SVR_VideoLocal_Place vlp, SVR_VideoLocal vl) @@ -85,26 +84,20 @@ public void OnFileDeleted(SVR_ImportFolder folder, SVR_VideoLocal_Place vlp, SVR var path = vlp.FilePath; var xrefs = vl.EpisodeCrossRefs; var episodes = xrefs - .Select(x => x.AniDBEpisode) + .Select(x => x.AnimeEpisode) .WhereNotNull() .ToList(); var series = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var episodeInfo = episodes.Cast<IEpisode>().ToList(); - var animeInfo = series.Cast<IAnime>().ToList(); - var groupInfo = xrefs .DistinctBy(x => x.AnimeID) .Select(x => x.AnimeSeries) .WhereNotNull() + .ToList(); + var groups = series .DistinctBy(a => a.AnimeGroupID) .Select(a => a.AnimeGroup) .WhereNotNull() - .Cast<IGroup>() .ToList(); - FileDeleted?.Invoke(null, new(path, folder, vlp, vl, episodeInfo, animeInfo, groupInfo)); + FileDeleted?.Invoke(null, new(path, folder, vlp, vl, episodes, series, groups)); } public void OnFileMatched(SVR_VideoLocal_Place vlp, SVR_VideoLocal vl) @@ -112,26 +105,20 @@ public void OnFileMatched(SVR_VideoLocal_Place vlp, SVR_VideoLocal vl) var path = vlp.FilePath; var xrefs = vl.EpisodeCrossRefs; var episodes = xrefs - .Select(x => x.AniDBEpisode) + .Select(x => x.AnimeEpisode) .WhereNotNull() .ToList(); var series = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var episodeInfo = episodes.Cast<IEpisode>().ToList(); - var animeInfo = series.Cast<IAnime>().ToList(); - var groupInfo = xrefs .DistinctBy(x => x.AnimeID) .Select(x => x.AnimeSeries) .WhereNotNull() + .ToList(); + var groups = series .DistinctBy(a => a.AnimeGroupID) .Select(a => a.AnimeGroup) .WhereNotNull() - .Cast<IGroup>() .ToList(); - FileMatched?.Invoke(null, new(path, vlp.ImportFolder, vlp, vl, episodeInfo, animeInfo, groupInfo)); + FileMatched?.Invoke(null, new(path, vlp.ImportFolder!, vlp, vl, episodes, series, groups)); } public void OnFileNotMatched(SVR_VideoLocal_Place vlp, SVR_VideoLocal vl, int autoMatchAttempts, bool hasXRefs, bool isUDPBanned) @@ -139,81 +126,63 @@ public void OnFileNotMatched(SVR_VideoLocal_Place vlp, SVR_VideoLocal vl, int au var path = vlp.FilePath; var xrefs = vl.EpisodeCrossRefs; var episodes = xrefs - .Select(x => x.AniDBEpisode) + .Select(x => x.AnimeEpisode) .WhereNotNull() .ToList(); var series = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var episodeInfo = episodes.Cast<IEpisode>().ToList(); - var animeInfo = series.Cast<IAnime>().ToList(); - var groupInfo = xrefs .DistinctBy(x => x.AnimeID) .Select(x => x.AnimeSeries) .WhereNotNull() + .ToList(); + var groups = series .DistinctBy(a => a.AnimeGroupID) .Select(a => a.AnimeGroup) .WhereNotNull() - .Cast<IGroup>() .ToList(); - FileNotMatched?.Invoke(null, new(path, vlp.ImportFolder, vlp, vl, episodeInfo, animeInfo, groupInfo, autoMatchAttempts, hasXRefs, isUDPBanned)); + FileNotMatched?.Invoke(null, new(path, vlp.ImportFolder!, vlp, vl, episodes, series, groups, autoMatchAttempts, hasXRefs, isUDPBanned)); } - public void OnFileMoved(SVR_ImportFolder oldFolder, SVR_ImportFolder newFolder, string oldPath, string newPath, SVR_VideoLocal_Place vlp) + public void OnFileMoved(IImportFolder oldFolder, IImportFolder newFolder, string oldPath, string newPath, SVR_VideoLocal_Place vlp) { - var vl = vlp.VideoLocal; + var vl = vlp.VideoLocal!; var xrefs = vl.EpisodeCrossRefs; var episodes = xrefs - .Select(x => x.AniDBEpisode) + .Select(x => x.AnimeEpisode) .WhereNotNull() .ToList(); var series = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var episodeInfo = episodes.Cast<IEpisode>().ToList(); - var animeInfo = series.Cast<IAnime>().ToList(); - var groupInfo = xrefs .DistinctBy(x => x.AnimeID) .Select(x => x.AnimeSeries) .WhereNotNull() + .ToList(); + var groups = series .DistinctBy(a => a.AnimeGroupID) .Select(a => a.AnimeGroup) .WhereNotNull() - .Cast<IGroup>() .ToList(); - FileMoved?.Invoke(null, new(newPath, newFolder, oldPath, oldFolder, vlp, vl, episodeInfo, animeInfo, groupInfo)); + FileMoved?.Invoke(null, new(newPath, newFolder, oldPath, oldFolder, vlp, vl, episodes, series, groups)); } - public void OnFileRenamed(SVR_ImportFolder folder, string oldName, string newName, SVR_VideoLocal_Place vlp) + public void OnFileRenamed(IImportFolder folder, string oldName, string newName, SVR_VideoLocal_Place vlp) { var path = vlp.FilePath; - var vl = vlp.VideoLocal; + var vl = vlp.VideoLocal!; var xrefs = vl.EpisodeCrossRefs; var episodes = xrefs - .Select(x => x.AniDBEpisode) + .Select(x => x.AnimeEpisode) .WhereNotNull() .ToList(); var series = xrefs - .DistinctBy(x => x.AnimeID) - .Select(x => x.AniDBAnime) - .WhereNotNull() - .ToList(); - var episodeInfo = episodes.Cast<IEpisode>().ToList(); - var animeInfo = series.Cast<IAnime>().ToList(); - var groupInfo = xrefs .DistinctBy(x => x.AnimeID) .Select(x => x.AnimeSeries) .WhereNotNull() + .ToList(); + var groups = series .DistinctBy(a => a.AnimeGroupID) .Select(a => a.AnimeGroup) .WhereNotNull() - .Cast<IGroup>() .ToList(); - FileRenamed?.Invoke(null, new(path, folder, newName, oldName, vlp, vl, episodeInfo, animeInfo, groupInfo)); + FileRenamed?.Invoke(null, new(path, folder, newName, oldName, vlp, vl, episodes, series, groups)); } public void OnAniDBBanned(AniDBBanType type, DateTime time, DateTime resumeTime) @@ -221,16 +190,58 @@ public void OnAniDBBanned(AniDBBanType type, DateTime time, DateTime resumeTime) AniDBBanned?.Invoke(null, new(type, time, resumeTime)); } - public void OnSeriesUpdated(SVR_AnimeSeries series, UpdateReason reason) + public void OnSeriesUpdated(SVR_AnimeSeries series, UpdateReason reason, IEnumerable<(SVR_AnimeEpisode episode, UpdateReason reason)>? episodes = null) + { + ArgumentNullException.ThrowIfNull(series, nameof(series)); + var episodeEvents = episodes?.Select(e => new EpisodeInfoUpdatedEventArgs(series, e.episode, e.reason)).ToList() ?? []; + SeriesUpdated?.Invoke(null, new(series, reason, episodeEvents)); + foreach (var e in episodeEvents) + EpisodeUpdated?.Invoke(null, e); + } + + public void OnSeriesUpdated(SVR_AnimeSeries series, UpdateReason reason, IEnumerable<KeyValuePair<SVR_AnimeEpisode, UpdateReason>> episodes) { ArgumentNullException.ThrowIfNull(series, nameof(series)); - SeriesUpdated?.Invoke(null, new(series, reason)); + var episodeEvents = episodes.Select(e => new EpisodeInfoUpdatedEventArgs(series, e.Key, e.Value)).ToList(); + SeriesUpdated?.Invoke(null, new(series, reason, episodeEvents)); + foreach (var e in episodeEvents) + EpisodeUpdated?.Invoke(null, e); } - public void OnSeriesUpdated(SVR_AniDB_Anime anime, UpdateReason reason) + public void OnSeriesUpdated(SVR_AniDB_Anime anime, UpdateReason reason, IEnumerable<(SVR_AniDB_Episode episode, UpdateReason reason)>? episodes = null) { ArgumentNullException.ThrowIfNull(anime, nameof(anime)); - SeriesUpdated?.Invoke(null, new(anime, reason)); + var episodeEvents = episodes?.Select(e => new EpisodeInfoUpdatedEventArgs(anime, e.episode, e.reason)).ToList() ?? []; + SeriesUpdated?.Invoke(null, new(anime, reason, episodeEvents)); + foreach (var e in episodeEvents) + EpisodeUpdated?.Invoke(null, e); + } + + public void OnSeriesUpdated(SVR_AniDB_Anime anime, UpdateReason reason, IEnumerable<KeyValuePair<SVR_AniDB_Episode, UpdateReason>> episodes) + { + ArgumentNullException.ThrowIfNull(anime, nameof(anime)); + var episodeEvents = episodes.Select(e => new EpisodeInfoUpdatedEventArgs(anime, e.Key, e.Value)).ToList(); + SeriesUpdated?.Invoke(null, new(anime, reason, episodeEvents)); + foreach (var e in episodeEvents) + EpisodeUpdated?.Invoke(null, e); + } + + public void OnSeriesUpdated(TMDB_Show show, UpdateReason reason, IEnumerable<(TMDB_Episode episode, UpdateReason reason)>? episodes = null) + { + ArgumentNullException.ThrowIfNull(show, nameof(show)); + var episodeEvents = episodes?.Select(e => new EpisodeInfoUpdatedEventArgs(show, e.episode, e.reason)).ToList() ?? []; + SeriesUpdated?.Invoke(null, new(show, reason, episodeEvents)); + foreach (var e in episodeEvents) + EpisodeUpdated?.Invoke(null, e); + } + + public void OnSeriesUpdated(TMDB_Show show, UpdateReason reason, IEnumerable<KeyValuePair<TMDB_Episode, UpdateReason>> episodes) + { + ArgumentNullException.ThrowIfNull(show, nameof(show)); + var episodeEvents = episodes.Select(e => new EpisodeInfoUpdatedEventArgs(show, e.Key, e.Value)).ToList(); + SeriesUpdated?.Invoke(null, new(show, reason, episodeEvents)); + foreach (var e in episodeEvents) + EpisodeUpdated?.Invoke(null, e); } public void OnEpisodeUpdated(SVR_AnimeSeries series, SVR_AnimeEpisode episode, UpdateReason reason) @@ -247,6 +258,19 @@ public void OnEpisodeUpdated(SVR_AniDB_Anime anime, SVR_AniDB_Episode episode, U EpisodeUpdated?.Invoke(null, new(anime, episode, reason)); } + public void OnEpisodeUpdated(TMDB_Show show, TMDB_Episode episode, UpdateReason reason) + { + ArgumentNullException.ThrowIfNull(show, nameof(show)); + ArgumentNullException.ThrowIfNull(episode, nameof(episode)); + EpisodeUpdated?.Invoke(null, new(show, episode, reason)); + } + + public void OnMovieUpdated(TMDB_Movie movie, UpdateReason reason) + { + ArgumentNullException.ThrowIfNull(movie, nameof(movie)); + MovieUpdated?.Invoke(null, new(movie, reason)); + } + public void OnSettingsSaved() { SettingsSaved?.Invoke(null, new SettingsSavedEventArgs()); diff --git a/Shoko.Server/Server/ShokoServer.cs b/Shoko.Server/Server/ShokoServer.cs index d700f0c8d..a68aca68d 100644 --- a/Shoko.Server/Server/ShokoServer.cs +++ b/Shoko.Server/Server/ShokoServer.cs @@ -13,6 +13,7 @@ using Shoko.Server.Databases; using Shoko.Server.Plugin; using Shoko.Server.Providers.AniDB.Interfaces; +using Shoko.Server.Renamer; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Actions; @@ -27,30 +28,29 @@ namespace Shoko.Server.Server; public class ShokoServer { - //private static bool doneFirstTrakTinfo = false; - private readonly ILogger<ShokoServer> logger; + private readonly ILogger<ShokoServer> _logger; private readonly DatabaseFactory _databaseFactory; private readonly ISettingsProvider _settingsProvider; private readonly ISchedulerFactory _schedulerFactory; private readonly RepoFactory _repoFactory; private readonly FileWatcherService _fileWatcherService; - private static DateTime? StartTime; + private static DateTime? _startTime; - public static TimeSpan? UpTime => StartTime == null ? null : DateTime.Now - StartTime; + public static TimeSpan? UpTime => _startTime == null ? null : DateTime.Now - _startTime; private readonly BackgroundWorker _workerSetupDB = new(); - // TODO Move all of these to Quartz - private static Timer autoUpdateTimer; - private static Timer autoUpdateTimerShort; + // TODO: Move all of these to Quartz - private BackgroundWorker downloadImagesWorker = new(); + private static Timer _autoUpdateTimer; + + private readonly BackgroundWorker _downloadImagesWorker = new(); public ShokoServer(ILogger<ShokoServer> logger, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, DatabaseFactory databaseFactory, RepoFactory repoFactory, FileWatcherService fileWatcherService) { - this.logger = logger; + _logger = logger; _settingsProvider = settingsProvider; _schedulerFactory = schedulerFactory; _databaseFactory = databaseFactory; @@ -96,9 +96,9 @@ public bool StartUpServer() ServerState.Instance.StartupFailed = false; ServerState.Instance.StartupFailedMessage = string.Empty; - downloadImagesWorker.DoWork += DownloadImagesWorker_DoWork; - downloadImagesWorker.WorkerSupportsCancellation = true; - + _downloadImagesWorker.DoWork += DownloadImagesWorker_DoWork; + _downloadImagesWorker.WorkerSupportsCancellation = true; + _workerSetupDB.WorkerReportsProgress = true; _workerSetupDB.ProgressChanged += (_, _) => WorkerSetupDB_ReportProgress(); _workerSetupDB.DoWork += WorkerSetupDB_DoWork; @@ -111,6 +111,7 @@ public bool StartUpServer() // for log readability, this will simply init the singleton Task.Run(async () => await Utils.ServiceContainer.GetRequiredService<IUDPConnectionHandler>().Init()); + Task.Run(() => Utils.ServiceContainer.GetRequiredService<RenameFileService>().AllRenamers); return true; } @@ -141,7 +142,7 @@ private bool CheckBlockedFiles() foreach (var dllFile in dllFiles) { if (!FileSystem.AlternateDataStreamExists(dllFile, "Zone.Identifier")) continue; - logger.LogError("Found blocked DLL file: " + dllFile); + _logger.LogError("Found blocked DLL file: " + dllFile); result = false; } @@ -151,9 +152,6 @@ private bool CheckBlockedFiles() #region Database settings and initial start up - public event EventHandler ServerStarting; - public event EventHandler LoginFormNeeded; - public event EventHandler DatabaseSetup; public event EventHandler DBSetupCompleted; private void WorkerSetupDB_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) @@ -167,32 +165,21 @@ private void WorkerSetupDB_RunWorkerCompleted(object sender, RunWorkerCompletedE if (!string.IsNullOrEmpty(settings.Database.Type)) return; settings.Database.Type = Constants.DatabaseType.Sqlite; - ShowDatabaseSetup(); } private void WorkerSetupDB_ReportProgress() { - logger.LogInformation("Starting Server: Complete!"); + _logger.LogInformation("Starting Server: Complete!"); ServerState.Instance.ServerStartingStatus = Resources.Server_Complete; ServerState.Instance.ServerOnline = true; var settings = _settingsProvider.GetSettings(); settings.FirstRun = false; _settingsProvider.SaveSettings(); - if (string.IsNullOrEmpty(settings.AniDb.Username) || - string.IsNullOrEmpty(settings.AniDb.Password)) - { - LoginFormNeeded?.Invoke(this, EventArgs.Empty); - } DBSetupCompleted?.Invoke(this, EventArgs.Empty); ShokoEventHandler.Instance.OnStarted(); } - private void ShowDatabaseSetup() - { - DatabaseSetup?.Invoke(this, EventArgs.Empty); - } - private void WorkerSetupDB_DoWork(object sender, DoWorkEventArgs e) { ServerState.Instance.DatabaseAvailable = false; @@ -208,14 +195,9 @@ private void WorkerSetupDB_DoWork(object sender, DoWorkEventArgs e) _fileWatcherService.StopWatchingFiles(); - if (autoUpdateTimer != null) - { - autoUpdateTimer.Enabled = false; - } - - if (autoUpdateTimerShort != null) + if (_autoUpdateTimer != null) { - autoUpdateTimerShort.Enabled = false; + _autoUpdateTimer.Enabled = false; } _databaseFactory.CloseSessionFactory(); @@ -225,7 +207,7 @@ private void WorkerSetupDB_DoWork(object sender, DoWorkEventArgs e) ServerState.Instance.ServerStartingStatus = Resources.Server_DatabaseSetup; - logger.LogInformation("Setting up database..."); + _logger.LogInformation("Setting up database..."); if (!InitDB(out var errorMessage)) { ServerState.Instance.DatabaseAvailable = false; @@ -242,7 +224,7 @@ private void WorkerSetupDB_DoWork(object sender, DoWorkEventArgs e) return; } - logger.LogInformation("Initializing Session Factory..."); + _logger.LogInformation("Initializing Session Factory..."); //init session factory ServerState.Instance.ServerStartingStatus = Resources.Server_InitializingSession; var _ = _databaseFactory.SessionFactory; @@ -250,20 +232,13 @@ private void WorkerSetupDB_DoWork(object sender, DoWorkEventArgs e) // timer for automatic updates - autoUpdateTimer = new Timer + _autoUpdateTimer = new Timer { - AutoReset = true, Interval = 5 * 60 * 1000 // 5 * 60 seconds (5 minutes) + AutoReset = true, + Interval = 5 * 60 * 1000 // 5 * 60 seconds (5 minutes) }; - autoUpdateTimer.Elapsed += AutoUpdateTimer_Elapsed; - autoUpdateTimer.Start(); - - // timer for automatic updates - autoUpdateTimerShort = new Timer - { - AutoReset = true, Interval = 5 * 1000 // 5 seconds, later we set it to 30 seconds - }; - autoUpdateTimerShort.Elapsed += AutoUpdateTimerShort_Elapsed; - autoUpdateTimerShort.Start(); + _autoUpdateTimer.Elapsed += AutoUpdateTimer_Elapsed; + _autoUpdateTimer.Start(); ServerState.Instance.ServerStartingStatus = Resources.Server_InitializingFile; @@ -276,13 +251,13 @@ private void WorkerSetupDB_DoWork(object sender, DoWorkEventArgs e) ServerState.Instance.ServerOnline = true; _workerSetupDB.ReportProgress(100); - StartTime = DateTime.Now; + _startTime = DateTime.Now; e.Result = true; } catch (Exception ex) { - logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); ServerState.Instance.ServerStartingStatus = ex.Message; ServerState.Instance.StartupFailed = true; ServerState.Instance.StartupFailedMessage = $"Startup Failed: {ex}"; @@ -306,17 +281,17 @@ public bool InitDB(out string errorMessage) { if (instance.TestConnection()) { - logger.LogInformation("Database Connection OK!"); + _logger.LogInformation("Database Connection OK!"); break; } if (i == 59) { - logger.LogError(errorMessage = "Unable to connect to database!"); + _logger.LogError(errorMessage = "Unable to connect to database!"); return false; } - logger.LogInformation("Waiting for database connection..."); + _logger.LogInformation("Waiting for database connection..."); Thread.Sleep(1000); } @@ -329,7 +304,7 @@ public bool InitDB(out string errorMessage) _databaseFactory.CloseSessionFactory(); var message = Resources.Database_Initializing; - logger.LogInformation("Starting Server: {Message}", message); + _logger.LogInformation("Starting Server: {Message}", message); ServerState.Instance.ServerStartingStatus = message; instance.Init(); @@ -337,7 +312,7 @@ public bool InitDB(out string errorMessage) if (version > instance.RequiredVersion) { message = Resources.Database_NotSupportedVersion; - logger.LogInformation("Starting Server: {Message}", message); + _logger.LogInformation("Starting Server: {Message}", message); ServerState.Instance.ServerStartingStatus = message; errorMessage = Resources.Database_NotSupportedVersion; return false; @@ -346,17 +321,17 @@ public bool InitDB(out string errorMessage) if (version != 0 && version < instance.RequiredVersion) { message = Resources.Database_Backup; - logger.LogInformation("Starting Server: {Message}", message); + _logger.LogInformation("Starting Server: {Message}", message); ServerState.Instance.ServerStartingStatus = message; instance.BackupDatabase(instance.GetDatabaseBackupName(version)); } try { - logger.LogInformation("Starting Server: {Type} - CreateAndUpdateSchema()", instance.GetType()); + _logger.LogInformation("Starting Server: {Type} - CreateAndUpdateSchema()", instance.GetType()); instance.CreateAndUpdateSchema(); - logger.LogInformation("Starting Server: RepoFactory.Init()"); + _logger.LogInformation("Starting Server: RepoFactory.Init()"); _repoFactory.Init(); instance.ExecuteDatabaseFixes(); instance.PopulateInitialData(); @@ -364,7 +339,7 @@ public bool InitDB(out string errorMessage) } catch (DatabaseCommandException ex) { - logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, ex.ToString()); Utils.ShowErrorMessage("Database Error :\n\r " + ex + "\n\rNotify developers about this error, it will be logged in your logs", "Database Error"); @@ -375,7 +350,7 @@ public bool InitDB(out string errorMessage) } catch (TimeoutException ex) { - logger.LogError(ex, $"Database Timeout: {ex}"); + _logger.LogError(ex, $"Database Timeout: {ex}"); ServerState.Instance.ServerStartingStatus = Resources.Server_DatabaseTimeOut; errorMessage = Resources.Server_DatabaseTimeOut + "\n\r" + ex; return false; @@ -387,7 +362,7 @@ public bool InitDB(out string errorMessage) catch (Exception ex) { errorMessage = $"Could not init database: {ex}"; - logger.LogError(ex, errorMessage); + _logger.LogError(ex, errorMessage); ServerState.Instance.ServerStartingStatus = Resources.Server_DatabaseFail; return false; } @@ -396,7 +371,7 @@ public bool InitDB(out string errorMessage) #endregion #region Update all media info - + public void RefreshAllMediaInfo() { var scheduler = _schedulerFactory.GetScheduler().Result; @@ -407,9 +382,9 @@ public void RefreshAllMediaInfo() public void DownloadAllImages() { - if (!downloadImagesWorker.IsBusy) + if (!_downloadImagesWorker.IsBusy) { - downloadImagesWorker.RunWorkerAsync(); + _downloadImagesWorker.RunWorkerAsync(); } } @@ -419,15 +394,6 @@ private void DownloadImagesWorker_DoWork(object sender, DoWorkEventArgs e) actionService.RunImport_GetImages().GetAwaiter().GetResult(); } - private void AutoUpdateTimerShort_Elapsed(object sender, ElapsedEventArgs e) - { - autoUpdateTimerShort.Enabled = false; - - - autoUpdateTimerShort.Interval = 30 * 1000; // 30 seconds - autoUpdateTimerShort.Enabled = true; - } - #region Tray Minimize private void ShutDown() @@ -441,9 +407,9 @@ private void ShutDown() private static void AutoUpdateTimer_Elapsed(object sender, ElapsedEventArgs e) { var actionService = Utils.ServiceContainer.GetRequiredService<ActionService>(); + actionService.CheckForUnreadNotifications(false).GetAwaiter().GetResult(); actionService.CheckForCalendarUpdate(false).GetAwaiter().GetResult(); actionService.CheckForAnimeUpdate().GetAwaiter().GetResult(); - actionService.CheckForTvDBUpdates(false).GetAwaiter().GetResult(); actionService.CheckForMyListSyncUpdate(false).GetAwaiter().GetResult(); actionService.CheckForTraktAllSeriesUpdate(false).GetAwaiter().GetResult(); actionService.CheckForTraktTokenUpdate(false); diff --git a/Shoko.Server/Server/Startup.cs b/Shoko.Server/Server/Startup.cs index 4845aad6a..ecbe9569f 100644 --- a/Shoko.Server/Server/Startup.cs +++ b/Shoko.Server/Server/Startup.cs @@ -1,6 +1,8 @@ using System; using System.Threading.Tasks; using MessagePack; +using MessagePack.Formatters; +using MessagePack.Resolvers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -15,9 +17,9 @@ using Shoko.Server.Filters.Legacy; using Shoko.Server.Plugin; using Shoko.Server.Providers.AniDB; -using Shoko.Server.Providers.MovieDB; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Providers.TraktTV; -using Shoko.Server.Providers.TvDB; +using Shoko.Server.Renamer; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Services; @@ -35,7 +37,7 @@ public class Startup private readonly ILogger<Startup> _logger; private readonly ISettingsProvider _settingsProvider; private IWebHost _webHost; - public event EventHandler<ServerAboutToStartEventArgs> AboutToStart; + public event EventHandler<ServerAboutToStartEventArgs> AboutToStart; public Startup(ILogger<Startup> logger, ISettingsProvider settingsProvider) { @@ -48,13 +50,17 @@ private class ServerStartup { public void ConfigureServices(IServiceCollection services) { + services.AddSingleton<IRelocationService, RelocationService>(); + services.AddSingleton<RenameFileService>(); services.AddSingleton<ISettingsProvider, SettingsProvider>(); services.AddSingleton<FileWatcherService>(); services.AddSingleton<ShokoServer>(); services.AddSingleton<LogRotator>(); services.AddSingleton<TraktTVHelper>(); - services.AddSingleton<TvDBApiHelper>(); - services.AddSingleton<MovieDBHelper>(); + services.AddSingleton<TmdbImageService>(); + services.AddSingleton<TmdbLinkingService>(); + services.AddSingleton<TmdbMetadataService>(); + services.AddSingleton<TmdbSearchService>(); services.AddSingleton<FilterEvaluator>(); services.AddSingleton<LegacyFilterConverter>(); services.AddSingleton<ActionService>(); @@ -168,7 +174,7 @@ private IWebHost InitWebHost(ISettingsProvider settingsProvider) .UseSentryConfig(); var result = builder.Build(); - + Utils.SettingsProvider = result.Services.GetRequiredService<ISettingsProvider>(); Utils.ServiceContainer = result.Services; return result; diff --git a/Shoko.Server/Server/UnhandledExceptionManager.cs b/Shoko.Server/Server/UnhandledExceptionManager.cs index 8197e9940..a9ab69ea7 100644 --- a/Shoko.Server/Server/UnhandledExceptionManager.cs +++ b/Shoko.Server/Server/UnhandledExceptionManager.cs @@ -91,33 +91,13 @@ private static DateTime AssemblyFileTime(Assembly objAssembly) //-- private static DateTime AssemblyBuildDate(Assembly objAssembly, bool blnForceFileDate = false) { - var objVersion = objAssembly.GetName().Version; - DateTime dtBuild = default; - - if (blnForceFileDate) + if (!blnForceFileDate) { - dtBuild = AssemblyFileTime(objAssembly); - } - else - { - //dtBuild = ((DateTime)"01/01/2000").AddDays(objVersion.Build).AddSeconds(objVersion.Revision * 2); - dtBuild = - Convert.ToDateTime("01/01/2000") - .AddDays(objVersion.Build) - .AddSeconds(objVersion.Revision * 2); - if (TimeZone.IsDaylightSavingTime(DateTime.Now, - TimeZone.CurrentTimeZone.GetDaylightChanges(DateTime.Now.Year))) - { - dtBuild = dtBuild.AddHours(1); - } - - if ((dtBuild > DateTime.Now) | (objVersion.Build < 730) | (objVersion.Revision == 0)) - { - dtBuild = AssemblyFileTime(objAssembly); - } + var extraVersionDict = Utils.GetApplicationExtraVersion(objAssembly); + if (extraVersionDict.TryGetValue("date", out var dateText) && DateTime.TryParse(dateText, out var releaseDate)) + return releaseDate.ToLocalTime(); } - - return dtBuild; + return AssemblyFileTime(objAssembly); } //-- @@ -164,7 +144,7 @@ private static string StackFrameToString(StackFrame sf) _with1.Append(" "); if (sf.GetFileName() == null || sf.GetFileName().Length == 0) { - _with1.Append(Path.GetFileName(ParentAssembly().CodeBase)); + _with1.Append(Path.GetFileName(ParentAssembly().Location)); //-- native code offset is always available _with1.Append(": N "); _with1.Append(string.Format("{0:#00000}", sf.GetNativeOffset())); @@ -375,7 +355,7 @@ internal static string SysInfoToString(bool blnIncludeStackTrace = false) try { //_with4.Append(ParentAssembly().CodeBase()); - _with4.Append(ParentAssembly().CodeBase); + _with4.Append(ParentAssembly().Location); } catch (Exception e) { diff --git a/Shoko.Server/Services/ActionService.cs b/Shoko.Server/Services/ActionService.cs index de26dacad..cc3aa5522 100644 --- a/Shoko.Server/Services/ActionService.cs +++ b/Shoko.Server/Services/ActionService.cs @@ -10,13 +10,13 @@ using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Databases; using Shoko.Server.Extensions; using Shoko.Server.FileHelper; using Shoko.Server.Models; -using Shoko.Server.Providers.MovieDB; +using Shoko.Server.Providers.TMDB; using Shoko.Server.Providers.TraktTV; -using Shoko.Server.Providers.TvDB; using Shoko.Server.Repositories; using Shoko.Server.Repositories.Cached; using Shoko.Server.Scheduling; @@ -25,7 +25,6 @@ using Shoko.Server.Scheduling.Jobs.Shoko; using Shoko.Server.Scheduling.Jobs.TMDB; using Shoko.Server.Scheduling.Jobs.Trakt; -using Shoko.Server.Scheduling.Jobs.TvDB; using Shoko.Server.Server; using Shoko.Server.Settings; using Utils = Shoko.Server.Utilities.Utils; @@ -38,21 +37,28 @@ public class ActionService private readonly ISchedulerFactory _schedulerFactory; private readonly ISettingsProvider _settingsProvider; private readonly VideoLocal_PlaceService _placeService; - private readonly MovieDBHelper _movieDBHelper; - private readonly TvDBApiHelper _tvdbHelper; + private readonly TmdbMetadataService _tmdbService; private readonly TraktTVHelper _traktHelper; private readonly ImportFolderRepository _importFolders; private readonly DatabaseFactory _databaseFactory; - public ActionService(ILogger<ActionService> logger, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService placeService, TvDBApiHelper tvdbHelper, TraktTVHelper traktHelper, MovieDBHelper movieDBHelper, ImportFolderRepository importFolders, DatabaseFactory databaseFactory) + public ActionService( + ILogger<ActionService> logger, + ISchedulerFactory schedulerFactory, + ISettingsProvider settingsProvider, + VideoLocal_PlaceService placeService, + TraktTVHelper traktHelper, + TmdbMetadataService tmdbService, + ImportFolderRepository importFolders, + DatabaseFactory databaseFactory + ) { _logger = logger; _schedulerFactory = schedulerFactory; _settingsProvider = settingsProvider; _placeService = placeService; - _tvdbHelper = tvdbHelper; _traktHelper = traktHelper; - _movieDBHelper = movieDBHelper; + _tmdbService = tmdbService; _importFolders = importFolders; _databaseFactory = databaseFactory; } @@ -145,18 +151,18 @@ public async Task RunImport_ScanFolder(int importFolderID, bool skipMyList = fal try { - var fldr = RepoFactory.ImportFolder.GetByID(importFolderID); - if (fldr == null) return; + var folder = RepoFactory.ImportFolder.GetByID(importFolderID); + if (folder == null) return; // first build a list of files that we already know about, as we don't want to process them again - var filesAll = RepoFactory.VideoLocalPlace.GetByImportFolder(fldr.ImportFolderID); + var filesAll = RepoFactory.VideoLocalPlace.GetByImportFolder(folder.ImportFolderID); var dictFilesExisting = new Dictionary<string, SVR_VideoLocal_Place>(); foreach (var vl in filesAll.Where(a => a.FullServerPath != null)) { dictFilesExisting[vl.FullServerPath] = vl; } - Utils.GetFilesForImportFolder(fldr.BaseDirectory, ref fileList); + Utils.GetFilesForImportFolder(folder.BaseDirectory, ref fileList); // Get Ignored Files and remove them from the scan listing var ignoredFiles = RepoFactory.VideoLocal.GetIgnoredVideos().SelectMany(a => a.Places) @@ -168,8 +174,8 @@ public async Task RunImport_ScanFolder(int importFolderID, bool skipMyList = fal { i++; - if (dictFilesExisting.TryGetValue(fileName, out var value) && fldr.IsDropSource == 1) - await _placeService.RenameAndMoveAsRequired(value); + if (dictFilesExisting.TryGetValue(fileName, out var value) && folder.IsDropSource == 1) + await _placeService.AutoRelocateFile(value); if (settings.Import.Exclude.Any(s => Regex.IsMatch(fileName, s))) { @@ -196,7 +202,7 @@ await scheduler.StartJob<DiscoverFileJob>(a => } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); } } @@ -288,7 +294,7 @@ public async Task RunImport_NewFiles() } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); } } @@ -320,8 +326,8 @@ public async Task RunImport_NewFiles() if (!FileHashHelper.IsVideo(fileName)) continue; videosFound++; - var tup = _importFolders.GetFromFullPath(fileName); - ShokoEventHandler.Instance.OnFileDetected(tup.Item1, new FileInfo(fileName)); + var (folder, relativePath) = _importFolders.GetFromFullPath(fileName); + ShokoEventHandler.Instance.OnFileDetected(folder, new FileInfo(fileName)); await scheduler.StartJob<DiscoverFileJob>(a => a.FilePath = fileName); } @@ -359,195 +365,140 @@ await scheduler.StartJob<GetAniDBImagesJob>(c => }); } - // TvDB Posters - if (settings.TvDB.AutoPosters) - { - var postersCount = new Dictionary<int, int>(); - - // build a dictionary of series and how many images exist - var allPosters = RepoFactory.TvDB_ImagePoster.GetAll(); - foreach (var tvPoster in allPosters) - { - if (string.IsNullOrEmpty(tvPoster.GetFullImagePath())) continue; - var fileExists = File.Exists(tvPoster.GetFullImagePath()); - if (!fileExists) continue; - if (!postersCount.TryAdd(tvPoster.SeriesID, 1)) postersCount[tvPoster.SeriesID] += 1; - } - - foreach (var tvPoster in allPosters) - { - if (string.IsNullOrEmpty(tvPoster.GetFullImagePath())) continue; - var fileExists = File.Exists(tvPoster.GetFullImagePath()); - var postersAvailable = 0; - if (postersCount.TryGetValue(tvPoster.SeriesID, out var value)) postersAvailable = value; - - if (fileExists || postersAvailable >= settings.TvDB.AutoPostersAmount) continue; - - await scheduler.StartJob<DownloadTvDBImageJob>(c => - { - c.Anime = RepoFactory.TvDB_Series.GetByTvDBID(tvPoster.SeriesID)?.SeriesName; - c.ImageID = tvPoster.TvDB_ImagePosterID; - c.ImageType = ImageEntityType.TvDB_Cover; - } - ); - - if (!postersCount.TryAdd(tvPoster.SeriesID, 1)) postersCount[tvPoster.SeriesID] += 1; - } - } + // TMDB Images + if (settings.TMDB.AutoDownloadPosters) + await RunImport_DownloadTmdbImagesForType(_schedulerFactory, ImageEntityType.Poster, settings.TMDB.MaxAutoPosters); + if (settings.TMDB.AutoDownloadLogos) + await RunImport_DownloadTmdbImagesForType(_schedulerFactory, ImageEntityType.Logo, settings.TMDB.MaxAutoLogos); + if (settings.TMDB.AutoDownloadBackdrops) + await RunImport_DownloadTmdbImagesForType(_schedulerFactory, ImageEntityType.Backdrop, settings.TMDB.MaxAutoBackdrops); + if (settings.TMDB.AutoDownloadStaffImages) + await RunImport_DownloadTmdbImagesForType(_schedulerFactory, ImageEntityType.Person, settings.TMDB.MaxAutoStaffImages); + if (settings.TMDB.AutoDownloadThumbnails) + await RunImport_DownloadTmdbImagesForType(_schedulerFactory, ImageEntityType.Thumbnail, settings.TMDB.MaxAutoThumbnails); + } - // TvDB Fanart - if (settings.TvDB.AutoFanart) + private static async Task RunImport_DownloadTmdbImagesForType(ISchedulerFactory schedulerFactory, ImageEntityType type, int maxCount) + { + // Build a few dictionaries to check how many images exist for each type. + var countsForMovies = new Dictionary<int, int>(); + var countForEpisodes = new Dictionary<int, int>(); + var countForSeasons = new Dictionary<int, int>(); + var countForShows = new Dictionary<int, int>(); + var countForCollections = new Dictionary<int, int>(); + var countForNetworks = new Dictionary<int, int>(); + var countForCompanies = new Dictionary<int, int>(); + var countForPersons = new Dictionary<int, int>(); + var allImages = RepoFactory.TMDB_Image.GetByType(type); + foreach (var image in allImages) { - var fanartCount = new Dictionary<int, int>(); - var allFanart = RepoFactory.TvDB_ImageFanart.GetAll(); - foreach (var tvFanart in allFanart) - { - // build a dictionary of series and how many images exist - if (string.IsNullOrEmpty(tvFanart.GetFullImagePath())) continue; - var fileExists = File.Exists(tvFanart.GetFullImagePath()); - if (!fileExists) continue; - if (!fanartCount.TryAdd(tvFanart.SeriesID, 1)) fanartCount[tvFanart.SeriesID] += 1; - } - - foreach (var tvFanart in allFanart) - { - if (string.IsNullOrEmpty(tvFanart.GetFullImagePath())) continue; - var fileExists = File.Exists(tvFanart.GetFullImagePath()); - - var fanartAvailable = 0; - if (fanartCount.TryGetValue(tvFanart.SeriesID, out var value)) fanartAvailable = value; - if (fileExists || fanartAvailable >= settings.TvDB.AutoFanartAmount) continue; + var path = image.LocalPath; + if (string.IsNullOrEmpty(path)) + continue; - await scheduler.StartJob<DownloadTvDBImageJob>(c => - { - c.Anime = RepoFactory.TvDB_Series.GetByTvDBID(tvFanart.SeriesID)?.SeriesName; - c.ImageID = tvFanart.TvDB_ImageFanartID; - c.ImageType = ImageEntityType.TvDB_FanArt; - } - ); + if (!File.Exists(path)) + continue; - if (!fanartCount.TryAdd(tvFanart.SeriesID, 1)) fanartCount[tvFanart.SeriesID] += 1; - } + if (image.TmdbMovieID.HasValue) + if (countsForMovies.ContainsKey(image.TmdbMovieID.Value)) + countsForMovies[image.TmdbMovieID.Value] += 1; + else + countsForMovies[image.TmdbMovieID.Value] = 1; + if (image.TmdbEpisodeID.HasValue) + if (countForEpisodes.ContainsKey(image.TmdbEpisodeID.Value)) + countForEpisodes[image.TmdbEpisodeID.Value] += 1; + else + countForEpisodes[image.TmdbEpisodeID.Value] = 1; + if (image.TmdbSeasonID.HasValue) + if (countForSeasons.ContainsKey(image.TmdbSeasonID.Value)) + countForSeasons[image.TmdbSeasonID.Value] += 1; + else + countForSeasons[image.TmdbSeasonID.Value] = 1; + if (image.TmdbShowID.HasValue) + if (countForShows.ContainsKey(image.TmdbShowID.Value)) + countForShows[image.TmdbShowID.Value] += 1; + else + countForShows[image.TmdbShowID.Value] = 1; + if (image.TmdbCollectionID.HasValue) + if (countForCollections.ContainsKey(image.TmdbCollectionID.Value)) + countForCollections[image.TmdbCollectionID.Value] += 1; + else + countForCollections[image.TmdbCollectionID.Value] = 1; + if (image.TmdbNetworkID.HasValue) + if (countForNetworks.ContainsKey(image.TmdbNetworkID.Value)) + countForNetworks[image.TmdbNetworkID.Value] += 1; + else + countForNetworks[image.TmdbNetworkID.Value] = 1; + if (image.TmdbCompanyID.HasValue) + if (countForCompanies.ContainsKey(image.TmdbCompanyID.Value)) + countForCompanies[image.TmdbCompanyID.Value] += 1; + else + countForCompanies[image.TmdbCompanyID.Value] = 1; + if (image.TmdbPersonID.HasValue) + if (countForPersons.ContainsKey(image.TmdbPersonID.Value)) + countForPersons[image.TmdbPersonID.Value] += 1; + else + countForPersons[image.TmdbPersonID.Value] = 1; } - // TvDB Wide Banners - if (settings.TvDB.AutoWideBanners) + var scheduler = await schedulerFactory.GetScheduler(); + foreach (var image in allImages) { - var fanartCount = new Dictionary<int, int>(); + var path = image.LocalPath; + if (string.IsNullOrEmpty(path) || File.Exists(path)) + continue; - // build a dictionary of series and how many images exist - var allBanners = RepoFactory.TvDB_ImageWideBanner.GetAll(); - foreach (var tvBanner in allBanners) + // Check if we should download the image or not. + var limitEnabled = maxCount > 0; + var shouldDownload = !limitEnabled; + if (limitEnabled) { - if (string.IsNullOrEmpty(tvBanner.GetFullImagePath())) continue; - var fileExists = File.Exists(tvBanner.GetFullImagePath()); - if (!fileExists) continue; - if (!fanartCount.TryAdd(tvBanner.SeriesID, 1)) fanartCount[tvBanner.SeriesID] += 1; + if (countsForMovies.TryGetValue(image.TmdbMovieID ?? 0, out var count) && count < maxCount) + shouldDownload = true; + if (countForEpisodes.TryGetValue(image.TmdbEpisodeID ?? 0, out count) && count < maxCount) + shouldDownload = true; + if (countForSeasons.TryGetValue(image.TmdbSeasonID ?? 0, out count) && count < maxCount) + shouldDownload = true; + if (countForShows.TryGetValue(image.TmdbShowID ?? 0, out count) && count < maxCount) + shouldDownload = true; + if (countForCollections.TryGetValue(image.TmdbCollectionID ?? 0, out count) && count < maxCount) + shouldDownload = true; + if (countForNetworks.TryGetValue(image.TmdbNetworkID ?? 0, out count) && count < maxCount) + shouldDownload = true; + if (countForCompanies.TryGetValue(image.TmdbCompanyID ?? 0, out count) && count < maxCount) + shouldDownload = true; + if (countForPersons.TryGetValue(image.TmdbPersonID ?? 0, out count) && count < maxCount) + shouldDownload = true; } - foreach (var tvBanner in allBanners) + if (shouldDownload) { - if (string.IsNullOrEmpty(tvBanner.GetFullImagePath())) continue; - var fileExists = File.Exists(tvBanner.GetFullImagePath()); - var bannersAvailable = 0; - if (fanartCount.TryGetValue(tvBanner.SeriesID, out var value)) bannersAvailable = value; - if (fileExists || bannersAvailable >= settings.TvDB.AutoWideBannersAmount) continue; - - await scheduler.StartJob<DownloadTvDBImageJob>(c => - { - c.Anime = RepoFactory.TvDB_Series.GetByTvDBID(tvBanner.SeriesID)?.SeriesName; - c.ImageID = tvBanner.TvDB_ImageWideBannerID; - c.ImageType = ImageEntityType.TvDB_Banner; - } - ); - - if (!fanartCount.TryAdd(tvBanner.SeriesID, 1)) fanartCount[tvBanner.SeriesID] += 1; - } - } - - // TvDB Episodes - - foreach (var tvEpisode in RepoFactory.TvDB_Episode.GetAll()) - { - if (string.IsNullOrEmpty(tvEpisode.GetFullImagePath())) continue; - var fileExists = File.Exists(tvEpisode.GetFullImagePath()); - if (fileExists) continue; - - await scheduler.StartJob<DownloadTvDBImageJob>(c => + await scheduler.StartJob<DownloadTmdbImageJob>(c => { - c.Anime = RepoFactory.TvDB_Series.GetByTvDBID(tvEpisode.SeriesID)?.SeriesName; - c.ImageID = tvEpisode.TvDB_EpisodeID; - c.ImageType = ImageEntityType.TvDB_Episode; - } - ); - } - - // MovieDB Posters - if (settings.MovieDb.AutoPosters) - { - var postersCount = new Dictionary<int, int>(); - - // build a dictionary of series and how many images exist - var allPosters = RepoFactory.MovieDB_Poster.GetAll(); - foreach (var moviePoster in allPosters) - { - if (string.IsNullOrEmpty(moviePoster.GetFullImagePath())) continue; - var fileExists = File.Exists(moviePoster.GetFullImagePath()); - if (!fileExists) continue; - if (!postersCount.TryAdd(moviePoster.MovieId, 1)) postersCount[moviePoster.MovieId] += 1; - } - - foreach (var moviePoster in allPosters) - { - if (string.IsNullOrEmpty(moviePoster.GetFullImagePath())) continue; - var fileExists = File.Exists(moviePoster.GetFullImagePath()); - var postersAvailable = 0; - if (postersCount.TryGetValue(moviePoster.MovieId, out var value)) postersAvailable = value; - - if (fileExists || postersAvailable >= settings.MovieDb.AutoPostersAmount) continue; - - await scheduler.StartJob<DownloadTMDBImageJob>(c => - { - c.ImageID = moviePoster.MovieDB_PosterID; - c.ImageType = ImageEntityType.MovieDB_Poster; - } - ); - - if (!postersCount.TryAdd(moviePoster.MovieId, 1)) postersCount[moviePoster.MovieId] += 1; - } - } - - // MovieDB Fanart - if (settings.MovieDb.AutoFanart) - { - var fanartCount = new Dictionary<int, int>(); - - // build a dictionary of series and how many images exist - var allFanarts = RepoFactory.MovieDB_Fanart.GetAll(); - foreach (var movieFanart in allFanarts) - { - if (string.IsNullOrEmpty(movieFanart.GetFullImagePath())) continue; - var fileExists = File.Exists(movieFanart.GetFullImagePath()); - if (!fileExists) continue; - if (!fanartCount.TryAdd(movieFanart.MovieId, 1)) fanartCount[movieFanart.MovieId] += 1; - } - - foreach (var movieFanart in RepoFactory.MovieDB_Fanart.GetAll()) - { - if (string.IsNullOrEmpty(movieFanart.GetFullImagePath())) continue; - var fileExists = File.Exists(movieFanart.GetFullImagePath()); - var fanartAvailable = 0; - if (fanartCount.TryGetValue(movieFanart.MovieId, out var value)) fanartAvailable = value; - if (fileExists || fanartAvailable >= settings.MovieDb.AutoFanartAmount) continue; - - await scheduler.StartJob<DownloadTMDBImageJob>(c => - { - c.ImageID = movieFanart.MovieDB_FanartID; - c.ImageType = ImageEntityType.MovieDB_FanArt; - } - ); + c.ImageID = image.TMDB_ImageID; + c.ImageType = image.ImageType; + }); - if (!fanartCount.TryAdd(movieFanart.MovieId, 1)) fanartCount[movieFanart.MovieId] += 1; + if (image.TmdbMovieID.HasValue) + if (countsForMovies.ContainsKey(image.TmdbMovieID.Value)) + countsForMovies[image.TmdbMovieID.Value] += 1; + else + countsForMovies[image.TmdbMovieID.Value] = 1; + if (image.TmdbSeasonID.HasValue) + if (countForSeasons.ContainsKey(image.TmdbSeasonID.Value)) + countForSeasons[image.TmdbSeasonID.Value] += 1; + else + countForSeasons[image.TmdbSeasonID.Value] = 1; + if (image.TmdbShowID.HasValue) + if (countForShows.ContainsKey(image.TmdbShowID.Value)) + countForShows[image.TmdbShowID.Value] += 1; + else + countForShows[image.TmdbShowID.Value] = 1; + if (image.TmdbCollectionID.HasValue) + if (countForCollections.ContainsKey(image.TmdbCollectionID.Value)) + countForCollections[image.TmdbCollectionID.Value] += 1; + else + countForCollections[image.TmdbCollectionID.Value] = 1; } } } @@ -557,18 +508,18 @@ private static bool ShouldUpdateAniDBCreatorImages(IServerSettings settings, SVR if (!settings.AniDb.DownloadCreators) return false; foreach (var seiyuu in RepoFactory.AniDB_Character.GetCharactersForAnime(anime.AnimeID) - .SelectMany(a => RepoFactory.AniDB_Character_Seiyuu.GetByCharID(a.CharID)) - .Select(a => RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(a.SeiyuuID))) + .SelectMany(a => RepoFactory.AniDB_Character_Creator.GetByCharacterID(a.CharID)) + .Select(a => RepoFactory.AniDB_Creator.GetByCreatorID(a.CreatorID)).WhereNotNull()) { - if (string.IsNullOrEmpty(seiyuu.PicName)) continue; - if (!File.Exists(seiyuu.GetPosterPath())) return true; + if (string.IsNullOrEmpty(seiyuu.ImagePath)) continue; + if (!File.Exists(seiyuu.GetFullImagePath())) return true; } foreach (var seiyuu in RepoFactory.AniDB_Anime_Staff.GetByAnimeID(anime.AnimeID) - .Select(a => RepoFactory.AniDB_Seiyuu.GetBySeiyuuID(a.CreatorID))) + .Select(a => RepoFactory.AniDB_Creator.GetByCreatorID(a.CreatorID)).WhereNotNull()) { - if (string.IsNullOrEmpty(seiyuu.PicName)) continue; - if (!File.Exists(seiyuu.GetPosterPath())) return true; + if (string.IsNullOrEmpty(seiyuu.ImagePath)) continue; + if (!File.Exists(seiyuu.GetFullImagePath())) return true; } return false; @@ -581,17 +532,12 @@ private static bool ShouldUpdateAniDBCharacterImages(IServerSettings settings, S foreach (var chr in RepoFactory.AniDB_Character.GetCharactersForAnime(anime.AnimeID)) { if (string.IsNullOrEmpty(chr.PicName)) continue; - if (!File.Exists(chr.GetPosterPath())) return true; + if (!File.Exists(chr.GetFullImagePath())) return true; } return false; } - public async Task RunImport_ScanTvDB() - { - await _tvdbHelper.ScanForMatches(); - } - public void RunImport_ScanTrakt() { var settings = _settingsProvider.GetSettings(); @@ -599,14 +545,9 @@ public void RunImport_ScanTrakt() _traktHelper.ScanForMatches(); } - public async Task RunImport_ScanMovieDB() - { - await _movieDBHelper.ScanForMatches(); - } - - public async Task RunImport_UpdateTvDB(bool forced) + public async Task RunImport_ScanTMDB() { - await _tvdbHelper.UpdateAllInfo(forced); + await _tmdbService.ScanForMatches(); } public async Task RunImport_UpdateAllAniDB() @@ -647,7 +588,7 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) } var videoLocalsAll = RepoFactory.VideoLocal.GetAll().ToList(); - // remove empty videolocals + // remove empty video locals BaseRepository.Lock(session, videoLocalsAll, (s, vls) => { using var transaction = s.BeginTransaction(); @@ -655,7 +596,7 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) transaction.Commit(); }); - // Remove duplicate videolocals + // Remove duplicate video locals var locals = videoLocalsAll .Where(a => !string.IsNullOrWhiteSpace(a.Hash)) .GroupBy(a => a.Hash) @@ -668,8 +609,8 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) var values = locals[hash]; values.Sort(comparer); var to = values.First(); - var froms = values.Except(to).ToList(); - foreach (var places in froms.Select(from => from.Places).Where(places => places != null && places.Count != 0)) + var from = values.Except(to).ToList(); + foreach (var places in from.Select(from => from.Places).Where(places => places != null && places.Count != 0)) { BaseRepository.Lock(session, places, (s, ps) => { @@ -684,7 +625,7 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) }); } - toRemove.AddRange(froms); + toRemove.AddRange(from); } BaseRepository.Lock(session, toRemove, (s, ps) => @@ -709,7 +650,9 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) using var transaction = s.BeginTransaction(); foreach (var place in ps.Where(place => string.IsNullOrWhiteSpace(place?.FullServerPath))) { +#pragma warning disable CS0618 _logger.LogInformation("RemoveRecordsWithOrphanedImportFolder : {Filename}", v.FileName); +#pragma warning restore CS0618 seriesToUpdate.UnionWith(v.AnimeEpisodes.Select(a => a.AnimeSeries) .DistinctBy(a => a.AnimeSeriesID)); RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(s, place); @@ -726,7 +669,7 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) if (places?.Count > 0) { places = places.DistinctBy(a => a.FullServerPath).ToList(); - places = v.Places?.Except(places).ToList() ?? new List<SVR_VideoLocal_Place>(); + places = v.Places?.Except(places).ToList() ?? []; foreach (var place in places) { BaseRepository.Lock(session, place, (s, p) => @@ -741,7 +684,9 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) if (v.Places?.Count > 0) continue; // delete video local record +#pragma warning disable CS0618 _logger.LogInformation("RemoveOrphanedVideoLocal : {Filename}", v.FileName); +#pragma warning restore CS0618 seriesToUpdate.UnionWith(v.AnimeEpisodes.Select(a => a.AnimeSeries) .DistinctBy(a => a.AnimeSeriesID)); @@ -752,6 +697,9 @@ public async Task RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) var xrefs = RepoFactory.CrossRef_File_Episode.GetByHash(v.Hash); foreach (var xref in xrefs) { + if (xref.AnimeID is 0) + continue; + var ep = RepoFactory.AniDB_Episode.GetByEpisodeID(xref.EpisodeID); if (ep == null) { @@ -788,8 +736,8 @@ await scheduler.StartJob<DeleteFileFromMyListJob>(c => // Clean up failed imports var list = RepoFactory.VideoLocal.GetAll() .SelectMany(a => RepoFactory.CrossRef_File_Episode.GetByHash(a.Hash)) - .Where(a => RepoFactory.AniDB_Anime.GetByAnimeID(a.AnimeID) == null || - a.AniDBEpisode == null).ToArray(); + .Where(a => a.AniDBAnime == null || a.AniDBEpisode == null) + .ToArray(); BaseRepository.Lock(session, s => { using var transaction = s.BeginTransaction(); @@ -801,7 +749,7 @@ await scheduler.StartJob<DeleteFileFromMyListJob>(c => transaction.Commit(); }); - + // clean up orphaned video local places var placesToRemove = RepoFactory.VideoLocalPlace.GetAll().Where(a => a.VideoLocal == null).ToList(); BaseRepository.Lock(session, s => @@ -840,22 +788,20 @@ public async Task<string> DeleteImportFolder(int importFolderID, bool removeFrom var scheduler = await _schedulerFactory.GetScheduler(); await Task.WhenAll(affectedSeries.Select(a => scheduler.StartJob<RefreshAnimeStatsJob>(b => b.AnimeID = a.AniDB_ID))); - return string.Empty; } catch (Exception ex) { - _logger.LogError(ex, ex.ToString()); + _logger.LogError(ex, "{ex}", ex.ToString()); return ex.Message; } } - public void UpdateAllStats() + public async Task UpdateAllStats() { - var scheduler = _schedulerFactory.GetScheduler().GetAwaiter().GetResult(); - Task.WhenAll(RepoFactory.AnimeSeries.GetAll().Select(a => scheduler.StartJob<RefreshAnimeStatsJob>(b => b.AnimeID = a.AniDB_ID))).GetAwaiter() - .GetResult(); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + await Task.WhenAll(RepoFactory.AnimeSeries.GetAll().Select(a => scheduler.StartJob<RefreshAnimeStatsJob>(b => b.AnimeID = a.AniDB_ID))); } public async Task<int> UpdateAniDBFileData(bool missingInfo, bool outOfDate, bool dryRun) @@ -911,50 +857,59 @@ await scheduler.StartJob<GetAniDBFileJob>(c => return vidsToUpdate.Count; } - public async Task CheckForTvDBUpdates(bool forceRefresh) + public async Task CheckForUnreadNotifications(bool ignoreSchedule) { var settings = _settingsProvider.GetSettings(); - if (settings.TvDB.UpdateFrequency == ScheduledUpdateFrequency.Never && !forceRefresh) return; + if (!ignoreSchedule && settings.AniDb.Notification_UpdateFrequency == ScheduledUpdateFrequency.Never) return; - var scheduler = await _schedulerFactory.GetScheduler(); - var freqHours = Utils.GetScheduledHours(settings.TvDB.UpdateFrequency); + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBNotify); + if (schedule == null) + { + schedule = new() + { + UpdateType = (int)ScheduledUpdateType.AniDBNotify, + UpdateDetails = string.Empty + }; + } + else + { + var freqHours = Utils.GetScheduledHours(settings.AniDb.Notification_UpdateFrequency); + var tsLastRun = DateTime.Now - schedule.LastUpdate; - // update tvdb info every 12 hours + // The NOTIFY command must not be issued more than once every 20 minutes according to the AniDB UDP API documentation: + // https://wiki.anidb.net/UDP_API_Definition#NOTIFY:_Notifications + // We will use 30 minutes as a safe interval. + if (tsLastRun.TotalMinutes < 30) return; - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.TvDBInfo); - if (sched != null) - { - // if we have run this in the last 12 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; - if (tsLastRun.TotalHours < freqHours && !forceRefresh) return; + // if we have run this in the last freqHours and are not forcing it, then exit + if (!ignoreSchedule && tsLastRun.TotalHours < freqHours) return; } - var tvDBIDs = new List<int>(); - var tvDBOnline = false; - var serverTime = _tvdbHelper.IncrementalTvDBUpdate(ref tvDBIDs, ref tvDBOnline); + schedule.LastUpdate = DateTime.Now; + RepoFactory.ScheduledUpdate.Save(schedule); - if (tvDBOnline) + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJob<GetAniDBNotifyJob>(); + + // process any unhandled moved file messages + await RefreshAniDBMovedFiles(false); + } + + public async Task RefreshAniDBMovedFiles(bool force) + { + var settings = _settingsProvider.GetSettings(); + if (force || settings.AniDb.Notification_HandleMovedFiles) { - foreach (var tvid in tvDBIDs) + var messages = RepoFactory.AniDB_Message.GetUnhandledFileMoveMessages(); + if (messages.Count > 0) { - // download and update series info, episode info and episode images - // will also download fanart, posters and wide banners - await scheduler.StartJob<GetTvDBSeriesJob>(c => - { - c.TvDBSeriesID = tvid; - c.ForceRefresh = true; - } - ); + var scheduler = await _schedulerFactory.GetScheduler(); + foreach (var msg in messages) + { + await scheduler.StartJob<ProcessFileMovedMessageJob>(c => c.MessageID = msg.MessageID); + } } } - - sched ??= new ScheduledUpdate { UpdateType = (int)ScheduledUpdateType.TvDBInfo }; - - sched.LastUpdate = DateTime.Now; - sched.UpdateDetails = serverTime; - RepoFactory.ScheduledUpdate.Save(sched); - - await _tvdbHelper.ScanForMatches(); } public async Task CheckForCalendarUpdate(bool forceRefresh) @@ -968,11 +923,11 @@ public async Task CheckForCalendarUpdate(bool forceRefresh) // update the calendar every 12 hours // we will always assume that an anime was downloaded via http first - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBCalendar); - if (sched != null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBCalendar); + if (schedule != null) { // if we have run this in the last 12 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; + var tsLastRun = DateTime.Now - schedule.LastUpdate; if (tsLastRun.TotalHours < freqHours && !forceRefresh) return; } @@ -989,11 +944,11 @@ public async Task CheckForAnimeUpdate() // check for any updated anime info every 12 hours - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBUpdates); - if (sched != null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBUpdates); + if (schedule != null) { // if we have run this in the last 12 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; + var tsLastRun = DateTime.Now - schedule.LastUpdate; if (tsLastRun.TotalHours < freqHours) return; } @@ -1010,11 +965,11 @@ public async Task CheckForMyListSyncUpdate(bool forceRefresh) // update the calendar every 24 hours - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBMyListSync); - if (sched != null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBMyListSync); + if (schedule != null) { // if we have run this in the last 24 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; + var tsLastRun = DateTime.Now - schedule.LastUpdate; _logger.LogTrace("Last AniDB MyList Sync: {Time} minutes ago", tsLastRun.TotalMinutes); if (tsLastRun.TotalHours < freqHours && !forceRefresh) return; } @@ -1029,12 +984,13 @@ public async Task CheckForTraktAllSeriesUpdate(bool forceRefresh) if (settings.TraktTv.UpdateFrequency == ScheduledUpdateFrequency.Never && !forceRefresh) return; // update the calendar every xxx hours - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.TraktUpdate); - if (sched == null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.TraktUpdate); + if (schedule == null) { - sched = new ScheduledUpdate + schedule = new ScheduledUpdate { - UpdateType = (int)ScheduledUpdateType.TraktUpdate, UpdateDetails = string.Empty + UpdateType = (int)ScheduledUpdateType.TraktUpdate, + UpdateDetails = string.Empty }; } else @@ -1042,12 +998,12 @@ public async Task CheckForTraktAllSeriesUpdate(bool forceRefresh) var freqHours = Utils.GetScheduledHours(settings.TraktTv.UpdateFrequency); // if we have run this in the last xxx hours then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; + var tsLastRun = DateTime.Now - schedule.LastUpdate; if (tsLastRun.TotalHours < freqHours && !forceRefresh) return; } - sched.LastUpdate = DateTime.Now; - RepoFactory.ScheduledUpdate.Save(sched); + schedule.LastUpdate = DateTime.Now; + RepoFactory.ScheduledUpdate.Save(schedule); var scheduler = await _schedulerFactory.GetScheduler(); @@ -1078,11 +1034,11 @@ public void CheckForTraktTokenUpdate(bool forceRefresh) _traktHelper.RefreshAuthToken(); // Update the last token refresh timestamp - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.TraktToken) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.TraktToken) ?? new ScheduledUpdate { UpdateType = (int)ScheduledUpdateType.TraktToken, UpdateDetails = string.Empty }; - sched.LastUpdate = DateTime.Now; - RepoFactory.ScheduledUpdate.Save(sched); + schedule.LastUpdate = DateTime.Now; + RepoFactory.ScheduledUpdate.Save(schedule); _logger.LogInformation("Trakt token refreshed successfully. Expiry date: {Date}", expirationDate); } @@ -1096,7 +1052,7 @@ public void CheckForTraktTokenUpdate(bool forceRefresh) _logger.LogError(ex, "Error in CheckForTraktTokenUpdate: {Ex}", ex); } } - + public async Task CheckForAniDBFileUpdate(bool forceRefresh) { var settings = _settingsProvider.GetSettings(); @@ -1110,12 +1066,11 @@ public async Task CheckForAniDBFileUpdate(bool forceRefresh) // check for any updated anime info every 12 hours - var sched = - RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBFileUpdates); - if (sched != null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBFileUpdates); + if (schedule != null) { // if we have run this in the last 12 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; + var tsLastRun = DateTime.Now - schedule.LastUpdate; if (tsLastRun.TotalHours < freqHours && !forceRefresh) return; } @@ -1141,16 +1096,17 @@ await scheduler.StartJob<ProcessFileJob>(c => // now check for any files which have been manually linked and are less than 30 days old - sched ??= new ScheduledUpdate + schedule ??= new ScheduledUpdate { - UpdateType = (int)ScheduledUpdateType.AniDBFileUpdates, UpdateDetails = string.Empty + UpdateType = (int)ScheduledUpdateType.AniDBFileUpdates, + UpdateDetails = string.Empty }; - sched.LastUpdate = DateTime.Now; - RepoFactory.ScheduledUpdate.Save(sched); + schedule.LastUpdate = DateTime.Now; + RepoFactory.ScheduledUpdate.Save(schedule); } - public void CheckForPreviouslyIgnored() + public void CheckForPreviouslyIgnored() { try { @@ -1165,7 +1121,7 @@ public void CheckForPreviouslyIgnored() var resultVideoLocalsIgnored = filesIgnored.Where(s => s.Hash == vl.Hash).ToList(); - if (resultVideoLocalsIgnored.Any()) + if (resultVideoLocalsIgnored.Count != 0) { vl.IsIgnored = true; RepoFactory.VideoLocal.Save(vl, false); @@ -1178,4 +1134,38 @@ public void CheckForPreviouslyIgnored() _logger.LogError(ex, "Error in CheckForPreviouslyIgnored: {Ex}", ex); } } + + public async Task ScheduleMissingAnidbCreators() + { + if (!_settingsProvider.GetSettings().AniDb.DownloadCreators) return; + + var allCreators = RepoFactory.AniDB_Creator.GetAll(); + var allMissingCreators = RepoFactory.AnimeStaff.GetAll() + .Select(s => s.AniDBID) + .Distinct() + .Except(allCreators.Select(a => a.CreatorID)) + .ToList(); + var missingCount = allMissingCreators.Count; + allMissingCreators.AddRange( + allCreators + .Where(creator => creator.Type is Providers.AniDB.CreatorType.Unknown) + .Select(creator => creator.CreatorID) + .Distinct() + ); + var partiallyMissingCount = allMissingCreators.Count - missingCount; + + var startedAt = DateTime.Now; + _logger.LogInformation("Scheduling {Count} AniDB Creators for a refresh. (Missing={MissingCount},PartiallyMissing={PartiallyMissingCount},Total={Total})", allMissingCreators.Count, missingCount, partiallyMissingCount, allMissingCreators.Count); + var scheduler = await _schedulerFactory.GetScheduler().ConfigureAwait(false); + var progressCount = 0; + foreach (var creatorID in allMissingCreators) + { + await scheduler.StartJob<GetAniDBCreatorJob>(c => c.CreatorID = creatorID).ConfigureAwait(false); + + if (++progressCount % 10 == 0) + _logger.LogInformation("Scheduling {Count} AniDB Creators for a refresh. (Progress={Count}/{Total})", allMissingCreators.Count, progressCount, allMissingCreators.Count); + } + + _logger.LogInformation("Scheduled {Count} AniDB Creators in {TimeSpan}", allMissingCreators.Count, DateTime.Now - startedAt); + } } diff --git a/Shoko.Server/Services/AniDB_AnimeService.cs b/Shoko.Server/Services/AniDB_AnimeService.cs index ef753ab86..19955e736 100644 --- a/Shoko.Server/Services/AniDB_AnimeService.cs +++ b/Shoko.Server/Services/AniDB_AnimeService.cs @@ -106,67 +106,31 @@ public CL_AniDB_Anime GetV1Contract(SVR_AniDB_Anime anime) { if (anime == null) return null; var characters = GetCharactersContract(anime); - var movDbFanart = anime.MovieDBFanarts; - var tvDbFanart = anime.TvDBImageFanarts; - var tvDbBanners = anime.TvDBImageWideBanners; - var cl = GenerateContract(anime, characters, movDbFanart, tvDbFanart, tvDbBanners); - var defFanart = anime.DefaultFanart; - var defPoster = anime.DefaultPoster; - var defBanner = anime.DefaultWideBanner; - - cl.DefaultImageFanart = defFanart?.ToClient(); - cl.DefaultImagePoster = defPoster?.ToClient(); - cl.DefaultImageWideBanner = defBanner?.ToClient(); - - return cl; - } - - public List<CL_AniDB_Character> GetCharactersContract(SVR_AniDB_Anime anime) - { - return _characters.GetCharactersForAnime(anime.AnimeID).Select(a => a.ToClient()).ToList(); - } - - private CL_AniDB_Anime GenerateContract(SVR_AniDB_Anime anime, List<CL_AniDB_Character> characters, IList<MovieDB_Fanart> movDbFanart, - IList<TvDB_ImageFanart> tvDbFanart, IList<TvDB_ImageWideBanner> tvDbBanners) - { + var movDbFanart = anime.TmdbMovieBackdrops.Concat(anime.TmdbShowBackdrops).Select(i => i.ToClientFanart()).ToList(); var cl = anime.ToClient(); cl.FormattedTitle = anime.PreferredTitle; cl.Characters = characters; - - cl.Fanarts = new List<CL_AniDB_Anime_DefaultImage>(); - if (movDbFanart != null && movDbFanart.Any()) + cl.Banners = null; + cl.Fanarts = []; + if (movDbFanart != null && movDbFanart.Count != 0) { cl.Fanarts.AddRange(movDbFanart.Select(a => new CL_AniDB_Anime_DefaultImage { - ImageType = (int)ImageEntityType.MovieDB_FanArt, MovieFanart = a, AniDB_Anime_DefaultImageID = a.MovieDB_FanartID - })); - } - - if (tvDbFanart != null && tvDbFanart.Any()) - { - cl.Fanarts.AddRange(tvDbFanart.Select(a => new CL_AniDB_Anime_DefaultImage - { - ImageType = (int)ImageEntityType.TvDB_FanArt, TVFanart = a, AniDB_Anime_DefaultImageID = a.TvDB_ImageFanartID + ImageType = (int)CL_ImageEntityType.MovieDB_FanArt, + MovieFanart = a, + AniDB_Anime_DefaultImageID = a.MovieDB_FanartID, })); } - - cl.Banners = tvDbBanners?.Select(a => - new CL_AniDB_Anime_DefaultImage - { - ImageType = (int)ImageEntityType.TvDB_Banner, TVWideBanner = a, AniDB_Anime_DefaultImageID = a.TvDB_ImageWideBannerID - }) - .ToList(); - if (cl.Fanarts?.Count == 0) - { cl.Fanarts = null; - } - - if (cl.Banners?.Count == 0) - { - cl.Banners = null; - } - + cl.DefaultImageFanart = anime.PreferredBackdrop?.ToClient(); + cl.DefaultImagePoster = anime.PreferredPoster?.ToClient(); + cl.DefaultImageWideBanner = anime.PreferredBanner?.ToClient(); return cl; } + + public List<CL_AniDB_Character> GetCharactersContract(SVR_AniDB_Anime anime) + { + return _characters.GetCharactersForAnime(anime.AnimeID).Select(a => a.ToClient()).ToList(); + } } diff --git a/Shoko.Server/Services/AnimeEpisodeService.cs b/Shoko.Server/Services/AnimeEpisodeService.cs index 9a58c23fb..ec40a1557 100644 --- a/Shoko.Server/Services/AnimeEpisodeService.cs +++ b/Shoko.Server/Services/AnimeEpisodeService.cs @@ -1,12 +1,18 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Quartz; using Shoko.Commons.Extensions; using Shoko.Models.Client; +using Shoko.Models.Enums; +using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; using Shoko.Server.Models; using Shoko.Server.Repositories; using Shoko.Server.Repositories.Cached; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.AniDB; namespace Shoko.Server.Services; @@ -16,19 +22,37 @@ public class AnimeEpisodeService private readonly AnimeSeries_UserRepository _seriesUsers; private readonly VideoLocalRepository _videoLocals; private readonly VideoLocalService _vlService; + private readonly ISchedulerFactory _schedulerFactory; - public AnimeEpisodeService(AnimeEpisode_UserRepository episodeUsers, AnimeSeries_UserRepository seriesUsers, VideoLocalRepository videoLocals, VideoLocalService vlService) + public AnimeEpisodeService(AnimeEpisode_UserRepository episodeUsers, AnimeSeries_UserRepository seriesUsers, VideoLocalRepository videoLocals, VideoLocalService vlService, ISchedulerFactory schedulerFactory) { _epUsers = episodeUsers; _seriesUsers = seriesUsers; _videoLocals = videoLocals; _vlService = vlService; + _schedulerFactory = schedulerFactory; + } + + public async Task AddEpisodeVote(SVR_AnimeEpisode episode, decimal vote) + { + var dbVote = RepoFactory.AniDB_Vote.GetByEntityAndType(episode.AniDB_EpisodeID, AniDBVoteType.Episode) ?? + new AniDB_Vote { EntityID = episode.AniDB_EpisodeID, VoteType = (int)AniDBVoteType.Episode }; + dbVote.VoteValue = (int)Math.Floor(vote * 100); + + RepoFactory.AniDB_Vote.Save(dbVote); + + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJob<VoteAniDBEpisodeJob>(c => + { + c.EpisodeID = episode.AniDB_EpisodeID; + c.VoteValue = Convert.ToDouble(vote); + }); } public List<CL_VideoDetailed> GetV1VideoDetailedContracts(SVR_AnimeEpisode ep, int userID) { // get all the cross refs - return ep?.FileCrossRefs.Select(xref => _videoLocals.GetByHash(xref.Hash)) + return ep?.FileCrossReferences.Select(xref => _videoLocals.GetByHash(xref.Hash)) .Where(v => v != null) .Select(v => _vlService.GetV1DetailedContract(v, userID)).ToList() ?? []; } diff --git a/Shoko.Server/Services/AnimeGroupService.cs b/Shoko.Server/Services/AnimeGroupService.cs index 059e9ef23..a43014717 100644 --- a/Shoko.Server/Services/AnimeGroupService.cs +++ b/Shoko.Server/Services/AnimeGroupService.cs @@ -68,7 +68,7 @@ public void SetMainSeries(SVR_AnimeGroup group, [CanBeNull] SVR_AnimeSeries seri ? RepoFactory.AnimeSeries.GetByAnimeID(group.MainAniDBAnimeID.Value) : group.AllSeries.FirstOrDefault()); if (group.IsManuallyNamed == 0 && current != null) - group.GroupName = current!.SeriesName; + group.GroupName = current!.PreferredTitle; if (group.OverrideDescription == 0 && current != null) group.Description = current!.AniDB_Anime.Description; @@ -132,7 +132,7 @@ public void RenameAllGroups() { // Reset the name/description as needed. if (grp.IsManuallyNamed == 0) - grp.GroupName = series.SeriesName; + grp.GroupName = series.PreferredTitle; if (grp.OverrideDescription == 0) grp.Description = series.AniDB_Anime.Description; @@ -150,7 +150,7 @@ public void RenameAllGroups() /// </summary> public void UpdateStatsFromTopLevel(SVR_AnimeGroup group, bool watchedStats, bool missingEpsStats) { - if (group?.AnimeGroupParentID == null) + if (group is not { AnimeGroupParentID: null }) { return; } @@ -182,11 +182,11 @@ private void UpdateStats(SVR_AnimeGroup group, bool watchedStats, bool missingEp var seriesList = group.AllSeries; // Reset the name/description for the group if needed. - var mainSeries = group.IsManuallyNamed == 0 || group.OverrideDescription == 0 ? group.MainSeries ?? group.AllSeries.FirstOrDefault() : null; + var mainSeries = group.IsManuallyNamed == 0 || group.OverrideDescription == 0 ? group.MainSeries ?? seriesList.FirstOrDefault() : null; if (mainSeries is not null) { if (group.IsManuallyNamed == 0) - group.GroupName = mainSeries.SeriesName; + group.GroupName = mainSeries.PreferredTitle; if (group.OverrideDescription == 0) group.Description = mainSeries.AniDB_Anime.Description; } @@ -208,6 +208,7 @@ private void UpdateStats(SVR_AnimeGroup group, bool watchedStats, bool missingEp }); } + group.DateTimeUpdated = DateTime.Now; _groups.Save(group, false); _logger.LogTrace($"Finished Updating STATS for GROUP {group.GroupName} in {(DateTime.Now - start).TotalMilliseconds}ms"); } @@ -291,7 +292,8 @@ private void UpdateWatchedStats(SVR_AnimeGroup animeGroup, { userRecord = new AnimeGroup_User { - JMMUserID = juser.JMMUserID, AnimeGroupID = animeGroup.AnimeGroupID + JMMUserID = juser.JMMUserID, + AnimeGroupID = animeGroup.AnimeGroupID }; isNewRecord = true; } @@ -405,19 +407,22 @@ public CL_AnimeGroup_User GetContract(SVR_AnimeGroup animeGroup) var hasFinishedAiring = false; var isCurrentlyAiring = false; var videoQualityEpisodes = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - var tvDbXrefByAnime = RepoFactory.CrossRef_AniDB_TvDB.GetByAnimeIDs(allIDs); var traktXrefByAnime = RepoFactory.CrossRef_AniDB_TraktV2.GetByAnimeIDs(allIDs); var allVidQualByGroup = allSeriesForGroup.SelectMany(a => _fileEpisodes.GetByAnimeID(a.AniDB_ID)).Select(a => _files.GetByHash(a.Hash)?.File_Source) .WhereNotNull().ToHashSet(StringComparer.InvariantCultureIgnoreCase); - var movieDbXRefByAnime = allIDs.Select(a => RepoFactory.CrossRef_AniDB_Other.GetByAnimeIDAndType(a, CrossRefType.MovieDB)).WhereNotNull() - .ToDictionary(a => a.AnimeID); + var tmdbShowXrefByAnime = allIDs + .Select(RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID) + .Where(a => a is { Count: > 0 }) + .ToDictionary(a => a[0].AnidbAnimeID); + var tmdbMovieXrefByAnime = allIDs + .Select(RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID) + .Where(a => a is { Count: > 0 }) + .ToDictionary(a => a[0].AnidbAnimeID); var malXRefByAnime = allIDs.SelectMany(a => RepoFactory.CrossRef_AniDB_MAL.GetByAnimeID(a)).ToLookup(a => a.AnimeID); // Even though the contract value says 'has link', it's easier to think about whether it's missing - var missingTvDBLink = false; var missingTraktLink = false; var missingMALLink = false; - var missingMovieDBLink = false; - var missingTvDBAndMovieDBLink = false; + var missingTMDBLink = false; var seriesCount = 0; var epCount = 0; @@ -434,7 +439,7 @@ public CL_AnimeGroup_User GetContract(SVR_AnimeGroup animeGroup) var dictVids = new Dictionary<string, SVR_VideoLocal>(); foreach (var vid in vidsTemp) - // Hashes may be repeated from multiple locations, but we don't care + // Hashes may be repeated from multiple locations, but we don't care { dictVids[vid.Hash] = vid; } @@ -562,30 +567,22 @@ public CL_AnimeGroup_User GetContract(SVR_AnimeGroup animeGroup) seriesCreatedDate = createdDate; } - // For the group, if any of the series don't have a tvdb link - // we will consider the group as not having a tvdb link - var foundTvDBLink = tvDbXrefByAnime[anime.AnimeID].Any(); + // For the group, if any of the series don't have a tmdb link + // we will consider the group as not having a tmdb link var foundTraktLink = traktXrefByAnime[anime.AnimeID].Any(); - var foundMovieDBLink = movieDbXRefByAnime.TryGetValue(anime.AnimeID, out var movieDbLink) && movieDbLink != null; + var foundTMDBShowLink = tmdbShowXrefByAnime.TryGetValue(anime.AnimeID, out var _); + var foundTMDBMovieLink = tmdbMovieXrefByAnime.TryGetValue(anime.AnimeID, out var _); var isMovie = anime.AnimeType == (int)AnimeType.Movie; - if (!foundTvDBLink) - { - if (!isMovie && !(anime.Restricted > 0)) - { - missingTvDBLink = true; - } - } - if (!foundTraktLink) { missingTraktLink = true; } - if (!foundMovieDBLink) + if (!foundTMDBShowLink && !foundTMDBMovieLink) { - if (isMovie && !(anime.Restricted > 0)) + if (!series.IsTMDBAutoMatchingDisabled) { - missingMovieDBLink = true; + missingTMDBLink = true; } } @@ -594,33 +591,31 @@ public CL_AnimeGroup_User GetContract(SVR_AnimeGroup animeGroup) missingMALLink = true; } - missingTvDBAndMovieDBLink |= !(anime.Restricted > 0) && !foundTvDBLink && !foundMovieDBLink; - - var endyear = anime.EndYear; - if (endyear == 0) + var endYear = anime.EndYear; + if (endYear == 0) { - endyear = DateTime.Today.Year; + endYear = DateTime.Today.Year; } - var startyear = anime.BeginYear; - if (endyear < startyear) + var startYear = anime.BeginYear; + if (endYear < startYear) { - endyear = startyear; + endYear = startYear; } - if (startyear != 0) + if (startYear != 0) { List<int> years; - if (startyear == endyear) + if (startYear == endYear) { years = new List<int> { - startyear + startYear }; } else { - years = Enumerable.Range(anime.BeginYear, endyear - anime.BeginYear + 1) + years = Enumerable.Range(anime.BeginYear, endYear - anime.BeginYear + 1) .Where(anime.IsInYear).ToList(); } @@ -639,11 +634,11 @@ public CL_AnimeGroup_User GetContract(SVR_AnimeGroup animeGroup) contract.Stat_IsComplete = isComplete; contract.Stat_HasFinishedAiring = hasFinishedAiring; contract.Stat_IsCurrentlyAiring = isCurrentlyAiring; - contract.Stat_HasTvDBLink = !missingTvDBLink; // Has a link if it isn't missing + contract.Stat_HasTvDBLink = false; // Deprecated contract.Stat_HasTraktLink = !missingTraktLink; // Has a link if it isn't missing contract.Stat_HasMALLink = !missingMALLink; // Has a link if it isn't missing - contract.Stat_HasMovieDBLink = !missingMovieDBLink; // Has a link if it isn't missing - contract.Stat_HasMovieDBOrTvDBLink = !missingTvDBAndMovieDBLink; // Has a link if it isn't missing + contract.Stat_HasMovieDBLink = !missingTMDBLink; // Has a link if it isn't missing + contract.Stat_HasMovieDBOrTvDBLink = !missingTMDBLink; // Has a link if it isn't missing contract.Stat_SeriesCount = seriesCount; contract.Stat_EpisodeCount = epCount; contract.Stat_AllVideoQuality_Episodes = videoQualityEpisodes; diff --git a/Shoko.Server/Services/AnimeSeriesService.cs b/Shoko.Server/Services/AnimeSeriesService.cs index cb9a7b451..c1265efa1 100644 --- a/Shoko.Server/Services/AnimeSeriesService.cs +++ b/Shoko.Server/Services/AnimeSeriesService.cs @@ -20,6 +20,7 @@ using Shoko.Server.Repositories.Cached; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Actions; +using Shoko.Server.Scheduling.Jobs.AniDB; using Shoko.Server.Scheduling.Jobs.Shoko; using Shoko.Server.Utilities; using AnimeType = Shoko.Models.Enums.AnimeType; @@ -36,29 +37,88 @@ public class AnimeSeriesService private readonly AniDB_AnimeService _animeService; private readonly AnimeGroupService _groupService; private readonly ISchedulerFactory _schedulerFactory; + private readonly JobFactory _jobFactory; - public AnimeSeriesService(ILogger<AnimeSeriesService> logger, AnimeSeries_UserRepository seriesUsers, ISchedulerFactory schedulerFactory, AniDB_AnimeService animeService, AnimeGroupService groupService, VideoLocal_UserRepository vlUsers) + public AnimeSeriesService(ILogger<AnimeSeriesService> logger, AnimeSeries_UserRepository seriesUsers, ISchedulerFactory schedulerFactory, JobFactory jobFactory, AniDB_AnimeService animeService, AnimeGroupService groupService, VideoLocal_UserRepository vlUsers) { _logger = logger; _seriesUsers = seriesUsers; _schedulerFactory = schedulerFactory; + _jobFactory = jobFactory; _animeService = animeService; _groupService = groupService; _vlUsers = vlUsers; } - public async Task<bool> CreateAnimeEpisodes(SVR_AnimeSeries series) + public async Task AddSeriesVote(SVR_AnimeSeries series, AniDBVoteType voteType, decimal vote) + { + var dbVote = (RepoFactory.AniDB_Vote.GetByEntityAndType(series.AniDB_ID, AniDBVoteType.AnimeTemp) ?? + RepoFactory.AniDB_Vote.GetByEntityAndType(series.AniDB_ID, AniDBVoteType.Anime)) ?? + new AniDB_Vote { EntityID = series.AniDB_ID }; + dbVote.VoteValue = (int)Math.Floor(vote * 100); + dbVote.VoteType = (int)voteType; + + RepoFactory.AniDB_Vote.Save(dbVote); + + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJob<VoteAniDBAnimeJob>(c => + { + c.AnimeID = series.AniDB_ID; + c.VoteType = voteType; + c.VoteValue = Convert.ToDouble(vote); + } + ); + } + + public async Task<bool> QueueAniDBRefresh(int animeID, bool force, bool downloadRelations, bool createSeriesEntry, bool immediate = false, + bool cacheOnly = false) + { + if (animeID == 0) return false; + if (immediate) + { + var job = _jobFactory.CreateJob<GetAniDBAnimeJob>(c => + { + c.AnimeID = animeID; + c.DownloadRelations = downloadRelations; + c.ForceRefresh = force; + c.CacheOnly = !force && cacheOnly; + c.CreateSeriesEntry = createSeriesEntry; + }); + + try + { + return await job.Process() != null; + } + catch + { + return false; + } + } + + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.StartJob<GetAniDBAnimeJob>(c => + { + c.AnimeID = animeID; + c.DownloadRelations = downloadRelations; + c.ForceRefresh = force; + c.CacheOnly = !force && cacheOnly; + c.CreateSeriesEntry = createSeriesEntry; + }); + return false; + } + + public async Task<(bool, Dictionary<SVR_AnimeEpisode, UpdateReason>)> CreateAnimeEpisodes(SVR_AnimeSeries series) { var anime = series.AniDB_Anime; if (anime == null) - return false; + return (false, []); var anidbEpisodes = anime.AniDBEpisodes; // Cleanup deleted episodes var epsToRemove = RepoFactory.AnimeEpisode.GetBySeriesID(series.AnimeSeriesID) .Where(a => a.AniDB_Episode is null) .ToList(); var filesToUpdate = epsToRemove - .SelectMany(a => a.FileCrossRefs) + .SelectMany(a => a.FileCrossReferences) .ToList(); var vlIDsToUpdate = filesToUpdate .Select(a => a.VideoLocal?.VideoLocalID) @@ -107,13 +167,14 @@ public async Task<bool> CreateAnimeEpisodes(SVR_AnimeSeries series) RepoFactory.AnimeEpisode.Delete(epsToRemove); - // Emit shoko episode updated events. - foreach (var (episode, reason) in episodeDict) - ShokoEventHandler.Instance.OnEpisodeUpdated(series, episode, reason); + // Add removed episodes to the dictionary. foreach (var episode in epsToRemove) - ShokoEventHandler.Instance.OnEpisodeUpdated(series, episode, UpdateReason.Removed); + episodeDict.Add(episode, UpdateReason.Removed); - return episodeDict.ContainsValue(UpdateReason.Added) || epsToRemove.Count > 0; + return ( + episodeDict.ContainsValue(UpdateReason.Added) || epsToRemove.Count > 0, + episodeDict + ); } private (SVR_AnimeEpisode episode, bool isNew, bool isUpdated) CreateAnimeEpisode(SVR_AniDB_Episode episode, int animeSeriesID) @@ -191,41 +252,16 @@ public CL_AnimeSeries_User GetV1UserContract(SVR_AnimeSeries series, int userid) SeriesNameOverride = series.SeriesNameOverride, DefaultFolder = series.DefaultFolder, AniDBAnime = _animeService.GetV1DetailedContract(series.AniDB_Anime), + CrossRefAniDBTvDBV2 = [], + TvDB_Series = [], }; - - var tvDBCrossRefs = series.TvDBXrefs; - var movieDBCrossRef = series.CrossRefMovieDB; - MovieDB_Movie movie = null; - if (movieDBCrossRef != null) - { - movie = movieDBCrossRef.GetMovieDB_Movie(); - } - - var sers = new List<TvDB_Series>(); - foreach (var xref in tvDBCrossRefs) + if (series.TmdbMovieCrossReferences is { Count: > 0 } tmdbMovieXrefs) { - var tvser = xref.GetTvDBSeries(); - if (tvser != null) - { - sers.Add(tvser); - } - else - { - _logger.LogWarning("You are missing database information for TvDB series: {0}", xref.TvDBID); - } + contract.CrossRefAniDBMovieDB = tmdbMovieXrefs[0].ToClient(); + contract.MovieDB_Movie = tmdbMovieXrefs[0].TmdbMovie?.ToClient(); } - contract.CrossRefAniDBTvDBV2 = RepoFactory.CrossRef_AniDB_TvDB.GetV2LinksFromAnime(series.AniDB_ID); - - contract.TvDB_Series = sers; - contract.CrossRefAniDBMovieDB = null; - if (movieDBCrossRef != null) - { - contract.CrossRefAniDBMovieDB = movieDBCrossRef; - contract.MovieDB_Movie = movie; - } - - contract.CrossRefAniDBMAL = series.CrossRefMAL?.ToList() ?? new List<CrossRef_AniDB_MAL>(); + contract.CrossRefAniDBMAL = series.MALCrossReferences?.ToList() ?? new List<CrossRef_AniDB_MAL>(); try { @@ -238,13 +274,13 @@ public CL_AnimeSeries_User GetV1UserContract(SVR_AnimeSeries series, int userid) contract.PlayedCount = rr.PlayedCount; contract.WatchedCount = rr.WatchedCount; contract.StoppedCount = rr.StoppedCount; - contract.AniDBAnime.AniDBAnime.FormattedTitle = series.SeriesName; + contract.AniDBAnime.AniDBAnime.FormattedTitle = series.PreferredTitle; return contract; } if (contract.AniDBAnime?.AniDBAnime != null) { - contract.AniDBAnime.AniDBAnime.FormattedTitle = series.SeriesName; + contract.AniDBAnime.AniDBAnime.FormattedTitle = series.PreferredTitle; } return contract; @@ -316,7 +352,7 @@ public void MoveSeries(SVR_AnimeSeries series, SVR_AnimeGroup newGroup, bool upd RepoFactory.AnimeGroup.Save(oldGroup); } - // Update the top group + // Update the top group var topGroup = oldGroup.TopLevelAnimeGroup; if (topGroup.AnimeGroupID != oldGroup.AnimeGroupID) { @@ -347,7 +383,7 @@ public void UpdateStats(SVR_AnimeSeries series, bool watchedStats, bool missingE watchedStats, missingEpsStats); var startEps = DateTime.Now; - var eps = series.AllAnimeEpisodes.Where(a => a.AniDB_Episode != null).ToList(); + var eps = series.AllAnimeEpisodes.Where(a => a.AniDB_Episode is not null).ToList(); var tsEps = DateTime.Now - startEps; _logger.LogTrace("Got episodes for SERIES {Name} in {Elapsed}ms", name, tsEps.TotalMilliseconds); @@ -514,7 +550,7 @@ private void UpdateMissingEpisodeStats(SVR_AnimeSeries series, List<SVR_AnimeEpi return aniFiles.Select(b => b.GroupID); } - ).ToList(); + ).Distinct().ToList(); var videoLocals = eps.Where(a => a.EpisodeTypeEnum == EpisodeType.Episode).SelectMany(a => a.VideoLocals.Select(b => new @@ -527,10 +563,41 @@ private void UpdateMissingEpisodeStats(SVR_AnimeSeries series, List<SVR_AnimeEpi // This was always Episodes only. Maybe in the future, we'll have a reliable way to check specials. eps.AsParallel().Where(a => a.EpisodeTypeEnum == EpisodeType.Episode).ForAll(ep => { - var vids = videoLocals[ep.AniDB_EpisodeID].ToList(); - var aniEp = ep.AniDB_Episode; + // Un-aired episodes should not be included in the stats. + if (aniEp is not { HasAired: true }) return; + var thisEpNum = aniEp.EpisodeNumber; + // does this episode have a file released + var epReleased = false; + // does this episode have a file released by the group the user is collecting + var epReleasedGroup = false; + + if (grpStatuses.Count == 0) + { + // If there are no group statuses, the UDP command has not been run yet or has failed + // The current has aired, as un-aired episodes are filtered out above + epReleased = true; + // We do not set epReleasedGroup here because we have no way to know + } + else + { + // Get all groups which have their status set to complete or finished or have released this episode + var filteredGroups = grpStatuses + .Where( + a => a.CompletionState is (int)Group_CompletionStatus.Complete or (int)Group_CompletionStatus.Finished + || a.HasGroupReleasedEpisode(thisEpNum)) + .ToList(); + // Episode is released if any of the groups have released it + epReleased = filteredGroups.Count > 0; + // Episode is released by one of the groups user is collecting if one of the userReleaseGroups is included in filteredGroups + epReleasedGroup = filteredGroups.Any(a => userReleaseGroups.Contains(a.GroupID)); + } + + // If epReleased is false, then we consider the episode to be not released, even if it has aired, as no group has released it + if (!epReleased) return; + + var vids = videoLocals[ep.AniDB_EpisodeID].ToList(); if (thisEpNum > latestLocalEpNumber && vids.Any()) { @@ -540,7 +607,7 @@ private void UpdateMissingEpisodeStats(SVR_AnimeSeries series, List<SVR_AnimeEpi var airdate = ep.AniDB_Episode.GetAirDateAsDate(); // If episode air date is unknown, air date of the anime is used instead - airdate ??= ep.AniDB_Episode.AniDB_Anime.AirDate; + airdate ??= series.AniDB_Anime?.AirDate; // Only count episodes that have already aired // airdate could, in theory, only be null here if AniDB neither has information on the episode @@ -563,7 +630,7 @@ private void UpdateMissingEpisodeStats(SVR_AnimeSeries series, List<SVR_AnimeEpi lock (daysofweekcounter) { daysofweekcounter.TryAdd(airdateLocal.DayOfWeek, 0); - daysofweekcounter[airdateLocal.DayOfWeek]++; + daysofweekcounter[airdateLocal.DayOfWeek]++; } if (lastEpAirDate == null || lastEpAirDate < airdate) @@ -572,36 +639,19 @@ private void UpdateMissingEpisodeStats(SVR_AnimeSeries series, List<SVR_AnimeEpi } } - // does this episode have a file released - // does this episode have a file released by the group the user is collecting - var epReleased = false; - var epReleasedGroup = false; - foreach (var gs in grpStatuses) - { - // if it's complete, then assume the episode is included - if (gs.CompletionState is (int)Group_CompletionStatus.Complete or (int)Group_CompletionStatus.Finished) - { - epReleased = true; - if (userReleaseGroups.Contains(gs.GroupID)) epReleasedGroup = true; - continue; - } - - if (!gs.HasGroupReleasedEpisode(thisEpNum)) continue; - - epReleased = true; - if (userReleaseGroups.Contains(gs.GroupID)) epReleasedGroup = true; - } - try { lock (epReleasedList) { - epReleasedList.Add(ep, !epReleased || vids.Any()); + epReleasedList.Add(ep, vids.Count > 0); } + // Skip adding to epGroupReleasedList if the episode has not been released by one of the groups user is collecting + if (!epReleasedGroup) return; + lock (epGroupReleasedList) { - epGroupReleasedList.Add(ep, !epReleasedGroup || vids.Any()); + epGroupReleasedList.Add(ep, vids.Count > 0); } } catch (Exception e) @@ -766,13 +816,13 @@ public async Task DeleteSeries(SVR_AnimeSeries series, bool deleteFiles, bool up if (completelyRemove) { // episodes, anime, characters, images, staff relations, tag relations, titles - var images = RepoFactory.AniDB_Anime_DefaultImage.GetByAnimeID(series.AniDB_ID); - RepoFactory.AniDB_Anime_DefaultImage.Delete(images); + var images = RepoFactory.AniDB_Anime_PreferredImage.GetByAnimeID(series.AniDB_ID); + RepoFactory.AniDB_Anime_PreferredImage.Delete(images); var characterXrefs = RepoFactory.AniDB_Anime_Character.GetByAnimeID(series.AniDB_ID); var characters = characterXrefs.Select(a => RepoFactory.AniDB_Character.GetByCharID(a.CharID)).ToList(); - var seiyuuXrefs = characters.SelectMany(a => RepoFactory.AniDB_Character_Seiyuu.GetByCharID(a.CharID)).ToList(); - RepoFactory.AniDB_Character_Seiyuu.Delete(seiyuuXrefs); + var seiyuuXrefs = characters.SelectMany(a => RepoFactory.AniDB_Character_Creator.GetByCharacterID(a.CharID)).ToList(); + RepoFactory.AniDB_Character_Creator.Delete(seiyuuXrefs); RepoFactory.AniDB_Character.Delete(characters); RepoFactory.AniDB_Anime_Character.Delete(characterXrefs); @@ -796,67 +846,94 @@ public async Task DeleteSeries(SVR_AnimeSeries series, bool deleteFiles, bool up } /// <summary> - /// Get the most recent activly watched episode for the user. + /// Get the most recent actively watched episode for the user. /// </summary> /// <param name="series"></param> /// <param name="userID">User ID</param> /// <param name="includeSpecials">Include specials when searching.</param> + /// <param name="includeOthers">Include other type episodes when searching.</param> /// <returns></returns> - public SVR_AnimeEpisode GetActiveEpisode(SVR_AnimeSeries series, int userID, bool includeSpecials = true) + public SVR_AnimeEpisode GetActiveEpisode(SVR_AnimeSeries series, int userID, bool includeSpecials = true, bool includeOthers = false) { // Filter the episodes to only normal or special episodes and order them in rising order. - var episodes = series.AllAnimeEpisodes + var order = includeOthers ? new List<EpisodeType> { EpisodeType.Episode, EpisodeType.Other, EpisodeType.Special } : null; + var episodes = series.AnimeEpisodes .Select(e => (episode: e, e.AniDB_Episode)) - .Where(tuple => !tuple.episode.IsHidden && (tuple.AniDB_Episode.EpisodeType == (int)EpisodeType.Episode || - (includeSpecials && tuple.AniDB_Episode.EpisodeType == (int)EpisodeType.Special))) - .OrderBy(tuple => tuple.AniDB_Episode.EpisodeType) + .Where(tuple => tuple.AniDB_Episode.EpisodeTypeEnum is EpisodeType.Episode || (includeSpecials && tuple.AniDB_Episode.EpisodeTypeEnum is EpisodeType.Special) || (includeOthers && tuple.AniDB_Episode.EpisodeTypeEnum is EpisodeType.Other)) + .OrderBy(tuple => order?.IndexOf(tuple.AniDB_Episode.EpisodeTypeEnum) ?? tuple.AniDB_Episode.EpisodeType) .ThenBy(tuple => tuple.AniDB_Episode.EpisodeNumber) .Select(tuple => tuple.episode) .ToList(); // Look for active watch sessions and return the episode for the most recent session if found. var (episode, _) = episodes .SelectMany(episode => episode.VideoLocals.Select(file => (episode, _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) - .Where(tuple => tuple.Item2 != null) + .Where(tuple => tuple.Item2 is not null) .OrderByDescending(tuple => tuple.Item2.LastUpdated) .FirstOrDefault(tuple => tuple.Item2.ResumePosition > 0); return episode; } + #region Next-up Episode(s) +#nullable enable + /// <summary> - /// Series next-up query options for use with <see cref="GetNextEpisode"/>. + /// Series next-up query options for use with <see cref="GetNextUpEpisode"/>. /// </summary> - public class NextUpQueryOptions + public class NextUpQuerySingleOptions : NextUpQueryOptions { /// <summary> /// Disable the first episode in the series from showing up. /// /// </summary> - public bool DisableFirstEpisode = false; + public bool DisableFirstEpisode { get; set; } = false; + public NextUpQuerySingleOptions() { } + + public NextUpQuerySingleOptions(NextUpQueryOptions options) + { + IncludeCurrentlyWatching = options.IncludeCurrentlyWatching; + IncludeMissing = options.IncludeMissing; + IncludeUnaired = options.IncludeUnaired; + IncludeRewatching = options.IncludeRewatching; + IncludeSpecials = options.IncludeSpecials; + IncludeOthers = options.IncludeOthers; + } + } + + /// <summary> + /// Series next-up query options for use with <see cref="GetNextUpEpisode"/>. + /// </summary> + public class NextUpQueryOptions + { /// <summary> /// Include currently watching episodes in the search. /// </summary> - public bool IncludeCurrentlyWatching = false; + public bool IncludeCurrentlyWatching { get; set; } = false; /// <summary> - /// Include hidden episodes in the search. + /// Include missing episodes in the search. /// </summary> - public bool IncludeHidden = false; + public bool IncludeMissing { get; set; } = false; /// <summary> - /// Include missing episodes in the search. + /// Include unaired episodes in the search. /// </summary> - public bool IncludeMissing = false; + public bool IncludeUnaired { get; set; } = false; /// <summary> /// Include already watched episodes in the search if we determine the /// user is "re-watching" the series. /// </summary> - public bool IncludeRewatching = false; + public bool IncludeRewatching { get; set; } = false; /// <summary> /// Include specials in the search. /// </summary> - public bool IncludeSpecials = true; + public bool IncludeSpecials { get; set; } = true; + + /// <summary> + /// Include other type episodes in the search. + /// </summary> + public bool IncludeOthers { get; set; } = false; } /// <summary> @@ -866,83 +943,70 @@ public class NextUpQueryOptions /// <param name="userID">User ID</param> /// <param name="options">Next-up query options.</param> /// <returns></returns> - public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextUpQueryOptions options = null) + public SVR_AnimeEpisode? GetNextUpEpisode(SVR_AnimeSeries series, int userID, NextUpQuerySingleOptions options) { - // Initialise the options if they're not provided. - options ??= new NextUpQueryOptions(); - - // Filter the episodes to only normal or special episodes and order them - // in rising order. Also count the number of episodes and specials if - // we're searching for the next episode for "re-watching" sessions. - var episodesCount = 0; - var speicalsCount = 0; - var episodeList = (options.IncludeHidden ? series.AllAnimeEpisodes : series.AnimeEpisodes) - .Select(episode => (episode, episode.AniDB_Episode)) + var episodeList = series.AnimeEpisodes + .Select(shoko => (shoko, anidb: shoko.AniDB_Episode!)) .Where(tuple => - { - if (tuple.episode.IsHidden) - { - return false; - } - - if (tuple.AniDB_Episode.EpisodeType == (int)EpisodeType.Episode) - { - episodesCount++; - return true; - } - - if (options.IncludeSpecials && tuple.AniDB_Episode.EpisodeType == (int)EpisodeType.Special) - { - speicalsCount++; - return true; - } - - return false; - }).ToList(); + tuple.anidb is not null && ( + (tuple.anidb.EpisodeTypeEnum is EpisodeType.Episode) || + (options.IncludeSpecials && tuple.anidb.EpisodeTypeEnum is EpisodeType.Special) || + (options.IncludeOthers && tuple.anidb.EpisodeTypeEnum is EpisodeType.Other) + ) + ) + .ToList(); // Look for active watch sessions and return the episode for the most // recent session if found. if (options.IncludeCurrentlyWatching) { var (currentlyWatchingEpisode, _) = episodeList - .SelectMany(tuple => tuple.episode.VideoLocals.Select(file => (tuple.episode, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) - .Where(tuple => tuple.fileUR != null) + .SelectMany(tuple => tuple.shoko.VideoLocals.Select(file => (tuple.shoko, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) + .Where(tuple => tuple.fileUR is not null) .OrderByDescending(tuple => tuple.fileUR.LastUpdated) .FirstOrDefault(tuple => tuple.fileUR.ResumePosition > 0); - if (currentlyWatchingEpisode != null) - { + if (currentlyWatchingEpisode is not null) return currentlyWatchingEpisode; - } } // Skip check if there is an active watch session for the series, and we // don't allow active watch sessions. - else if (episodeList.Any(tuple => tuple.episode.VideoLocals.Any(file => (_vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)?.ResumePosition ?? 0) > 0))) + else if (episodeList.Any(tuple => tuple.shoko.VideoLocals.Any(file => (_vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)?.ResumePosition ?? 0) > 0))) { return null; } + // If we're listing out other type episodes, then they should be listed + // before specials, so order them now. + if (options.IncludeOthers) + { + var order = new List<EpisodeType>() { EpisodeType.Episode, EpisodeType.Other, EpisodeType.Special }; + episodeList = episodeList + .OrderBy(tuple => order.IndexOf(tuple.anidb.EpisodeTypeEnum)) + .ThenBy(tuple => tuple.anidb.EpisodeNumber) + .ToList(); + } + // When "re-watching" we look for the next episode after the last // watched episode. if (options.IncludeRewatching) { var (lastWatchedEpisode, _) = episodeList - .SelectMany(tuple => tuple.episode.VideoLocals.Select(file => (tuple.episode, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) + .SelectMany(tuple => tuple.shoko.VideoLocals.Select(file => (tuple.shoko, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) .Where(tuple => tuple.fileUR is { WatchedDate: not null }) .OrderByDescending(tuple => tuple.fileUR.LastUpdated) .FirstOrDefault(); - if (lastWatchedEpisode != null) + if (lastWatchedEpisode is not null) { - // Return `null` if we're on the last episode in the list, or - // if we're on the last normal episode and there is no specials - // after it. - var nextIndex = episodeList.FindIndex(tuple => Equals(tuple.episode, lastWatchedEpisode)) + 1; - if ((nextIndex == episodeList.Count) || (nextIndex == episodesCount) && (!options.IncludeSpecials || speicalsCount == 0)) + // Return `null` if we're on the last episode in the list. + var nextIndex = episodeList.FindIndex(tuple => tuple.shoko.AnimeEpisodeID == lastWatchedEpisode.AnimeEpisodeID) + 1; + if (nextIndex == episodeList.Count) return null; - var (nextEpisode, _) = episodeList.Skip(nextIndex) - .FirstOrDefault(options.IncludeMissing ? _ => true : tuple => tuple.episode.VideoLocals.Count > 0); + var (nextEpisode, _) = episodeList + .Skip(nextIndex) + .FirstOrDefault(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.anidb.HasAired : tuple => tuple.shoko.VideoLocals.Count > 0 && tuple.anidb.HasAired); return nextEpisode; } } @@ -951,23 +1015,54 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU var (unwatchedEpisode, anidbEpisode) = episodeList .Where(tuple => { - var episodeUserRecord = tuple.episode.GetUserRecord(userID); - if (episodeUserRecord == null) - { + var episodeUserRecord = tuple.shoko.GetUserRecord(userID); + if (episodeUserRecord is null) return true; - } return !episodeUserRecord.WatchedDate.HasValue; }) - .FirstOrDefault(options.IncludeMissing ? _ => true : tuple => tuple.episode.VideoLocals.Count > 0); + .FirstOrDefault(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.anidb.HasAired : tuple => tuple.shoko.VideoLocals.Count > 0 && tuple.anidb.HasAired); // Disable first episode from showing up in the search. - if (options.DisableFirstEpisode && anidbEpisode != null && anidbEpisode.EpisodeType == (int)EpisodeType.Episode && anidbEpisode.EpisodeNumber == 1) + if (options.DisableFirstEpisode && anidbEpisode is not null && anidbEpisode.EpisodeType == (int)EpisodeType.Episode && anidbEpisode.EpisodeNumber == 1) return null; return unwatchedEpisode; } + public IReadOnlyList<SVR_AnimeEpisode> GetNextUpEpisodes(SVR_AnimeSeries series, int userID, NextUpQueryOptions options) + { + var firstEpisode = GetNextUpEpisode(series, userID, new(options)); + if (firstEpisode is null) + return []; + + var order = new List<EpisodeType>() { EpisodeType.Episode, EpisodeType.Other, EpisodeType.Special }; + var allEpisodes = series.AnimeEpisodes + .Select(shoko => (shoko, anidb: shoko.AniDB_Episode!)) + .Where(tuple => + tuple.anidb is not null && ( + (tuple.anidb.EpisodeTypeEnum is EpisodeType.Episode) || + (options.IncludeSpecials && tuple.anidb.EpisodeTypeEnum is EpisodeType.Special) || + (options.IncludeOthers && tuple.anidb.EpisodeTypeEnum is EpisodeType.Other) + ) + ) + .Where(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.anidb.HasAired : tuple => tuple.shoko.VideoLocals.Count > 0 && tuple.anidb.HasAired) + .OrderBy(tuple => order.IndexOf(tuple.anidb.EpisodeTypeEnum)) + .ThenBy(tuple => tuple.anidb.EpisodeNumber) + .ToList(); + var index = allEpisodes.FindIndex(tuple => tuple.shoko.AnimeEpisodeID == firstEpisode.AnimeEpisodeID); + if (index == -1) + return []; + + return allEpisodes + .Skip(index) + .Select(tuple => tuple.shoko) + .ToList(); + } + +#nullable disable + #endregion + internal class EpisodeList : List<EpisodeList.StatEpisodes> { public EpisodeList(AnimeType ept) diff --git a/Shoko.Server/Services/Connectivity/ConnectivityService.cs b/Shoko.Server/Services/Connectivity/ConnectivityService.cs index 611c692e0..0ca133724 100644 --- a/Shoko.Server/Services/Connectivity/ConnectivityService.cs +++ b/Shoko.Server/Services/Connectivity/ConnectivityService.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Shoko.Plugin.Abstractions; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Providers.AniDB.Interfaces; diff --git a/Shoko.Server/Services/ErrorHandling/SentryInit.cs b/Shoko.Server/Services/ErrorHandling/SentryInit.cs index 13e3c80bd..09d18aa51 100644 --- a/Shoko.Server/Services/ErrorHandling/SentryInit.cs +++ b/Shoko.Server/Services/ErrorHandling/SentryInit.cs @@ -51,7 +51,7 @@ void Action(SentryAspNetCoreOptions opts) if (extraInfo.TryGetValue("tag", out var gitTag)) opts.DefaultTags.Add("commit.tag", gitTag); // Append the release channel for the release on non-stable branches. - if (environment != "stable") opts.Release += string.IsNullOrEmpty(gitCommit) ? $"-{environment}" : $"-{environment}-{gitCommit[0..7]}"; + if (environment is not "stable" and not "dev") opts.Release += string.IsNullOrEmpty(gitCommit) ? $"-{environment}" : $"-{environment}-{gitCommit[0..7]}"; opts.SampleRate = 0.5f; @@ -78,7 +78,7 @@ void Action(SentryAspNetCoreOptions opts) typeof(GenericADOException), typeof(UnexpectedUDPResponseException) }; - + private static SentryEvent? BeforeSentrySend(SentryEvent arg) { var ex = arg.Exception; diff --git a/Shoko.Server/Services/GeneratedPlaylistService.cs b/Shoko.Server/Services/GeneratedPlaylistService.cs new file mode 100644 index 000000000..b800e907a --- /dev/null +++ b/Shoko.Server/Services/GeneratedPlaylistService.cs @@ -0,0 +1,460 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.API; +using Shoko.Server.Models; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Utilities; + +using FileCrossReference = Shoko.Server.API.v3.Models.Shoko.FileCrossReference; + +#pragma warning disable CA1822 +#nullable enable +namespace Shoko.Server.Services; + +public class GeneratedPlaylistService +{ + private readonly HttpContext _context; + + private readonly AnimeSeriesService _animeSeriesService; + + private readonly AnimeSeriesRepository _seriesRepository; + + private readonly AnimeEpisodeRepository _episodeRepository; + + private readonly VideoLocalRepository _videoRepository; + + private readonly AuthTokensRepository _authTokensRepository; + + public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSeriesService animeSeriesService, AnimeSeriesRepository seriesRepository, AnimeEpisodeRepository episodeRepository, VideoLocalRepository videoRepository, AuthTokensRepository authTokensRepository) + { + _context = contentAccessor.HttpContext!; + _animeSeriesService = animeSeriesService; + _seriesRepository = seriesRepository; + _episodeRepository = episodeRepository; + _videoRepository = videoRepository; + _authTokensRepository = authTokensRepository; + } + + public bool TryParsePlaylist(string[] items, out IReadOnlyList<(IReadOnlyList<IShokoEpisode> episodes, IReadOnlyList<IVideo> videos)> playlist, ModelStateDictionary? modelState = null, string fieldName = "playlist") + { + modelState ??= new(); + playlist = ParsePlaylist(items, modelState, fieldName); + return modelState.IsValid; + } + + public IReadOnlyList<(IReadOnlyList<IShokoEpisode> episodes, IReadOnlyList<IVideo> videos)> ParsePlaylist(string[] items, ModelStateDictionary? modelState = null, string fieldName = "playlist") + { + items ??= []; + var playlist = new List<(IReadOnlyList<IShokoEpisode> episodes, IReadOnlyList<IVideo> videos)>(); + var index = -1; + foreach (var item in items) + { + index++; + if (string.IsNullOrEmpty(item)) + continue; + + var releaseGroupID = -2; + var subItems = item.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (subItems.Any(subItem => subItem[0] == 's')) + { + var seriesItem = subItems.First(subItem => subItem[0] == 's'); + var releaseItem = subItems.FirstOrDefault(subItem => subItem[0] == 'r'); + if (releaseItem is not null) + { + if (subItems.Length > 2) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid item \"{item}\"."); + continue; + } + + if (!int.TryParse(releaseItem[1..], out releaseGroupID) || releaseGroupID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid release group ID \"{releaseItem}\"."); + continue; + } + } + else if (subItems.Length > 1) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid item \"{item}\"."); + continue; + } + + var endIndex = seriesItem.IndexOf('+'); + if (endIndex == -1) + endIndex = seriesItem.Length; + var plusExtras = endIndex == seriesItem.Length ? [] : seriesItem[(endIndex + 1)..].Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (!int.TryParse(seriesItem[1..endIndex], out var seriesID) || seriesID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid series ID \"{item}\"."); + continue; + } + if (_seriesRepository.GetByAnimeID(seriesID) is not { } series) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Unknown series ID \"{item}\"."); + continue; + } + + // Check if we've included any extra options. + var onlyUnwatched = false; + var includeSpecials = false; + var includeOthers = false; + var includeRewatching = false; + if (plusExtras.Length > 0) + { + if (plusExtras.Contains("onlyUnwatched")) + onlyUnwatched = true; + if (plusExtras.Contains("includeSpecials")) + includeSpecials = true; + if (plusExtras.Contains("includeOthers")) + includeOthers = true; + if (plusExtras.Contains("includeRewatching")) + includeRewatching = true; + } + + // Get the playlist items for the series. + foreach (var tuple in GetListForSeries( + series, + releaseGroupID, + new() + { + IncludeCurrentlyWatching = !onlyUnwatched, + IncludeSpecials = includeSpecials, + IncludeOthers = includeOthers, + IncludeRewatching = includeRewatching, + } + )) + playlist.Add(tuple); + + continue; + } + + var offset = -1; + var episodes = new List<IShokoEpisode>(); + var videos = new List<IVideo>(); + foreach (var subItem in subItems) + { + offset++; + var rawValue = subItem; + switch (subItem[0]) + { + case 'r': + { + if (releaseGroupID is not -2) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unexpected release group ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + if (!int.TryParse(rawValue[1..], out releaseGroupID) || releaseGroupID < -1) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid release group ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + break; + } + + case 'e': + { + if (!int.TryParse(rawValue[1..], out var episodeID) || episodeID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid episode ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + if (_episodeRepository.GetByAniDBEpisodeID(episodeID) is not { } extraEpisode) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown episode ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + episodes.Add(extraEpisode); + break; + } + + case 'f': + rawValue = rawValue[1..]; + goto default; + + default: + { + // Lookup by ED2K (optionally also by file size) + if (rawValue.Length >= 32) + { + var ed2kHash = rawValue[0..32]; + var fileSize = 0L; + if (rawValue[32] == '-') + { + if (!long.TryParse(rawValue[33..], out fileSize) || fileSize <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid file size \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + } + if ((fileSize > 0 ? _videoRepository.GetByHashAndSize(ed2kHash, fileSize) : _videoRepository.GetByHash(ed2kHash)) is not { } video0) + { + if (fileSize == 0) + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown hash \"{rawValue}\" at index {index} at offset {offset}"); + else + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown hash/size pair \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + videos.Add(video0); + continue; + } + + // Lookup by file ID + if (!int.TryParse(rawValue, out var fileID) || fileID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid file ID \"{rawValue}\"."); + continue; + } + if (_videoRepository.GetByID(fileID) is not { } video) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown file ID \"{rawValue}\"."); + continue; + } + videos.Add(video); + break; + } + } + } + + // Make sure all the videos and episodes are connected for each item. + // This will generally allow 1-N and N-1 relationships, but not N-N relationships. + foreach (var video in videos) + { + foreach (var episode in episodes) + { + if (video.Episodes.Any(x => x.ID == episode.ID)) + continue; + modelState?.AddModelError($"{fieldName}[{index}]", $"Video ID \"{video.ID}\" does not belong to episode ID \"{episode.AnidbEpisodeID}\"."); + continue; + } + } + + // Skip adding it to the playlist if it's empty. + if (episodes.Count == 0 && videos.Count == 0) + continue; + + // Add video to playlist. + if (episodes.Count is 0) + { + if (releaseGroupID is not -2) + { + modelState?.AddModelError($"{fieldName}[{index}]", "Cannot specify a release group ID for a video."); + continue; + } + + if (videos.Count > 1) + { + modelState?.AddModelError($"{fieldName}[{index}]", "Cannot combine multiple videos."); + continue; + } + + foreach (var tuple in GetListForVideo(videos[0])) + playlist.Add(tuple); + continue; + } + + // Add episode to playlist. + if (videos.Count is 0) + { + if (episodes.Count > 1) + { + modelState?.AddModelError($"{fieldName}[{index}]", "Cannot combine multiple episodes."); + continue; + } + + foreach (var tuple in GetListForEpisode(episodes[0], releaseGroupID)) + playlist.Add(tuple); + continue; + } + + // Add video and episode combination to the playlist. + playlist.Add((episodes, videos)); + } + + // Combine episodes with the same video into a single playlist entry. + index = 1; + while (index < playlist.Count) + { +#pragma warning disable IDE0042 + var current = playlist[index]; + var previous = playlist[index - 1]; +#pragma warning restore IDE0042 + if (previous.videos.Count is 1 && current.videos.Count is 1 && previous.videos[0].ID == current.videos[0].ID) + { + previous.episodes = [.. previous.episodes, .. current.episodes]; + playlist.RemoveAt(index); + continue; + } + + index++; + } + + return playlist; + } + + private IEnumerable<(IReadOnlyList<IShokoEpisode> episodes, IReadOnlyList<IVideo> videos)> GetListForSeries(IShokoSeries series, int? releaseGroupID = null, AnimeSeriesService.NextUpQueryOptions? options = null) + { + options ??= new(); + options.IncludeMissing = false; + options.IncludeUnaired = false; + var user = _context.GetUser(); + var episodes = _animeSeriesService.GetNextUpEpisodes((series as SVR_AnimeSeries)!, user.JMMUserID, options); + + // Make sure the release group is in the list, otherwise pick the most used group. + var xrefs = FileCrossReference.From(series.CrossReferences).FirstOrDefault(seriesXRef => seriesXRef.SeriesID.ID == series.ID)?.EpisodeIDs ?? []; + var releaseGroups = xrefs.GroupBy(xref => xref.ReleaseGroup ?? -1).ToDictionary(xref => xref.Key, xref => xref.Count()); + if (releaseGroups.Count > 0 && (releaseGroupID is null || !releaseGroups.ContainsKey(releaseGroupID.Value))) + releaseGroupID = releaseGroups.MaxBy(xref => xref.Value).Key; + if (releaseGroupID is -1) + releaseGroupID = null; + + foreach (var episode in episodes) + foreach (var tuple in GetListForEpisode(episode, releaseGroupID)) + yield return tuple; + } + + private IEnumerable<(IReadOnlyList<IShokoEpisode> episodes, IReadOnlyList<IVideo> videos)> GetListForEpisode(IShokoEpisode episode, int? releaseGroupID = null) + { + // For now we're just re-using the logic used in the API layer. In the future it should be moved to the service layer or somewhere else. + var xrefs = FileCrossReference.From(episode.CrossReferences).FirstOrDefault(seriesXRef => seriesXRef.SeriesID.ID == episode.SeriesID)?.EpisodeIDs ?? []; + if (xrefs.Count is 0) + yield break; + + // Make sure the release group is in the list, otherwise pick the most used group. + var releaseGroups = xrefs.GroupBy(xref => xref.ReleaseGroup ?? -1).ToDictionary(xref => xref.Key, xref => xref.Count()); + if (releaseGroups.Count > 0 && (releaseGroupID is null || !releaseGroups.ContainsKey(releaseGroupID.Value))) + releaseGroupID = releaseGroups.MaxBy(xref => xref.Value).Key; + if (releaseGroupID is -1) + releaseGroupID = null; + + // Filter to only cross-references which from the specified release group. + xrefs = xrefs + .Where(xref => xref.ReleaseGroup == releaseGroupID) + .ToList(); + var videos = xrefs.Select(xref => _videoRepository.GetByHashAndSize(xref.ED2K, xref.FileSize)) + .WhereNotNull() + .ToList(); + yield return ([episode], videos); + } + + private static readonly EpisodeType[] _videoToEpisodeGroupPreference = [EpisodeType.Episode, EpisodeType.Special, EpisodeType.Other, EpisodeType.Credits, EpisodeType.Trailer, EpisodeType.Parody]; + + private IEnumerable<(IReadOnlyList<IShokoEpisode> episodes, IReadOnlyList<IVideo> videos)> GetListForVideo(IVideo video) + { + var crossReferences = video.CrossReferences; + if (crossReferences.Count is 0) + return []; + + if (crossReferences.Count is 1) + { + if (crossReferences[0].ShokoEpisode is not { } episode) + return []; + + return [([episode], [video])]; + } + + var seriesOrder = crossReferences + .OrderBy(xref => xref.Order) + .Select(xref => xref.AnidbAnimeID) + .Distinct() + .ToArray(); + var episodes = crossReferences + .DistinctBy(xref => xref.AnidbEpisodeID) + .Select(xref => (xref, episode: xref.ShokoEpisode!)) + .Where(tuple => tuple.episode is not null) + .GroupBy(tuple => tuple.episode.Type) + .OrderBy(groupBy => Array.IndexOf(_videoToEpisodeGroupPreference, groupBy.Key)) + .First() + .OrderBy(tuple => Array.IndexOf(seriesOrder, tuple.xref.AnidbAnimeID)) + .ThenBy(tuple => tuple.episode.EpisodeNumber) + .Select(tuple => tuple.episode) + .ToList() as IReadOnlyList<IShokoEpisode>; + return episodes is { Count: > 0 } ? [(episodes, [video])] : []; + } + + public FileStreamResult GeneratePlaylist( + IEnumerable<(IReadOnlyList<IShokoEpisode> episodes, IReadOnlyList<IVideo> videos)> playlist, + string name = "Playlist" + ) + { + var m3U8 = new StringBuilder("#EXTM3U\n"); + var request = _context.Request; + var uri = new UriBuilder( + request.Scheme, + request.Host.Host, + request.Host.Port ?? (request.Scheme == "https" ? 443 : 80), + request.PathBase, + null + ); + // Get the API Key from the request or generate a new one to use for the playlist. + var apiKey = request.Query["apikey"].FirstOrDefault() ?? request.Headers["apikey"].FirstOrDefault(); + if (string.IsNullOrEmpty(apiKey)) + { + var user = _context.GetUser(); + apiKey = _authTokensRepository.CreateNewApikey(user, "playlist"); + } + foreach (var (episodes, videos) in playlist) + { + var series = episodes[0].Series; + if (series is null) + continue; + + var index = 0; + foreach (var video in videos) + m3U8.Append(GetEpisodeEntry(new UriBuilder(uri.ToString()), series, episodes[0], video, ++index, videos.Count, episodes.Count, apiKey)); + } + + var bytes = Encoding.UTF8.GetBytes(m3U8.ToString()); + var stream = new MemoryStream(bytes); + return new FileStreamResult(stream, "application/x-mpegURL") + { + FileDownloadName = $"{name}.m3u8", + }; + } + + private static string GetEpisodeEntry(UriBuilder uri, IShokoSeries series, IShokoEpisode episode, IVideo video, int part, int totalParts, int episodeRange, string apiKey) + { + var poster = series.GetPreferredImageForType(ImageEntityType.Poster) ?? series.DefaultPoster; + var parts = totalParts > 1 ? $" ({part}/{totalParts})" : string.Empty; + var episodeNumber = episode.Type is EpisodeType.Episode + ? episode.EpisodeNumber.ToString() + : $"{episode.Type.ToString()[0]}{episode.EpisodeNumber}"; + var episodePartNumber = totalParts > 1 ? $".{part}" : string.Empty; + var queryString = HttpUtility.ParseQueryString(string.Empty); + queryString.Add("shokoVersion", Utils.GetApplicationVersion()); + queryString.Add("apikey", apiKey); + + // These fields are for media player plugins to consume. + if (poster is not null && !string.IsNullOrEmpty(poster.RemoteURL)) + queryString.Add("posterUrl", poster.RemoteURL); + queryString.Add("appId", "07a58b50-5109-5aa3-abbc-782fed0df04f"); // plugin id + queryString.Add("animeId", series.AnidbAnimeID.ToString()); + queryString.Add("animeName", series.PreferredTitle); + queryString.Add("epId", episode.AnidbEpisodeID.ToString()); + queryString.Add("episodeName", episode.PreferredTitle); + queryString.Add("epNo", episodeNumber); + queryString.Add("epNoRange", episodeRange.ToString()); + queryString.Add("epCount", series.EpisodeCounts.Episodes.ToString()); + if (totalParts > 1) + { + queryString.Add("epNoPart", part.ToString()); + queryString.Add("epNoPartCount", totalParts.ToString()); + } + queryString.Add("restricted", series.Restricted ? "true" : "false"); + + uri.Path = $"{(uri.Path.Length > 1 ? uri.Path + "/" : "/")}api/v3/File/{video.ID}/Stream"; + uri.Query = queryString.ToString(); + return $"#EXTINF:-1,{series.PreferredTitle} - {episodeNumber}{episodePartNumber} - {episode.PreferredTitle}{parts}\n{uri.Uri}\n"; + } +} diff --git a/Shoko.Server/Services/Ogg/OggFile.cs b/Shoko.Server/Services/Ogg/OggFile.cs new file mode 100644 index 000000000..0018769c0 --- /dev/null +++ b/Shoko.Server/Services/Ogg/OggFile.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +//AVDump OggFile parser, tweaked for our needs. + +namespace Shoko.Server.Services.Ogg; + +public class OggFile +{ + private readonly Dictionary<uint, OGGBitStream> _bitStreams = []; + + public long FileSize { get; private set; } + + public long Overhead { get; private set; } + + public IEnumerable<OGGBitStream> BitStreams => _bitStreams.Values; + + public double Duration => BitStreams.OfType<IDuration>().Max(a => a.Duration.TotalSeconds); + + public static OggFile ParseFile(string filename) + { + var parser = new OggParser(new Reader(File.OpenRead(filename))); + parser.Process(); + return parser.Info; + } + + public void ProcessOggPage(ref OggPage page) + { + Overhead += 27 + page.SegmentCount; + + if (_bitStreams.TryGetValue(page.StreamId, out var bitStream)) + { + bitStream.ProcessPage(ref page); + + } + else if (page.Flags.HasFlag(PageFlags.Header)) + { + bitStream = OGGBitStream.ProcessBeginPage(ref page); + _bitStreams.Add(bitStream.Id, bitStream); + + } + else + { + Overhead += page.Data.Length; + } + } + + public class Reader + { + private readonly BufferedStream _stream; + + public static int SuggestedReadLength => 1 << 20; + + public Reader(Stream s) + { + _stream = new BufferedStream(s); + } + + public ReadOnlySpan<byte> GetBlock(int length) + { + var pos = _stream.Position; + var buffer = new Span<byte>(new byte[length]); + var cnt = _stream.Read(buffer); + _stream.Position = pos; + return buffer[..cnt]; + } + + public bool Advance(int length) + { + _stream.Position += length; + if (_stream.Position >= _stream.Length) + return false; + return true; + } + } + + public class OggParser + { + private readonly Reader _reader; + + public OggFile Info { get; private set; } + + public OggParser(Reader reader) + { + _reader = reader; + } + + public void Process() + { + var info = new OggFile(); + + var page = new OggPage(); + var stream = new OggBlockDataSource(_reader); + + if (!stream.SeekPastSyncBytes(false, 0)) return; + + while (stream.ReadOggPage(ref page)) + { + info.ProcessOggPage(ref page); + if (info.BitStreams.FirstOrDefault(a => a is VideoOGGBitStream) is VideoOGGBitStream bs && bs.Duration.TotalMilliseconds > 0) + { + break; + } + } + + Info = info; + } + } + + public enum PageFlags + { + None = 0, + SpanBefore = 1, + Header = 2, + Footer = 4, + SpanAfter = 1 << 31, + } + + public ref struct OggPage + { + //public long FilePosition; + //public long DataPosition; + public PageFlags Flags { get; set; } + + public byte Version { get; set; } + + public ReadOnlySpan<byte> GranulePosition { get; set; } + + public uint StreamId { get; set; } + + public uint PageIndex { get; set; } + + public ReadOnlySpan<byte> Checksum { get; set; } + + public byte SegmentCount { get; set; } + + public ReadOnlySpan<int> PacketOffsets { get; set; } + + public ReadOnlySpan<byte> Data { get; set; } + } + + public class OggBlockDataSource + { + private static readonly ReadOnlyMemory<byte> _headerSignature = new("OggS"u8.ToArray()); + + private readonly Reader _reader; + + public OggBlockDataSource(Reader reader) + { + _reader = reader; + } + + + public bool SeekPastSyncBytes(bool advanceReader, int maxBytesToSkip = 1 << 20) + { + var bytesSkipped = 0; + var magicBytes = _headerSignature.Span; + while (true) + { + var block = _reader.GetBlock(Reader.SuggestedReadLength); + var offset = block.IndexOf(magicBytes); + if (offset != -1 && offset <= maxBytesToSkip) + { + if (advanceReader) _reader.Advance(offset + 4); + return true; + } + bytesSkipped += offset; + + if (bytesSkipped > maxBytesToSkip || block.Length < 4 || !_reader.Advance(block.Length - 3)) break; + } + return false; + } + + public bool ReadOggPage(ref OggPage page) + { + if (!SeekPastSyncBytes(true)) return false; + + var block = _reader.GetBlock(23 + 256 * 256); + + //page.FilePosition = Position; + page.Version = block[0]; + page.Flags = (PageFlags)block[1]; + page.GranulePosition = block.Slice(2, 8); + page.StreamId = MemoryMarshal.Read<uint>(block[10..]); + page.PageIndex = MemoryMarshal.Read<uint>(block[14..]); + page.Checksum = block.Slice(18, 4); + + var segmentCount = page.SegmentCount = block[22]; + + var offset = 0; + var dataLength = 0; + var packetOffsets = new List<int>(); + while (segmentCount != 0) + { + dataLength += block[23 + offset]; + + if (block[23 + offset] != 255) packetOffsets.Add(dataLength); + + offset++; + segmentCount--; + } + page.PacketOffsets = packetOffsets.ToArray(); + if (block[23 + offset - 1] == 255) page.Flags |= PageFlags.SpanAfter; + + //reader.BytesRead + 23 + offset; + page.Data = block.Slice(23 + offset, Math.Min(dataLength, block.Length - 23 - offset)); + + return true; + } + } + + public class UnknownOGGBitStream : OGGBitStream + { + public override string CodecName => "Unknown"; + + public override string CodecVersion { get; protected set; } + + public UnknownOGGBitStream() : base(false) { } + } + + public abstract class OGGBitStream + { + public uint Id { get; private set; } + + public long Size { get; private set; } + + public long LastGranulePosition { get; private set; } + + public abstract string CodecName { get; } + + public abstract string CodecVersion { get; protected set; } + + public bool IsOfficiallySupported { get; private set; } + + public OGGBitStream(bool isOfficiallySupported) { IsOfficiallySupported = isOfficiallySupported; } + + public static OGGBitStream ProcessBeginPage(ref OggPage page) + { + OGGBitStream bitStream = null; + if (page.Data.Length >= 29 && Encoding.ASCII.GetString(page.Data.Slice(1, 5)).Equals("video")) + { + bitStream = new OGMVideoOGGBitStream(page.Data); + } + else if (page.Data.Length >= 46 && Encoding.ASCII.GetString(page.Data.Slice(1, 5)).Equals("audio")) + { + bitStream = new OGMAudioOGGBitStream(page.Data); + } + else if (page.Data.Length >= 0x39 && Encoding.ASCII.GetString(page.Data.Slice(1, 4)).Equals("text")) + { + bitStream = new OGMTextOGGBitStream(page.Data); + } + else if (page.Data.Length >= 42 && Encoding.ASCII.GetString(page.Data.Slice(1, 6)).Equals("theora")) + { + bitStream = new TheoraOGGBitStream(page.Data); + } + else if (page.Data.Length >= 30 && Encoding.ASCII.GetString(page.Data.Slice(1, 6)).Equals("vorbis")) + { + bitStream = new VorbisOGGBitStream(page.Data); + } + else if (page.Data.Length >= 79 && Encoding.ASCII.GetString(page.Data.Slice(1, 4)).Equals("FLAC")) + { + bitStream = new FlacOGGBitStream(page.Data); + } + + bitStream ??= new UnknownOGGBitStream(); + bitStream.Id = page.StreamId; + + return bitStream; + } + + public virtual void ProcessPage(ref OggPage page) + { + var granulePosition = MemoryMarshal.Read<long>(page.GranulePosition); + LastGranulePosition = granulePosition > LastGranulePosition && granulePosition < LastGranulePosition + 10000000 ? granulePosition : LastGranulePosition; + + Size += page.Data.Length; + } + } + + public abstract class VideoOGGBitStream : OGGBitStream, IDuration + { + public abstract long FrameCount { get; } + + public abstract double FrameRate { get; } + + public int Width { get; protected set; } + + public int Height { get; protected set; } + + public virtual TimeSpan Duration => TimeSpan.FromSeconds(FrameCount / FrameRate); + + public VideoOGGBitStream(bool isOfficiallySupported) : base(isOfficiallySupported) { } + } + + public class TheoraOGGBitStream : VideoOGGBitStream + { + public override string CodecName => "Theora"; + public override string CodecVersion { get; protected set; } + public override long FrameCount => LastGranulePosition; + public override double FrameRate { get; } + + public TheoraOGGBitStream(ReadOnlySpan<byte> header) : base(true) + { + var offset = 0; + CodecVersion = header[offset + 7] + "." + header[offset + 8] + "." + header[offset + 9]; + Width = header[offset + 14] << 16 | header[offset + 15] << 8 | header[offset + 16]; + Height = header[offset + 17] << 16 | header[offset + 18] << 8 | header[offset + 19]; + FrameRate = (header[offset + 22] << 24 | header[offset + 23] << 16 | header[offset + 24] << 8 | header[offset + 25]) / (double)(header[offset + 26] << 24 | header[offset + 27] << 16 | header[offset + 28] << 8 | header[offset + 29]); + } + } + + public class OGMVideoOGGBitStream : VideoOGGBitStream + { + public override string CodecName => "OGMVideo"; + + public override string CodecVersion { get; protected set; } + + public override long FrameCount => LastGranulePosition; + + public override double FrameRate { get; } + + public string ActualCodecName { get; private set; } + + public OGMVideoOGGBitStream(ReadOnlySpan<byte> header) + : base(false) + { + var codecInfo = MemoryMarshal.Read<OGMVideoHeader>(header.Slice(1, 0x38)); + ActualCodecName = Encoding.ASCII.GetString(BitConverter.GetBytes(codecInfo.SubType)); + FrameRate = 10000000d / codecInfo.TimeUnit; + Width = codecInfo.Width; + Height = codecInfo.Height; + + } + + [StructLayout(LayoutKind.Sequential, Size = 52)] + public struct OGMVideoHeader + { + public long StreamType { get; set; } + + public int SubType { get; set; } + + public int Size { get; set; } + + public long TimeUnit { get; set; } + + public long SamplesPerUnit { get; set; } + + public int DefaultLength { get; set; } + + public int BufferSize { get; set; } + + public short BitsPerSample { get; set; } + + public int Width { get; set; } + + public int Height { get; set; } + } + } + + public abstract class SubtitleOGGBitStream : OGGBitStream + { + public SubtitleOGGBitStream(bool isOfficiallySupported) : base(isOfficiallySupported) { } + } + + public class OGMTextOGGBitStream : SubtitleOGGBitStream + { + public override string CodecName => "OGMText"; + + public override string CodecVersion { get; protected set; } + + public string ActualCodecName { get; private set; } + + public OGMTextOGGBitStream(ReadOnlySpan<byte> header) + : base(false) + { + var codecInfo = MemoryMarshal.Read<OGMTextHeader>(header.Slice(1, 0x38)); + ActualCodecName = Encoding.ASCII.GetString(BitConverter.GetBytes(codecInfo.SubType)); + } + + [StructLayout(LayoutKind.Sequential, Size = 52)] + private struct OGMTextHeader + { + public long StreamType { get; set; } + + public int SubType { get; set; } + + public int Size { get; set; } + + public long TimeUnit { get; set; } + + public long SamplesPerUnit { get; set; } + + public int DefaultLength { get; set; } + + public int BufferSize { get; set; } + + public short BitsPerSample { get; set; } + + public long Unused { get; set; } + } + } + + public sealed class VorbisOGGBitStream : AudioOGGBitStream + { + public override string CodecName => "Vorbis"; + + public override string CodecVersion { get; protected set; } + + public override long SampleCount => LastGranulePosition; + + public override double SampleRate { get; } + + public VorbisOGGBitStream(ReadOnlySpan<byte> header) + : base(true) + { + var codecInfo = MemoryMarshal.Read<VorbisIdentHeader>(header.Slice(7, 23)); + ChannelCount = codecInfo.ChannelCount; + SampleRate = codecInfo.SampleRate; + CodecVersion = codecInfo.Version.ToString(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct VorbisIdentHeader + { + public uint Version { get; set; } + + public byte ChannelCount { get; set; } + + public uint SampleRate { get; set; } + + public int MaxBitrate { get; set; } + + public int NomBitrate { get; set; } + + public int MinBitrate { get; set; } + + public byte BlockSizes { get; set; } + + public bool Framing { get; set; } + } + } + + public class OGMAudioOGGBitStream : AudioOGGBitStream + { + public override string CodecName => "OGMAudio"; + + public override string CodecVersion { get; protected set; } + + public string ActualCodecName { get; private set; } + + public override long SampleCount => LastGranulePosition; + + public override double SampleRate { get; } + + public OGMAudioOGGBitStream(ReadOnlySpan<byte> header) + : base(false) + { + var codecInfo = MemoryMarshal.Read<OGMAudioHeader>(header.Slice(1, 56)); + ChannelCount = codecInfo.ChannelCount; + SampleRate = codecInfo.SamplesPerUnit; + ActualCodecName = Encoding.ASCII.GetString(BitConverter.GetBytes(codecInfo.SubType)); + } + + [StructLayout(LayoutKind.Sequential)] + public struct OGMAudioHeader + { + public long StreamType { get; set; } + + public int SubType { get; set; } + + public int Size { get; set; } + + public long TimeUnit { get; set; } + + public long SamplesPerUnit { get; set; } + + public int DefaultLength { get; set; } + + public int BufferSize { get; set; } + + public short BitsPerSample { get; set; } + + public short Unknown { get; set; } + + public short ChannelCount { get; set; } + + public short BlockAlign { get; set; } + + public int ByteRate { get; set; } + } + } + + public class FlacOGGBitStream : AudioOGGBitStream + { + public override string CodecName => "Flac"; + + public override string CodecVersion { get; protected set; } + + public override long SampleCount => LastGranulePosition; + + public override double SampleRate { get; } + + public FlacOGGBitStream(ReadOnlySpan<byte> header) + : base(true) + { + //TODO: check offsets + SampleRate = header[33] << 12 | header[34] << 4 | (header[35] & 0xF0) >> 4; + ChannelCount = ((header[35] & 0x0E) >> 1) + 1; + } + } + + public interface IDuration + { + TimeSpan Duration { get; } + } + + public abstract class AudioOGGBitStream : OGGBitStream, IDuration + { + public abstract long SampleCount { get; } + + public abstract double SampleRate { get; } + + public virtual TimeSpan Duration => TimeSpan.FromSeconds(SampleCount / SampleRate); + + public int ChannelCount { get; protected set; } + + public AudioOGGBitStream(bool isOfficiallySupported) : base(isOfficiallySupported) { } + } +} diff --git a/Shoko.Server/Services/VideoLocalService.cs b/Shoko.Server/Services/VideoLocalService.cs index 6741b5360..c092b9ddc 100644 --- a/Shoko.Server/Services/VideoLocalService.cs +++ b/Shoko.Server/Services/VideoLocalService.cs @@ -7,11 +7,13 @@ using Microsoft.Extensions.Logging; using Shoko.Models.Client; using Shoko.Models.MediaInfo; +using Shoko.Plugin.Abstractions.DataModels; using Shoko.Server.Extensions; using Shoko.Server.Models; using Shoko.Server.Repositories.Cached; using Media = Shoko.Models.PlexAndKodi.Media; +#pragma warning disable CS0618 namespace Shoko.Server.Services; public class VideoLocalService @@ -59,7 +61,7 @@ public CL_VideoLocal GetV1Contract(SVR_VideoLocal vl, int userID) HashSource = vl.HashSource, IsIgnored = vl.IsIgnored ? 1 : 0, IsVariation = vl.IsVariation ? 1 : 0, - Duration = (long) (vl.MediaInfo?.GeneralStream.Duration ?? 0), + Duration = (long)(vl.MediaInfo?.GeneralStream.Duration ?? 0), MD5 = vl.MD5, SHA1 = vl.SHA1, VideoLocalID = vl.VideoLocalID, @@ -100,8 +102,16 @@ public CL_VideoDetailed GetV1DetailedContract(SVR_VideoLocal vl, int userID) var userRecord = _vlUsers.GetByUserIDAndVideoLocalID(userID, vl.VideoLocalID); var aniFile = vl.AniDBFile; // to prevent multiple db calls - var relGroup = vl.ReleaseGroup; // to prevent multiple db calls - var cl = new CL_VideoDetailed { Percentage = xrefs[0].Percentage, EpisodeOrder = xrefs[0].EpisodeOrder, CrossRefSource = xrefs[0].CrossRefSource, AnimeEpisodeID = xrefs[0].EpisodeID, + var relGroup = vl.ReleaseGroup?.ToClient(); // to prevent multiple db calls + var mediaInfo = vl.MediaInfo as IMediaInfo; // to prevent multiple db calls + var audioStream = mediaInfo.AudioStreams is { Count: > 0 } ? mediaInfo.AudioStreams[0] : null; + var videoStream = mediaInfo.VideoStream; + var cl = new CL_VideoDetailed + { + Percentage = xrefs[0].Percentage, + EpisodeOrder = xrefs[0].EpisodeOrder, + CrossRefSource = xrefs[0].CrossRefSource, + AnimeEpisodeID = xrefs[0].EpisodeID, VideoLocal_FileName = vl.FileName, VideoLocal_Hash = vl.Hash, VideoLocal_FileSize = vl.FileSize, @@ -116,24 +126,24 @@ public CL_VideoDetailed GetV1DetailedContract(SVR_VideoLocal vl, int userID) VideoLocal_IsWatched = userRecord?.WatchedDate == null ? 0 : 1, VideoLocal_WatchedDate = userRecord?.WatchedDate, VideoLocal_ResumePosition = userRecord?.ResumePosition ?? 0, - VideoInfo_AudioBitrate = vl.MediaInfo?.AudioStreams.FirstOrDefault()?.BitRate.ToString(), - VideoInfo_AudioCodec = LegacyMediaUtils.TranslateCodec(vl.MediaInfo?.AudioStreams.FirstOrDefault()), - VideoInfo_Duration = vl.Duration, - VideoInfo_VideoBitrate = (vl.MediaInfo?.VideoStream?.BitRate ?? 0).ToString(), - VideoInfo_VideoBitDepth = (vl.MediaInfo?.VideoStream?.BitDepth ?? 0).ToString(), - VideoInfo_VideoCodec = LegacyMediaUtils.TranslateCodec(vl.MediaInfo?.VideoStream), - VideoInfo_VideoFrameRate = vl.MediaInfo?.VideoStream?.FrameRate.ToString(), - VideoInfo_VideoResolution = vl.VideoResolution, + VideoInfo_AudioBitrate = audioStream?.BitRate.ToString(), + VideoInfo_AudioCodec = audioStream?.Codec.Simplified, + VideoInfo_Duration = (long)(mediaInfo?.Duration.TotalMilliseconds ?? 0), + VideoInfo_VideoBitrate = videoStream?.BitRate.ToString() ?? "0", + VideoInfo_VideoBitDepth = videoStream?.BitDepth.ToString() ?? "0", + VideoInfo_VideoCodec = videoStream?.Codec.Simplified, + VideoInfo_VideoFrameRate = videoStream?.FrameRate.ToString(), + VideoInfo_VideoResolution = videoStream?.Resolution, AniDB_File_FileExtension = Path.GetExtension(aniFile?.FileName) ?? string.Empty, - AniDB_File_LengthSeconds = (int?) vl.MediaInfo?.General?.Duration ?? 0, - AniDB_AnimeID = xrefs.FirstOrDefault()?.AnimeID, + AniDB_File_LengthSeconds = (int?)mediaInfo?.Duration.TotalSeconds ?? 0, + AniDB_AnimeID = xrefs.FirstOrDefault(xref => xref.AnimeID > 0)?.AnimeID, AniDB_CRC = vl.CRC32, AniDB_MD5 = vl.MD5, AniDB_SHA1 = vl.SHA1, AniDB_Episode_Rating = 0, AniDB_Episode_Votes = 0, - AniDB_File_AudioCodec = LegacyMediaUtils.TranslateCodec(vl.MediaInfo?.AudioStreams.FirstOrDefault()) ?? string.Empty, - AniDB_File_VideoCodec = LegacyMediaUtils.TranslateCodec(vl.MediaInfo?.VideoStream) ?? string.Empty, + AniDB_File_AudioCodec = audioStream?.Codec.Simplified ?? string.Empty, + AniDB_File_VideoCodec = videoStream?.Codec.Simplified ?? string.Empty, AniDB_File_VideoResolution = vl.VideoResolution, AniDB_Anime_GroupName = aniFile?.Anime_GroupName ?? string.Empty, AniDB_Anime_GroupNameShort = aniFile?.Anime_GroupNameShort ?? string.Empty, @@ -150,7 +160,7 @@ public CL_VideoDetailed GetV1DetailedContract(SVR_VideoLocal vl, int userID) LanguagesAudio = aniFile?.LanguagesRAW ?? string.Empty, LanguagesSubtitle = aniFile?.SubtitlesRAW ?? string.Empty, ReleaseGroup = relGroup, - Media = vl.MediaInfo == null ? null : new Media(vl.VideoLocalID, vl.MediaInfo), + Media = mediaInfo is null ? null : new Media(vl.VideoLocalID, mediaInfo), }; return cl; diff --git a/Shoko.Server/Services/VideoLocal_PlaceService.cs b/Shoko.Server/Services/VideoLocal_PlaceService.cs index d46950739..92973eb22 100644 --- a/Shoko.Server/Services/VideoLocal_PlaceService.cs +++ b/Shoko.Server/Services/VideoLocal_PlaceService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NHibernate; using Polly; @@ -12,6 +13,7 @@ using Shoko.Models.MediaInfo; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Databases; using Shoko.Server.FileHelper.Subtitles; using Shoko.Server.Models; @@ -21,9 +23,11 @@ using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Actions; using Shoko.Server.Scheduling.Jobs.AniDB; -using Shoko.Server.Settings; +using Shoko.Server.Services.Ogg; using Shoko.Server.Utilities; +using ISettingsProvider = Shoko.Server.Settings.ISettingsProvider; + #nullable enable namespace Shoko.Server.Services; @@ -34,17 +38,17 @@ public class VideoLocal_PlaceService private readonly ISchedulerFactory _schedulerFactory; private readonly DatabaseFactory _databaseFactory; private readonly FileWatcherService _fileWatcherService; + private readonly RenameFileService _renameFileService; private readonly AniDB_FileRepository _aniDBFile; private readonly AniDB_EpisodeRepository _aniDBEpisode; private readonly CrossRef_File_EpisodeRepository _crossRefFileEpisode; private readonly VideoLocalRepository _videoLocal; private readonly VideoLocal_PlaceRepository _videoLocalPlace; - public VideoLocal_PlaceService(ILogger<VideoLocal_PlaceService> logger, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, FileWatcherService fileWatcherService, VideoLocalRepository videoLocal, VideoLocal_PlaceRepository videoLocalPlace, CrossRef_File_EpisodeRepository crossRefFileEpisode, AniDB_FileRepository aniDBFile, AniDB_EpisodeRepository aniDBEpisode, - DatabaseFactory databaseFactory) + DatabaseFactory databaseFactory, RenameFileService renameFileService) { _logger = logger; _settingsProvider = settingsProvider; @@ -56,6 +60,7 @@ public VideoLocal_PlaceService(ILogger<VideoLocal_PlaceService> logger, ISetting _aniDBFile = aniDBFile; _aniDBEpisode = aniDBEpisode; _databaseFactory = databaseFactory; + _renameFileService = renameFileService; } #region Relocation (Move & Rename) @@ -67,26 +72,6 @@ private enum DelayInUse Third = 5000 } - #region Move On Import - - public async Task RenameAndMoveAsRequired(SVR_VideoLocal_Place place) - { - ArgumentNullException.ThrowIfNull(place, nameof(place)); - - var settings = Utils.SettingsProvider.GetSettings(); - if (!settings.Import.RenameOnImport) - _logger.LogDebug("Skipping rename of {FilePath} as rename on import is disabled.", place.FullServerPath); - if (!settings.Import.MoveOnImport) - _logger.LogDebug("Skipping move of {FilePath} as move on import is disabled.", place.FullServerPath); - - await AutoRelocateFile(place, new AutoRelocateRequest() - { - Rename = settings.Import.RenameOnImport, - Move = settings.Import.MoveOnImport, - }); - } - - #endregion Move On Import #region Methods /// <summary> @@ -108,9 +93,20 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl ErrorMessage = "Invalid request object, import folder, or relative path.", }; + if (place.VideoLocal is not { } video) + { + _logger.LogWarning("Could not find the associated video for the file location: {LocationID}", place.VideoLocal_Place_ID); + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Could not find the associated video for the file location: {place.VideoLocal_Place_ID}", + }; + } + // Sanitize relative path and reject paths leading to outside the import folder. - var fullPath = Path.GetFullPath(Path.Combine(request.ImportFolder.ImportFolderLocation, request.RelativePath)); - if (!fullPath.StartsWith(request.ImportFolder.ImportFolderLocation, StringComparison.OrdinalIgnoreCase)) + var fullPath = Path.GetFullPath(Path.Combine(request.ImportFolder.Path, request.RelativePath)); + if (!fullPath.StartsWith(request.ImportFolder.Path, StringComparison.OrdinalIgnoreCase)) return new() { Success = false, @@ -143,30 +139,67 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl }; } - var dropFolder = place.ImportFolder!; - var newRelativePath = Path.GetRelativePath(request.ImportFolder.ImportFolderLocation, fullPath); - var newFolderPath = Path.GetDirectoryName(newRelativePath); - var newFullPath = Path.Combine(request.ImportFolder.ImportFolderLocation, newRelativePath); - var newFileName = Path.GetFileName(newRelativePath); - var renamed = !string.Equals(Path.GetFileName(oldRelativePath), newFileName, StringComparison.OrdinalIgnoreCase); - var moved = !string.Equals(Path.GetDirectoryName(oldFullPath), Path.GetDirectoryName(newFullPath), StringComparison.OrdinalIgnoreCase); + var dropFolder = (IImportFolder?)place.ImportFolder; + if (dropFolder is null) + { + _logger.LogTrace("Unable to find import folder for file with ID {VideoLocal}", place.VideoLocal); + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Unable to find import folder for file with ID {place.ImportFolderID}.", + }; + } // Don't relocate files not in a drop source or drop destination. - if (dropFolder.IsDropSource == 0 && dropFolder.IsDropDestination == 0) + if (dropFolder.DropFolderType is DropFolderType.Excluded) + { + _logger.LogTrace("Not relocating file as it is not in a drop source or drop destination: {FullPath}", oldFullPath); + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Not relocating file as it is not in a drop source or drop destination: \"{oldFullPath}\"", + }; + } + + // Or if it's in a drop destination not also marked as a drop source and relocating inside destinations is disabled. + if (dropFolder.DropFolderType is DropFolderType.Destination && !request.AllowRelocationInsideDestination) + { + _logger.LogTrace("Not relocating file because it's in a drop destination not also marked as a drop source and relocating inside destinations is disabled: {FullPath}", oldFullPath); + return new() + { + Success = false, + ShouldRetry = false, + ErrorMessage = $"Not relocating file because it's in a drop destination not also marked as a drop source and relocating inside destinations is disabled: \"{oldFullPath}\"", + }; + } + + // Check if the import folder can accept the file. + var settings = _settingsProvider.GetSettings(); + var relocationService = Utils.ServiceContainer.GetRequiredService<IRelocationService>(); + if (!settings.Import.SkipDiskSpaceChecks && !relocationService.ImportFolderHasSpace(request.ImportFolder, place)) { - _logger.LogTrace("Not relocating file as it is NOT in an import folder marked as a drop source: {FullPath}", oldFullPath); + _logger.LogWarning("The import folder cannot accept the file due to too little space available: {FilePath}", oldFullPath); return new() { Success = false, ShouldRetry = false, - ErrorMessage = $"Not relocating file as it is NOT in an import folder marked as a drop source: \"{oldFullPath}\"", + ErrorMessage = $"The import folder cannot accept the file due to too little space available: \"{oldFullPath}\"", }; } + var newRelativePath = Path.GetRelativePath(request.ImportFolder.Path, fullPath); + var newFolderPath = Path.GetDirectoryName(newRelativePath); + var newFullPath = Path.Combine(request.ImportFolder.Path, newRelativePath); + var newFileName = Path.GetFileName(newRelativePath); + var renamed = !string.Equals(Path.GetFileName(oldRelativePath), newFileName, StringComparison.OrdinalIgnoreCase); + var moved = !string.Equals(Path.GetDirectoryName(oldFullPath), Path.GetDirectoryName(newFullPath), StringComparison.OrdinalIgnoreCase); + // Last ditch effort to ensure we aren't moving a file unto itself if (string.Equals(newFullPath, oldFullPath, StringComparison.OrdinalIgnoreCase)) { - _logger.LogTrace("Resolved to move {FilePath} unto itself. Not moving.", newFullPath); + _logger.LogTrace("Resolved to relocate {FilePath} onto itself. Nothing to do.", newFullPath); return new() { Success = true, @@ -175,12 +208,12 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl }; } - var destFullTree = string.IsNullOrEmpty(newFolderPath) ? request.ImportFolder.ImportFolderLocation : Path.Combine(request.ImportFolder.ImportFolderLocation, newFolderPath); + var destFullTree = string.IsNullOrEmpty(newFolderPath) ? request.ImportFolder.Path : Path.Combine(request.ImportFolder.Path, newFolderPath); if (!Directory.Exists(destFullTree)) { + _fileWatcherService.AddFileWatcherExclusion(destFullTree); try { - _fileWatcherService.AddFileWatcherExclusion(destFullTree); Directory.CreateDirectory(destFullTree); } catch (Exception ex) @@ -200,12 +233,11 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl } var sourceFile = new FileInfo(oldFullPath); + var destVideoLocalPlace = RepoFactory.VideoLocalPlace.GetByFilePathAndImportFolderID(newRelativePath, request.ImportFolder.ID); if (File.Exists(newFullPath)) { // A file with the same name exists at the destination. _logger.LogTrace("A file already exists at the new location, checking it for duplicate…"); - var destVideoLocalPlace = RepoFactory.VideoLocalPlace.GetByFilePathAndImportFolderID(newRelativePath, - request.ImportFolder.ImportFolderID); var destVideoLocal = destVideoLocalPlace?.VideoLocal; if (destVideoLocalPlace is null || destVideoLocal is null) { @@ -218,7 +250,7 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl }; } - if (destVideoLocal.Hash == place.VideoLocal.Hash) + if (destVideoLocal.Hash == video.Hash) { _logger.LogDebug("Not moving file as it already exists at the new location, deleting source file instead: {PreviousPath} to {NextPath}", oldFullPath, newFullPath); @@ -234,7 +266,7 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl await RemoveRecord(place, false); if (request.DeleteEmptyDirectories) - RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(oldFullPath), dropFolder.ImportFolderLocation); + RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(oldFullPath), dropFolder.Path); return new() { Success = false, @@ -258,7 +290,7 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl }; } - var aniDBFile = place.VideoLocal.AniDBFile; + var aniDBFile = video.AniDBFile; if (aniDBFile is null) { _logger.LogWarning("The file does not have AniDB info. Not moving."); @@ -270,7 +302,7 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl }; } - if (destinationExistingAniDBFile.Anime_GroupName == aniDBFile.Anime_GroupName && + if (destinationExistingAniDBFile.GroupID == aniDBFile.GroupID && destinationExistingAniDBFile.FileVersion < aniDBFile.FileVersion) { // This is a V2 replacing a V1 with the same name. @@ -291,8 +323,6 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl catch (Exception e) { _logger.LogError(e, "Unable to MOVE file: {PreviousPath} to {NextPath}\n{ErrorMessage}", oldFullPath, newFullPath, e.Message); - _fileWatcherService.RemoveFileWatcherExclusion(oldFullPath); - _fileWatcherService.RemoveFileWatcherExclusion(newFullPath); return new() { Success = false, @@ -300,21 +330,39 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl ErrorMessage = $"Unable to MOVE file: \"{oldFullPath}\" to \"{newFullPath}\" error {e}", }; } + finally + { + _fileWatcherService.RemoveFileWatcherExclusion(oldFullPath); + _fileWatcherService.RemoveFileWatcherExclusion(newFullPath); + } - place.ImportFolderID = request.ImportFolder.ImportFolderID; + place.ImportFolderID = request.ImportFolder.ID; place.FilePath = newRelativePath; RepoFactory.VideoLocalPlace.Save(place); if (request.DeleteEmptyDirectories) { + // For some reason this totally hangs, if the Folder is a network folder, and multiple thread are doing it. + // IDK why, Shoko get totally frozen, but it seems a .NET issue. + // https://stackoverflow.com/questions/33036650/directory-enumeratedirectories-hang-on-some-network-folders + /* var directories = dropFolder.BaseDirectory.EnumerateDirectories("*", new EnumerationOptions() { RecurseSubdirectories = true, IgnoreInaccessible = true }) - .Select(dirInfo => dirInfo.FullName); + .Select(dirInfo => dirInfo.FullName); RecursiveDeleteEmptyDirectories(directories, dropFolder.ImportFolderLocation); + */ + RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(oldFullPath), dropFolder.Path); } } } else { + if (destVideoLocalPlace is not null) + { + _logger.LogTrace("An entry already exists for the new location at {NewPath} but no physical file resides there. Removing the entry.", newFullPath); + await RemoveRecord(destVideoLocalPlace); + } + + // Move _fileWatcherService.AddFileWatcherExclusion(oldFullPath); _fileWatcherService.AddFileWatcherExclusion(newFullPath); _logger.LogInformation("Moving file from {PreviousPath} to {NextPath}", oldFullPath, newFullPath); @@ -325,8 +373,6 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl catch (Exception e) { _logger.LogError(e, "Unable to MOVE file: {PreviousPath} to {NextPath}\n{ErrorMessage}", oldFullPath, newFullPath, e.Message); - _fileWatcherService.RemoveFileWatcherExclusion(oldFullPath); - _fileWatcherService.RemoveFileWatcherExclusion(newFullPath); return new() { Success = false, @@ -334,33 +380,40 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl ErrorMessage = $"Unable to MOVE file: \"{oldFullPath}\" to \"{newFullPath}\" error {e}", }; } + finally + { + _fileWatcherService.RemoveFileWatcherExclusion(oldFullPath); + _fileWatcherService.RemoveFileWatcherExclusion(newFullPath); + } - place.ImportFolderID = request.ImportFolder.ImportFolderID; + place.ImportFolderID = request.ImportFolder.ID; place.FilePath = newRelativePath; RepoFactory.VideoLocalPlace.Save(place); if (request.DeleteEmptyDirectories) { + // For some reason this totally hangs, if the Folder is a network folder, and multiple thread are doing it. + // IDK why, Shoko get totally frozen, but it seems a .NET issue. + // https://stackoverflow.com/questions/33036650/directory-enumeratedirectories-hang-on-some-network-folders + /* var directories = dropFolder.BaseDirectory.EnumerateDirectories("*", new EnumerationOptions() { RecurseSubdirectories = true, IgnoreInaccessible = true }) .Select(dirInfo => dirInfo.FullName); RecursiveDeleteEmptyDirectories(directories, dropFolder.ImportFolderLocation); + */ + RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(oldFullPath), dropFolder.Path); } } if (renamed) { - // Add a new lookup entry. - var filenameHash = RepoFactory.FileNameHash.GetByHash(place.VideoLocal.Hash); - if (!filenameHash.Any(a => a.FileName.Equals(newFileName))) + // Add a new or update an existing lookup entry. + var existingEntries = RepoFactory.FileNameHash.GetByHash(video.Hash); + if (!existingEntries.Any(a => a.FileName.Equals(newFileName))) { - var file = place.VideoLocal; - var hash = new FileNameHash - { - DateTimeUpdated = DateTime.Now, - FileName = newFileName, - FileSize = file.FileSize, - Hash = file.Hash, - }; + var hash = RepoFactory.FileNameHash.GetByFileNameAndSize(newFileName, video.FileSize).FirstOrDefault() ?? + new() { FileName = newFileName, FileSize = video.FileSize }; + hash.DateTimeUpdated = DateTime.Now; + hash.Hash = video.Hash; RepoFactory.FileNameHash.Save(hash); } } @@ -397,44 +450,45 @@ public async Task<RelocationResult> DirectlyRelocateFile(SVR_VideoLocal_Place pl public async Task<RelocationResult> AutoRelocateFile(SVR_VideoLocal_Place place, AutoRelocateRequest? request = null) { // Allows calling the method without any parameters. - request ??= new(); + var settings = _settingsProvider.GetSettings(); + // give defaults from the settings + request ??= new() + { + Move = settings.Plugins.Renamer.MoveOnImport, + Rename = settings.Plugins.Renamer.RenameOnImport, + DeleteEmptyDirectories = settings.Plugins.Renamer.MoveOnImport, + AllowRelocationInsideDestination = settings.Plugins.Renamer.AllowRelocationInsideDestinationOnImport, + Renamer = RepoFactory.RenamerConfig.GetByName(settings.Plugins.Renamer.DefaultRenamer), + }; - if (!request.Preview && request.ContainsBody) + if (request is { Preview: true, Renamer: null }) return new() { Success = false, ShouldRetry = false, - ErrorMessage = "Do not attempt to use an unsaved script to rename or move.", + ErrorMessage = "Cannot preview without a renamer given", }; - // Make sure the import folder is reachable. - var dropFolder = place.ImportFolder; - if (dropFolder is null) - { - _logger.LogWarning("Unable to find import folder with id {ImportFolderId}", place.ImportFolderID); + if (request is { Move: false, Rename: false }) return new() { Success = false, ShouldRetry = false, - ErrorMessage = $"Unable to find import folder with id {place.ImportFolderID}", + ErrorMessage = "Rename and Move are both set to false. Nothing to do.", }; - } - // Make sure the path is resolvable. - var oldFullPath = Path.Combine(dropFolder.ImportFolderLocation, place.FilePath); - if (string.IsNullOrWhiteSpace(place.FilePath) || string.IsNullOrWhiteSpace(oldFullPath)) + // make sure we can find the file + var previousLocation = place.FullServerPath; + if (!File.Exists(place.FullServerPath)) { return new() { Success = false, ShouldRetry = false, - ErrorMessage = $"Could not find or access the file to move: {place.VideoLocal_Place_ID}", + ErrorMessage = $"Could not find or access the file to move: {place.FileName} ({place.VideoLocal_Place_ID})" }; } - RelocationResult renameResult; - RelocationResult moveResult; - var settings = _settingsProvider.GetSettings(); var retryPolicy = Policy .HandleResult<RelocationResult>(a => a.ShouldRetry) .Or<Exception>(e => @@ -447,27 +501,10 @@ public async Task<RelocationResult> AutoRelocateFile(SVR_VideoLocal_Place place, TimeSpan.FromMilliseconds((int)DelayInUse.Second), TimeSpan.FromMilliseconds((int)DelayInUse.Third), ]); - if (settings.Import.RenameThenMove) - { - renameResult = await retryPolicy.ExecuteAsync(() => RenameFile(place, request)).ConfigureAwait(false); - if (!renameResult.Success) - return renameResult; - - // Same as above, just for moving instead. - moveResult = await retryPolicy.ExecuteAsync(() => MoveFile(place, request)).ConfigureAwait(false); - if (!moveResult.Success) - return moveResult; - } - else - { - moveResult = await retryPolicy.ExecuteAsync(() => MoveFile(place, request)).ConfigureAwait(false); - if (!moveResult.Success) - return moveResult; - renameResult = await retryPolicy.ExecuteAsync(() => RenameFile(place, request)).ConfigureAwait(false); - if (!renameResult.Success) - return renameResult; - } + var relocationResult = await retryPolicy.ExecuteAsync(() => RelocateFile(place, request)).ConfigureAwait(false); + if (!relocationResult.Success) + return relocationResult; // Set the linux permissions now if we're not previewing the result. if (!request.Preview) @@ -487,22 +524,22 @@ public async Task<RelocationResult> AutoRelocateFile(SVR_VideoLocal_Place place, } } - var correctFileName = Path.GetFileName(renameResult.RelativePath)!; - var correctFolder = Path.GetDirectoryName(moveResult.RelativePath); + var correctFileName = Path.GetFileName(relocationResult.RelativePath); + var correctFolder = Path.GetDirectoryName(relocationResult.RelativePath); var correctRelativePath = !string.IsNullOrEmpty(correctFolder) ? Path.Combine(correctFolder, correctFileName) : correctFileName; - var correctFullPath = Path.Combine(moveResult.ImportFolder!.ImportFolderLocation, correctRelativePath); + var correctFullPath = Path.Combine(relocationResult.ImportFolder!.Path, correctRelativePath); if (request.Preview) - _logger.LogTrace("Resolved to move from {PreviousPath} to {NextPath}.", oldFullPath, correctFullPath); + _logger.LogTrace("Resolved to move from {PreviousPath} to {NextPath}.", previousLocation, correctFullPath); else - _logger.LogTrace("Moved from {PreviousPath} to {NextPath}.", oldFullPath, correctFullPath); + _logger.LogTrace("Moved from {PreviousPath} to {NextPath}.", previousLocation, correctFullPath); return new() { Success = true, ShouldRetry = false, - ImportFolder = moveResult.ImportFolder, + ImportFolder = relocationResult.ImportFolder, RelativePath = correctRelativePath, - Moved = moveResult.Moved, - Renamed = renameResult.Renamed, + Moved = relocationResult.Moved, + Renamed = relocationResult.Renamed, }; } @@ -510,14 +547,14 @@ public async Task<RelocationResult> AutoRelocateFile(SVR_VideoLocal_Place place, /// Renames a file using the specified rename request. /// </summary> /// <param name="place">The <see cref="SVR_VideoLocal_Place"/> to rename.</param> - /// <param name="request">The <see cref="AutoRenameRequest"/> containing the - /// details for the rename operation.</param> + /// <param name="request">The <see cref="AutoRelocateRequest"/> containing the + /// details for the rename operation.</param> /// <returns>A <see cref="RelocationResult"/> representing the outcome of /// the rename operation.</returns> - private async Task<RelocationResult> RenameFile(SVR_VideoLocal_Place place, AutoRenameRequest request) + public async Task<RelocationResult> RelocateFile(SVR_VideoLocal_Place place, AutoRelocateRequest request) { // Just return the existing values if we're going to skip the operation. - if (!request.Rename) + if (request is { Rename: false, Move: false }) return new() { Success = true, @@ -526,20 +563,15 @@ private async Task<RelocationResult> RenameFile(SVR_VideoLocal_Place place, Auto RelativePath = place.FilePath, }; - string? newFileName; + RelocationResult result; + // run the renamer and process the result try { - if (request.ContainsBody) - newFileName = RenameFileHelper.GetFilename(place, new RenameScriptImpl() { Type = request.RenamerName, Script = request.ScriptBody ?? string.Empty }); - else - newFileName = RenameFileHelper.GetFilename(place, request.ScriptID); + result = _renameFileService.GetNewPath(place, request.Renamer, request.Move, request.Rename, request.AllowRelocationInsideDestination); } - // The renamer may throw an error catch (Exception ex) { var errorMessage = ex.Message; - if (ex.Message.StartsWith("*Error:")) - errorMessage = errorMessage[7..].Trim(); _logger.LogError(ex, "An error occurred while trying to find a new file name for {FilePath}: {ErrorMessage}", place.FullServerPath, errorMessage); return new() @@ -551,125 +583,17 @@ private async Task<RelocationResult> RenameFile(SVR_VideoLocal_Place place, Auto }; } - // Or it may return an error message or empty/null file name. - if (string.IsNullOrWhiteSpace(newFileName) || newFileName.StartsWith("*Error:")) - { - var errorMessage = !string.IsNullOrWhiteSpace(newFileName) - ? newFileName[7..].Trim() - : "The file renamer returned a null or empty value."; - _logger.LogError("An error occurred while trying to find a new file name for {FilePath}: {ErrorMessage}", place.FullServerPath, errorMessage); - return new() - { - Success = false, - ShouldRetry = false, - ErrorMessage = errorMessage, - }; - } - - // Return early if we're only previewing. - var newFullPath = Path.Combine(Path.GetDirectoryName(place.FullServerPath)!, newFileName); - var newRelativePath = Path.GetRelativePath(place.ImportFolder.ImportFolderLocation, newFullPath); - if (request.Preview) - return new() - { - Success = true, - ImportFolder = place.ImportFolder, - RelativePath = newRelativePath, - Renamed = !string.Equals(place.FileName, newFileName, StringComparison.InvariantCultureIgnoreCase), - }; - - // Actually move it. - return await DirectlyRelocateFile(place, new() - { - DeleteEmptyDirectories = false, - ImportFolder = place.ImportFolder, - RelativePath = newRelativePath, - }); - } - - /// <summary> - /// Moves a file using the specified move request. - /// </summary> - /// <param name="place">The <see cref="SVR_VideoLocal_Place"/> to rename.</param> - /// <param name="request">The <see cref="AutoMoveRequest"/> containing the - /// details for the move operation.</param> - /// <returns>A <see cref="RelocationResult"/> representing the outcome of - /// the move operation.</returns> - private async Task<RelocationResult> MoveFile(SVR_VideoLocal_Place place, AutoMoveRequest request) - { - // Just return the existing values if we're going to skip the operation. - if (!request.Move) - return new() - { - Success = true, - ShouldRetry = false, - ImportFolder = place.ImportFolder, - RelativePath = place.FilePath, - }; - - SVR_ImportFolder? importFolder; - string? newFolderPath; - try - { - if (request.ContainsBody) - (importFolder, newFolderPath) = RenameFileHelper.GetDestination(place, new RenameScriptImpl() { Type = request.RenamerName, Script = request.ScriptBody ?? string.Empty }); - else - (importFolder, newFolderPath) = RenameFileHelper.GetDestination(place, request.ScriptID); - } - // The renamer may throw an error - catch (Exception ex) - { - var errorMessage = ex.Message; - if (ex.Message.StartsWith("*Error:")) - errorMessage = errorMessage[7..].Trim(); - - _logger.LogError(ex, "An error occurred while trying to find a destination for {FilePath}: {ErrorMessage}", place.FullServerPath, errorMessage); - return new() - { - Success = false, - ShouldRetry = false, - ErrorMessage = errorMessage, - }; - } - - // Ensure the new folder path is not null. - newFolderPath ??= string.Empty; - - // Check if we have an import folder selected, and check the path for errors, even if an import folder is selected. - if (importFolder is null || newFolderPath.StartsWith("*Error:", StringComparison.InvariantCultureIgnoreCase)) - { - var errorMessage = !string.IsNullOrWhiteSpace(newFolderPath) - ? newFolderPath.StartsWith("*Error:", StringComparison.InvariantCultureIgnoreCase) - ? newFolderPath[7..].Trim() - : newFolderPath - : "Could not find a valid destination."; - _logger.LogWarning("An error occurred while trying to find a destination for {FilePath}: {ErrorMessage}", place.FullServerPath, errorMessage); - return new() - { - Success = false, - ShouldRetry = false, - ErrorMessage = errorMessage, - }; - } - - // Return early if we're only previewing. - var oldFolderPath = Path.GetDirectoryName(place.FullServerPath); - var newRelativePath = Path.Combine(newFolderPath, place.FileName); - if (request.Preview) - return new() - { - Success = true, - ImportFolder = place.ImportFolder, - RelativePath = newRelativePath, - Moved = !string.Equals(oldFolderPath, newFolderPath, StringComparison.InvariantCultureIgnoreCase), - }; + // Return early if we're only previewing or if it not a success. + if (request.Preview || !result.Success) + return result; // Actually move it. return await DirectlyRelocateFile(place, new() { DeleteEmptyDirectories = request.DeleteEmptyDirectories, - ImportFolder = importFolder, - RelativePath = newRelativePath, + AllowRelocationInsideDestination = request.AllowRelocationInsideDestination, + ImportFolder = result.ImportFolder, + RelativePath = result.RelativePath, }); } @@ -812,6 +736,20 @@ private static bool IsDirectoryEmpty(string path) #endregion Helpers #endregion Relocation (Move & Rename) + double CalculateDurationOggFile(string filename) + { + try + { + var oggFile = OggFile.ParseFile(filename); + return oggFile.Duration; + } + catch (Exception e) + { + _logger.LogError(e, "Unable to parse duration from Ogg-Vorbis file {filename}.", filename); + return 0; + } + } + public bool RefreshMediaInfo(SVR_VideoLocal_Place place) { try @@ -834,6 +772,12 @@ public bool RefreshMediaInfo(SVR_VideoLocal_Place place) var name = place.FullServerPath.Replace("/", $"{Path.DirectorySeparatorChar}"); m = Utilities.MediaInfoLib.MediaInfo.GetMediaInfo(name); // MediaInfo should have libcurl.dll for http + + if (m?.GeneralStream != null && m.GeneralStream.Duration == 0 && m.GeneralStream.Format != null && m.GeneralStream.Format.Equals("ogg", StringComparison.InvariantCultureIgnoreCase)) + { + m.GeneralStream.Duration = CalculateDurationOggFile(name); + } + var duration = m?.GeneralStream?.Duration ?? 0; if (duration == 0) { @@ -848,7 +792,7 @@ public bool RefreshMediaInfo(SVR_VideoLocal_Place place) var subs = SubtitleHelper.GetSubtitleStreams(place.FullServerPath); if (subs.Count > 0) { - m.media.track.AddRange(subs); + m.media?.track.AddRange(subs); } info.MediaInfo = m; @@ -894,7 +838,7 @@ public async Task RemoveRecordAndDeletePhysicalFile(SVR_VideoLocal_Place place, } if (deleteFolder) - RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(place.FullServerPath), place.ImportFolder.ImportFolderLocation); + RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(place.FullServerPath), place.ImportFolder!.ImportFolderLocation); await RemoveRecord(place); } @@ -927,7 +871,7 @@ public async Task RemoveAndDeleteFileWithOpenTransaction(ISession session, SVR_V return; } - if (deleteFolders) RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(place.FullServerPath), place.ImportFolder.ImportFolderLocation); + if (deleteFolders) RecursiveDeleteEmptyDirectories(Path.GetDirectoryName(place.FullServerPath), place.ImportFolder!.ImportFolderLocation); await RemoveRecordWithOpenTransaction(session, place, seriesToUpdate, updateMyList); // For deletion of files from Trakt, we will rely on the Daily sync } @@ -988,8 +932,12 @@ public async Task RemoveRecord(SVR_VideoLocal_Place place, bool updateMyListStat var xrefs = RepoFactory.CrossRef_File_Episode.GetByHash(v.Hash); foreach (var xref in xrefs) { + if (xref.AnimeID is 0) + continue; + var ep = RepoFactory.AniDB_Episode.GetByEpisodeID(xref.EpisodeID); - if (ep is null) continue; + if (ep is null) + continue; await scheduler.StartJob<DeleteFileFromMyListJob>(c => { @@ -1013,7 +961,7 @@ await scheduler.StartJob<DeleteFileFromMyListJob>(c => try { - ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place, v); + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder!, place, v); } catch { @@ -1042,7 +990,7 @@ await scheduler.StartJob<DeleteFileFromMyListJob>(c => { try { - ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place, v); + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder!, place, v); } catch { @@ -1078,11 +1026,12 @@ public async Task RemoveRecordWithOpenTransaction(ISession session, SVR_VideoLoc var xrefs = _crossRefFileEpisode.GetByHash(v.Hash); foreach (var xref in xrefs) { + if (xref.AnimeID is 0) + continue; + var ep = _aniDBEpisode.GetByEpisodeID(xref.EpisodeID); if (ep is null) - { continue; - } await scheduler.StartJob<DeleteFileFromMyListJob>(c => { @@ -1108,7 +1057,7 @@ await scheduler.StartJob<DeleteFileFromMyListJob>(c => try { - ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place, v); + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder!, place, v); } catch { @@ -1129,7 +1078,7 @@ await scheduler.StartJob<DeleteFileFromMyListJob>(c => { try { - ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place, v); + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder!, place, v); } catch { diff --git a/Shoko.Server/Services/WatchedStatusService.cs b/Shoko.Server/Services/WatchedStatusService.cs index 0ea0e6355..7a06516a0 100644 --- a/Shoko.Server/Services/WatchedStatusService.cs +++ b/Shoko.Server/Services/WatchedStatusService.cs @@ -124,6 +124,7 @@ await scheduler.StartJob<UpdateMyListFileStatusJob>( c.Hash = vl.Hash; c.Watched = watched; c.UpdateSeriesStats = false; + c.Watched = watched; c.WatchedDate = watchedDate?.ToUniversalTime(); } ); @@ -152,7 +153,7 @@ await scheduler.StartJob<UpdateMyListFileStatusJob>( // get all the files for this episode var epPercentWatched = 0; - foreach (var filexref in ep.FileCrossRefs) + foreach (var filexref in ep.FileCrossReferences) { var xrefVideoLocal = filexref.VideoLocal; if (xrefVideoLocal == null) continue; @@ -200,7 +201,7 @@ await scheduler.StartJob<SyncTraktEpisodeHistoryJob>( // get all the files for this episode var epPercentWatched = 0; - foreach (var filexref in ep.FileCrossRefs) + foreach (var filexref in ep.FileCrossReferences) { var xrefVideoLocal = filexref.VideoLocal; if (xrefVideoLocal == null) continue; diff --git a/Shoko.Server/Settings/AniDbSettings.cs b/Shoko.Server/Settings/AniDbSettings.cs index 8f7ed41f1..e182bc6c8 100644 --- a/Shoko.Server/Settings/AniDbSettings.cs +++ b/Shoko.Server/Settings/AniDbSettings.cs @@ -19,15 +19,17 @@ public class AniDbSettings public ushort UDPServerPort { get; set; } = 9000; + // We set it to 60 seconds due to issues with UDP timeouts behind NAT. + // 60 seconds is a good default for most users. + public int UDPPingFrequency { get; set; } = 60; + public ushort ClientPort { get; set; } = 4556; public string AVDumpKey { get; set; } public ushort AVDumpClientPort { get; set; } = 4557; - public bool DownloadRelatedAnime { get; set; } = true; - - public bool DownloadSimilarAnime { get; set; } = true; + public bool DownloadRelatedAnime { get; set; } = false; public bool DownloadReviews { get; set; } = false; @@ -51,14 +53,16 @@ public class AniDbSettings public ScheduledUpdateFrequency MyList_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.Never; - public ScheduledUpdateFrequency Calendar_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.HoursTwelve; + public ScheduledUpdateFrequency Calendar_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.Never; - public ScheduledUpdateFrequency Anime_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.HoursTwelve; - - public ScheduledUpdateFrequency MyListStats_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.Never; + public ScheduledUpdateFrequency Anime_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.Never; public ScheduledUpdateFrequency File_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.Daily; + public ScheduledUpdateFrequency Notification_UpdateFrequency { get; set; } = ScheduledUpdateFrequency.Never; + + public bool Notification_HandleMovedFiles { get; set; } = false; + public bool DownloadCharacters { get; set; } = true; public bool DownloadCreators { get; set; } = true; @@ -71,4 +75,8 @@ public class AniDbSettings public bool AutomaticallyImportSeries { get; set; } = false; public AVDumpSettings AVDump { get; set; } = new(); + + public AnidbRateLimitSettings HTTPRateLimit { get; set; } = new(); + + public AnidbRateLimitSettings UDPRateLimit { get; set; } = new(); } diff --git a/Shoko.Server/Settings/AnidbRateLimitSettings.cs b/Shoko.Server/Settings/AnidbRateLimitSettings.cs new file mode 100644 index 000000000..eb98ecf2c --- /dev/null +++ b/Shoko.Server/Settings/AnidbRateLimitSettings.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Shoko.Server.Settings; + +/// <summary> +/// Settings for rate limiting the Anidb provider. +/// </summary> +public class AnidbRateLimitSettings +{ + /// <summary> + /// Base rate in seconds for request and the multipliers. + /// </summary> + [Range(2, 1000)] + public int BaseRateInSeconds { get; set; } = 2; + + /// <summary> + /// Slow rate multiplier applied to the <seealso cref="BaseRateInSeconds"/>. + /// </summary> + [Range(2, 1000)] + public int SlowRateMultiplier { get; set; } = 3; + + /// <summary> + /// Slow rate period multiplier applied to the <seealso cref="BaseRateInSeconds"/>. + /// </summary> + [Range(2, 1000)] + public int SlowRatePeriodMultiplier { get; set; } = 5; + + /// <summary> + /// Reset period multiplier applied to the <seealso cref="BaseRateInSeconds"/>. + /// </summary> + [Range(2, 1000)] + public int ResetPeriodMultiplier { get; set; } = 60; +} diff --git a/Shoko.Server/Settings/DatabaseSettings.cs b/Shoko.Server/Settings/DatabaseSettings.cs index cfc90753c..497ed20ec 100644 --- a/Shoko.Server/Settings/DatabaseSettings.cs +++ b/Shoko.Server/Settings/DatabaseSettings.cs @@ -11,11 +11,13 @@ public class DatabaseSettings { public string MySqliteDirectory { get; set; } = Path.Combine(Utils.ApplicationPath, "SQLite"); - public string DatabaseBackupDirectory { get; set; } = - Path.Combine(Utils.ApplicationPath, "DatabaseBackup"); + public string DatabaseBackupDirectory { get; set; } = Path.Combine(Utils.ApplicationPath, "DatabaseBackup"); - [JsonIgnore] public string DefaultUserUsername { get; set; } = "Default"; - [JsonIgnore] public string DefaultUserPassword { get; set; } = string.Empty; + [JsonIgnore] + public string DefaultUserUsername { get; set; } = "Default"; + + [JsonIgnore] + public string DefaultUserPassword { get; set; } = string.Empty; /// <summary> /// Use Constants.DatabaseType @@ -23,9 +25,13 @@ public class DatabaseSettings public string Type { get; set; } = Constants.DatabaseType.Sqlite; public bool UseDatabaseLock { get; set; } = true; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Schema { get; set; } = string.Empty; + public string Host { get; set; } = string.Empty; [JsonIgnore] @@ -57,7 +63,7 @@ public string Hostname public string SQLite_DatabaseFile { - get => sqlite_file; + get => _sqliteFile; set { string prefix = null; @@ -73,7 +79,7 @@ public string SQLite_DatabaseFile var parts = value.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length <= 1) { - sqlite_file = value; + _sqliteFile = value; return; } @@ -84,9 +90,10 @@ public string SQLite_DatabaseFile } MySqliteDirectory = directory; - sqlite_file = parts.LastOrDefault(); + _sqliteFile = parts.LastOrDefault(); } } - [JsonIgnore] private string sqlite_file { get; set; } = "JMMServer.db3"; + [JsonIgnore] + private string _sqliteFile = "JMMServer.db3"; } diff --git a/Shoko.Server/Settings/IServerSettings.cs b/Shoko.Server/Settings/IServerSettings.cs index 95817dda0..faf3bbbd8 100644 --- a/Shoko.Server/Settings/IServerSettings.cs +++ b/Shoko.Server/Settings/IServerSettings.cs @@ -6,48 +6,137 @@ namespace Shoko.Server.Settings; public interface IServerSettings { - static string ApplicationPath { get; set; } + /// <summary> + /// Path where the images are stored. If set to <c>null</c> then it will use + /// the default location. + /// </summary> + /// <remarks> + /// The default location is the "Images" folder in the same directory as the executable. + /// </remarks> + string ImagesPath { get; set; } + + /// <summary> + /// The port number to listen on for web requests. + /// </summary> ushort ServerPort { get; set; } + + /// <summary> + /// The culture to use when formatting strings. + /// </summary> string Culture { get; set; } - string WebUI_Settings { get; set; } + + /// <summary> + /// Indicates this the first time the server has been started. + /// </summary> bool FirstRun { get; set; } - int LegacyRenamerMaxEpisodeLength { get; set; } - LogRotatorSettings LogRotator { get; set; } + + /// <summary> + /// Auto group series based on the detected relation. + /// </summary> + bool AutoGroupSeries { get; set; } + + /// <summary> + /// The list of relation types to exclude from auto grouping. + /// </summary> + List<string> AutoGroupSeriesRelationExclusions { get; set; } + + /// <summary> + /// Use the score algorithm for auto grouping. + /// </summary> + bool AutoGroupSeriesUseScoreAlgorithm { get; set; } + + /// <summary> + /// Load image metadata from the file system and send to the clients. + /// </summary> + bool LoadImageMetadata { get; set; } + + /// <summary> + /// The timeout in seconds for the caching database. + /// </summary> + int CachingDatabaseTimeout { get; set; } + + /// <summary> + /// The database settings. + /// </summary> DatabaseSettings Database { get; set; } + + /// <summary> + /// The Quartz.NET settings. + /// </summary> QuartzSettings Quartz { get; set; } + + /// <summary> + /// The connectivity settings. + /// </summary> + ConnectivitySettings Connectivity { get; set; } + + /// <summary> + /// The language settings. + /// </summary> + LanguageSettings Language { get; set; } + + /// <summary> + /// The AniDB settings. + /// </summary> AniDbSettings AniDb { get; set; } - WebCacheSettings WebCache { get; set; } - TvDBSettings TvDB { get; set; } - MovieDbSettings MovieDb { get; set; } + + /// <summary> + /// The TMDB settings. + /// </summary> + TMDBSettings TMDB { get; set; } + + /// <summary> + /// The import settings. + /// </summary> ImportSettings Import { get; set; } + + /// <summary> + /// The Plex settings. + /// </summary> PlexSettings Plex { get; set; } - PluginSettings Plugins { get; set; } + + /// <summary> + /// The TraktTV settings. + /// </summary> TraktSettings TraktTv { get; set; } - LinuxSettings Linux { get; set; } - FileQualityPreferences FileQualityPreferences { get; set; } - ConnectivitySettings Connectivity { get; set; } - bool AutoGroupSeries { get; set; } - List<string> AutoGroupSeriesRelationExclusions { get; set; } - bool AutoGroupSeriesUseScoreAlgorithm { get; set; } + + /// <summary> + /// The plugin settings. + /// </summary> + PluginSettings Plugins { get; set; } + + /// <summary> + /// Filter out video files based on quality. + /// </summary> bool FileQualityFilterEnabled { get; set; } - List<string> LanguagePreference { get; set; } - List<string> EpisodeLanguagePreference { get; set; } - bool LanguageUseSynonyms { get; set; } - int CloudWatcherTime { get; set; } - DataSourceType EpisodeTitleSource { get; set; } - DataSourceType SeriesDescriptionSource { get; set; } - DataSourceType SeriesNameSource { get; set; } + /// <summary> - /// Path where the images are stored. If set to <c>null</c> then it will use - /// the default location. + /// The file quality preferences. /// </summary> - string ImagesPath { get; set; } + FileQualityPreferences FileQualityPreferences { get; set; } + /// <summary> - /// Load image metadata from the file system and send to the clients. + /// The log rotator settings. + /// </summary> + LogRotatorSettings LogRotator { get; set; } + + /// <summary> + /// Linux runtime settings. Windows users can ignore this. + /// </summary> + LinuxSettings Linux { get; set; } + + /// <summary> + /// The web UI settings, as a stringified JSON object. + /// </summary> + string WebUI_Settings { get; set; } + + /// <summary> + /// Indicates if trace logging enabled. /// </summary> - bool LoadImageMetadata { get; set; } - string UpdateChannel { get; set; } bool TraceLog { get; set; } - int CachingDatabaseTimeout { get; set; } + + /// <summary> + /// Opt out of sending error reports to Sentry. + /// </summary> bool SentryOptOut { get; set; } } diff --git a/Shoko.Server/Settings/ImportSettings.cs b/Shoko.Server/Settings/ImportSettings.cs index 156b0ddd2..ac5c5030c 100644 --- a/Shoko.Server/Settings/ImportSettings.cs +++ b/Shoko.Server/Settings/ImportSettings.cs @@ -8,8 +8,9 @@ namespace Shoko.Server.Settings; public class ImportSettings { public HasherSettings Hasher { get; set; } = new(); - public List<string> VideoExtensions { get; set; } = new() - { + + public List<string> VideoExtensions { get; set; } = + [ "MKV", "AVI", "MP4", @@ -20,16 +21,12 @@ public class ImportSettings "MPEG", "MK3D", "M4V" - }; + ]; - public List<string> Exclude { get; set; } = new() - { + public List<string> Exclude { get; set; } = + [ @"[\\\/]\$RECYCLE\.BIN[\\\/]", @"[\\\/]\.Recycle\.Bin[\\\/]", @"[\\\/]\.Trash-\d+[\\\/]" - }; - - public RenamingLanguage DefaultSeriesLanguage { get; set; } = RenamingLanguage.Romaji; - - public RenamingLanguage DefaultEpisodeLanguage { get; set; } = RenamingLanguage.Romaji; + ]; public bool RunOnStart { get; set; } = false; @@ -41,46 +38,20 @@ public class ImportSettings public bool ScanDropFoldersOnStart { get; set; } = false; - [JsonIgnore] - public bool Hash_CRC32 - { - get => Hasher.CRC; - set => Hasher.CRC = value; - } - - [JsonIgnore] - public bool Hash_MD5 - { - get => Hasher.MD5; - set => Hasher.MD5 = value; - } - - [JsonIgnore] - public bool Hash_SHA1 - { - get => Hasher.SHA1; - set => Hasher.SHA1 = value; - } - public bool UseExistingFileWatchedStatus { get; set; } = true; public bool AutomaticallyDeleteDuplicatesOnImport { get; set; } = false; public bool FileLockChecking { get; set; } = true; - public bool AggressiveFileLockChecking { get; set; } = true; - public int FileLockWaitTimeMS { get; set; } = 4000; + public bool AggressiveFileLockChecking { get; set; } = true; + public int AggressiveFileLockWaitTimeSeconds { get; set; } = 8; public bool SkipDiskSpaceChecks { get; set; } - public bool RenameThenMove { get; set; } - - public bool RenameOnImport { get; set; } = false; - public bool MoveOnImport { get; set; } = false; - public string MediaInfoPath { get; set; } public int MediaInfoTimeoutMinutes { get; set; } = 5; diff --git a/Shoko.Server/Settings/LanguageSettings.cs b/Shoko.Server/Settings/LanguageSettings.cs new file mode 100644 index 000000000..e2cc19289 --- /dev/null +++ b/Shoko.Server/Settings/LanguageSettings.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using Shoko.Models.Enums; +using Shoko.Server.Utilities; + +#nullable enable +namespace Shoko.Server.Settings; + +public class LanguageSettings +{ + /// <summary> + /// Use synonyms when selecting the preferred language from AniDB. + /// </summary> + public bool UseSynonyms { get; set; } = false; + + private List<string> _seriesTitleLanguageOrder = ["x-main"]; + + /// <summary> + /// Series / group title language preference order. + /// </summary> + public List<string> SeriesTitleLanguageOrder + { + get => _seriesTitleLanguageOrder; + set + { + _seriesTitleLanguageOrder = value.Where(s => !string.IsNullOrEmpty(s)).ToList(); + Languages.PreferredNamingLanguages = []; + } + } + + private List<DataSourceType> _seriesTitleSourceOrder = [DataSourceType.AniDB, DataSourceType.TMDB]; + + /// <summary> + /// Series / group title source preference order. + /// </summary> + [JsonProperty(ItemConverterType = typeof(StringEnumConverter))] + public List<DataSourceType> SeriesTitleSourceOrder + { + get => _seriesTitleSourceOrder; + set => _seriesTitleSourceOrder = value.Distinct().ToList(); + } + + private List<string> _episodeLanguagePreference = ["en"]; + + /// <summary> + /// Episode / season title language preference order. + /// </summary> + public List<string> EpisodeTitleLanguageOrder + { + get => _episodeLanguagePreference; + set + { + _episodeLanguagePreference = value.Where(s => !string.IsNullOrEmpty(s)).ToList(); + Languages.PreferredEpisodeNamingLanguages = []; + } + } + + private List<DataSourceType> _episodeTitleSourceOrder = [DataSourceType.TMDB, DataSourceType.AniDB]; + + /// <summary> + /// Episode / season title source preference order. + /// </summary> + [JsonProperty(ItemConverterType = typeof(StringEnumConverter))] + public List<DataSourceType> EpisodeTitleSourceOrder + { + get => _episodeTitleSourceOrder; + set => _episodeTitleSourceOrder = value.Distinct().ToList(); + } + + private List<string> _descriptionLanguagePreference = ["en"]; + + /// <summary> + /// Description language preference order. + /// </summary> + public List<string> DescriptionLanguageOrder + { + get => _descriptionLanguagePreference; + set + { + _descriptionLanguagePreference = value.Where(s => !string.IsNullOrEmpty(s)).ToList(); + Languages.PreferredDescriptionNamingLanguages = []; + } + } + + private List<DataSourceType> _descriptionSourceOrder = [DataSourceType.TMDB, DataSourceType.AniDB]; + + /// <summary> + /// Description source preference order. + /// </summary> + [JsonProperty(ItemConverterType = typeof(StringEnumConverter))] + public List<DataSourceType> DescriptionSourceOrder + { + get => _descriptionSourceOrder; + set => _descriptionSourceOrder = value.Distinct().ToList(); + } +} diff --git a/Shoko.Server/Settings/MovieDbSettings.cs b/Shoko.Server/Settings/MovieDbSettings.cs deleted file mode 100644 index cb5bf3142..000000000 --- a/Shoko.Server/Settings/MovieDbSettings.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Shoko.Server.Settings; - -public class MovieDbSettings -{ - public bool AutoFanart { get; set; } = true; - - public int AutoFanartAmount { get; set; } = 10; - - public bool AutoPosters { get; set; } = true; - - public int AutoPostersAmount { get; set; } = 10; -} diff --git a/Shoko.Server/Settings/PlexSettings.cs b/Shoko.Server/Settings/PlexSettings.cs index 28ef23644..4cda26d9a 100644 --- a/Shoko.Server/Settings/PlexSettings.cs +++ b/Shoko.Server/Settings/PlexSettings.cs @@ -4,11 +4,8 @@ namespace Shoko.Server.Settings; public class PlexSettings { - public string ThumbnailAspects { get; set; } = "Default, 0.6667, IOS, 1.0, Android, 1.3333"; - public List<int> Libraries { get; set; } = new(); - - public string Token { get; set; } = string.Empty; + public List<int> Libraries { get; set; } = []; public string Server { get; set; } = string.Empty; } diff --git a/Shoko.Server/Settings/PluginSettings.cs b/Shoko.Server/Settings/PluginSettings.cs index bb38e40c2..7376cf7da 100644 --- a/Shoko.Server/Settings/PluginSettings.cs +++ b/Shoko.Server/Settings/PluginSettings.cs @@ -6,13 +6,9 @@ namespace Shoko.Server.Settings; public class PluginSettings { - public Dictionary<string, bool> EnabledPlugins { get; set; } = new(); + public Dictionary<string, bool> EnabledPlugins { get; set; } = []; - public List<string> Priority { get; set; } = new(); - public Dictionary<string, bool> EnabledRenamers { get; set; } = new(); - public Dictionary<string, int> RenamerPriorities { get; set; } = new(); + public List<string> Priority { get; set; } = []; - [JsonIgnore] public List<IPluginSettings> Settings { get; set; } = new(); - - public bool DeferOnError { get; set; } + public RenamerSettings Renamer { get; set; } = new(); } diff --git a/Shoko.Server/Settings/RenamerSettings.cs b/Shoko.Server/Settings/RenamerSettings.cs new file mode 100644 index 000000000..40a2c9218 --- /dev/null +++ b/Shoko.Server/Settings/RenamerSettings.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace Shoko.Server.Settings; + +public class RenamerSettings +{ + public Dictionary<string, bool> EnabledRenamers { get; set; } = []; + + /// <summary> + /// Indicates that we can rename a video file on import, and after metadata + /// updates when the metadata related to the file may have changed. + /// </summary> + public bool RenameOnImport { get; set; } = false; + + /// <summary> + /// Indicates that we can move a video file on import, and after metadata + /// updates when the metadata related to the file may have changed. + /// </summary> + public bool MoveOnImport { get; set; } = false; + + /// <summary> + /// Indicates that we can relocate a video file that lives inside a + /// drop destination import folder that's not also a drop source on import. + /// </summary> + public bool AllowRelocationInsideDestinationOnImport { get; set; } = true; + + public string DefaultRenamer { get; set; } = "Default"; +} diff --git a/Shoko.Server/Settings/ServerSettings.cs b/Shoko.Server/Settings/ServerSettings.cs index 91649260b..5482677d7 100644 --- a/Shoko.Server/Settings/ServerSettings.cs +++ b/Shoko.Server/Settings/ServerSettings.cs @@ -3,8 +3,6 @@ using JetBrains.Annotations; using Newtonsoft.Json; using Shoko.Models; -using Shoko.Models.Enums; -using Shoko.Server.ImageDownload; using Shoko.Server.Utilities; namespace Shoko.Server.Settings; @@ -15,120 +13,67 @@ public class ServerSettings : IServerSettings [UsedImplicitly] public int SettingsVersion { get; set; } = SettingsMigrations.Version; - [Range(1, 65535, ErrorMessage = "PluginAutoWatchThreshold must be between 1 and 65535")] - public ushort ServerPort { get; set; } = 8111; + [JsonIgnore] + private string _imagesPath; - [Range(0, 1, ErrorMessage = "PluginAutoWatchThreshold must be between 0 and 1")] - public double PluginAutoWatchThreshold { get; set; } = 0.89; + /// <inheritdoc /> + public string ImagesPath + { + get => _imagesPath; + set + { + _imagesPath = value; + ImageUtils.GetBaseImagesPath(); + } + } - public int CachingDatabaseTimeout { get; set; } = 180; + [Range(1, 65535, ErrorMessage = "Server Port must be between 1 and 65535")] + public ushort ServerPort { get; set; } = 8111; public string Culture { get; set; } = "en"; - /// <summary> - /// Store json settings inside string - /// </summary> - public string WebUI_Settings { get; set; } = ""; - - /// <summary> - /// FirstRun indicates if DB was configured or not, as it needed as backend for user authentication - /// </summary> public bool FirstRun { get; set; } = true; - public int LegacyRenamerMaxEpisodeLength { get; set; } = 33; + public bool AutoGroupSeries { get; set; } - public LogRotatorSettings LogRotator { get; set; } = new(); + public List<string> AutoGroupSeriesRelationExclusions { get; set; } = ["same setting", "character", "other"]; + + public bool AutoGroupSeriesUseScoreAlgorithm { get; set; } + + public bool LoadImageMetadata { get; set; } = false; + + public int CachingDatabaseTimeout { get; set; } = 180; public DatabaseSettings Database { get; set; } = new(); + public QuartzSettings Quartz { get; set; } = new(); - public AniDbSettings AniDb { get; set; } = new(); + public ConnectivitySettings Connectivity { get; set; } = new(); - public WebCacheSettings WebCache { get; set; } = new(); + public LanguageSettings Language { get; set; } = new(); - public TvDBSettings TvDB { get; set; } = new(); + public AniDbSettings AniDb { get; set; } = new(); - public MovieDbSettings MovieDb { get; set; } = new(); + public TMDBSettings TMDB { get; set; } = new(); public ImportSettings Import { get; set; } = new(); public PlexSettings Plex { get; set; } = new(); - public PluginSettings Plugins { get; set; } = new(); - - public ConnectivitySettings Connectivity { get; set; } = new(); - - public bool AutoGroupSeries { get; set; } - - public List<string> AutoGroupSeriesRelationExclusions { get; set; } = new() { "same setting", "character", "other" }; + public TraktSettings TraktTv { get; set; } = new(); - public bool AutoGroupSeriesUseScoreAlgorithm { get; set; } + public PluginSettings Plugins { get; set; } = new(); public bool FileQualityFilterEnabled { get; set; } public FileQualityPreferences FileQualityPreferences { get; set; } = new(); - private List<string> _languagePreference = new() - { - "x-jat", - "en", - }; - - public List<string> LanguagePreference - { - get => _languagePreference; - set - { - _languagePreference = value; - Languages.PreferredNamingLanguages = null; - Languages.PreferredNamingLanguageNames = null; - } - } - - private List<string> _episodeLanguagePreference = new() - { - "en", - }; - - public List<string> EpisodeLanguagePreference { - get => _episodeLanguagePreference; - set - { - _episodeLanguagePreference = value; - Languages.PreferredEpisodeNamingLanguages = null; - } - } - - public bool LanguageUseSynonyms { get; set; } = true; - - public int CloudWatcherTime { get; set; } = 3; - - public DataSourceType EpisodeTitleSource { get; set; } = DataSourceType.AniDB; - public DataSourceType SeriesDescriptionSource { get; set; } = DataSourceType.AniDB; - public DataSourceType SeriesNameSource { get; set; } = DataSourceType.AniDB; - - [JsonIgnore] public string _ImagesPath; - - /// <inheritdoc /> - public string ImagesPath - { - get => _ImagesPath; - set - { - _ImagesPath = value; - ImageUtils.GetBaseImagesPath(); - } - } - - /// <inheritdoc/> /> - public bool LoadImageMetadata { get; set; } = false; - - public TraktSettings TraktTv { get; set; } = new(); - - public string UpdateChannel { get; set; } = "Stable"; + public LogRotatorSettings LogRotator { get; set; } = new(); public LinuxSettings Linux { get; set; } = new(); + public string WebUI_Settings { get; set; } = ""; + public bool TraceLog { get; set; } public bool SentryOptOut { get; set; } = false; diff --git a/Shoko.Server/Settings/SettingsMigrations.cs b/Shoko.Server/Settings/SettingsMigrations.cs index a0f83d5b1..e053e3b65 100644 --- a/Shoko.Server/Settings/SettingsMigrations.cs +++ b/Shoko.Server/Settings/SettingsMigrations.cs @@ -2,13 +2,17 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; namespace Shoko.Server.Settings; public static class SettingsMigrations { - public const int Version = 6; + public const int Version = 9; /// <summary> /// Perform migrations on the settings json, pre-init @@ -40,13 +44,17 @@ public static string MigrateSettings(string settings) { 3, MigrateAutoGroupRelations }, { 4, MigrateHostnameToHost }, { 5, MigrateAutoGroupRelationsAlternateToAlternative }, - { 6, MigrateAniDBServerAddresses } + { 6, MigrateAniDBServerAddresses }, + { 7, MigrateLanguageSettings }, + { 8, MigrateRenamerFromImportToPluginsSettings }, + { 9, MigrateFixDefaultRenamer }, + { 10, MigrateLanguageSourceOrders }, }; private static string MigrateTvDBLanguageEnum(string settings) { var regex = new Regex("(\"EpisodeTitleSource\"\\:\\s*\")(TheTvDB)(\")", RegexOptions.Compiled); - return regex.Replace(settings, "$1TvDB$3"); + return regex.Replace(settings, "$1AniDB$3"); } private static string MigrateEpisodeLanguagePreference(string settings) @@ -96,14 +104,99 @@ private static string MigrateAniDBServerAddresses(string settings) if (currentSettings["AniDb"] is null) return settings; - + var serverAddress = currentSettings["AniDb"]["ServerAddress"]?.Value<string>() ?? "api.anidb.net"; - var serverPort = currentSettings["AniDb"]["ServerPort"]?.Value<ushort>() ?? 9001; - + var serverPort = currentSettings["AniDb"]["ServerPort"]?.Value<ushort>() ?? 9000; + currentSettings["AniDb"]["HTTPServerUrl"] = $"http://{serverAddress}:{serverPort + 1}"; currentSettings["AniDb"]["UDPServerAddress"] = serverAddress; currentSettings["AniDb"]["UDPServerPort"] = serverPort; return currentSettings.ToString(); } + + private static string MigrateLanguageSettings(string settings) + { + var currentSettings = JObject.Parse(settings); + if (currentSettings["Language"] is not null) + return settings; + + var seriesTitlePreference = (currentSettings["LanguagePreference"] as JArray)?.Values<string>() ?? []; + var episodeTitlePreference = (currentSettings["EpisodeLanguagePreference"] as JArray)?.Values<string>() ?? []; + var language = new LanguageSettings + { + UseSynonyms = currentSettings["LanguageUseSynonyms"]?.Value<bool>() ?? false, + SeriesTitleLanguageOrder = seriesTitlePreference + .Select(val => val.GetTitleLanguage()) + .Except([TitleLanguage.None, TitleLanguage.Unknown]) + .Select(val => val.GetString()) + .ToList(), + EpisodeTitleLanguageOrder = episodeTitlePreference + .Select(val => val.GetTitleLanguage()) + .Except([TitleLanguage.None, TitleLanguage.Unknown]) + .Select(val => val.GetString()) + .ToList(), + }; + currentSettings["Language"] = JObject.Parse(JsonConvert.SerializeObject(language)); + + return currentSettings.ToString(); + } + + private static string MigrateRenamerFromImportToPluginsSettings(string settings) + { + var currentSettings = JObject.Parse(settings); + + var importSettings = currentSettings["Import"]; + if (importSettings is null) + return settings; + + var renameOnImport = importSettings["RenameOnImport"]?.Value<bool>() ?? false; + var moveOnImport = importSettings["MoveOnImport"]?.Value<bool>() ?? false; + var pluginsSettings = currentSettings["Plugins"] ?? (currentSettings["Plugins"] = new JObject()); + var renamerSettings = pluginsSettings["Renamer"] ?? (pluginsSettings["Renamer"] = new JObject()); + renamerSettings["RenameOnImport"] = renameOnImport; + renamerSettings["MoveOnImport"] = moveOnImport; + renamerSettings["EnabledRenamers"] = pluginsSettings["EnabledRenamers"] ?? new JObject(); + + return currentSettings.ToString(); + } + + private static string MigrateFixDefaultRenamer(string settings) + { + var currentSettings = JObject.Parse(settings); + + if (currentSettings["Plugins"]?["Renamer"] is null) + return settings; + + var renamerSettings = currentSettings["Plugins"]["Renamer"]; + + if (string.IsNullOrEmpty(renamerSettings["DefaultRenamer"]?.Value<string>())) + renamerSettings["DefaultRenamer"] = "Default"; + + return currentSettings.ToString(); + } + + private static string MigrateLanguageSourceOrders(string settings) + { + var currentSettings = JObject.Parse(settings); + + var languageSettings = currentSettings["Language"] ?? (currentSettings["Language"] = new JObject()); + + languageSettings["SeriesTitleSourceOrder"] = new JArray + { + DataSourceType.AniDB, DataSourceType.TMDB + }; +; + languageSettings["EpisodeTitleSourceOrder"] = new JArray + { + DataSourceType.AniDB, DataSourceType.TMDB + }; + + languageSettings["DescriptionSourceOrder"] = new JArray + { + DataSourceType.AniDB, DataSourceType.TMDB + }; + + return currentSettings.ToString(); + } } diff --git a/Shoko.Server/Settings/SettingsProvider.cs b/Shoko.Server/Settings/SettingsProvider.cs index 9fe27fe91..6917aa7fb 100644 --- a/Shoko.Server/Settings/SettingsProvider.cs +++ b/Shoko.Server/Settings/SettingsProvider.cs @@ -72,8 +72,6 @@ private static ServerSettings LoadLegacySettings() { ImagesPath = legacy.ImagesPath, ServerPort = (ushort)legacy.JMMServerPort, - PluginAutoWatchThreshold = double.Parse(legacy.PluginAutoWatchThreshold, CultureInfo.InvariantCulture), - Culture = legacy.Culture, WebUI_Settings = legacy.WebUI_Settings, FirstRun = legacy.FirstRun, LogRotator = @@ -93,7 +91,6 @@ private static ServerSettings LoadLegacySettings() AVDumpKey = legacy.AniDB_AVDumpKey, AVDumpClientPort = ushort.Parse(legacy.AniDB_AVDumpClientPort), DownloadRelatedAnime = legacy.AniDB_DownloadRelatedAnime, - DownloadSimilarAnime = legacy.AniDB_DownloadSimilarAnime, DownloadReviews = legacy.AniDB_DownloadReviews, DownloadReleaseGroups = legacy.AniDB_DownloadReleaseGroups, MyList_AddFiles = legacy.AniDB_MyList_AddFiles, @@ -106,62 +103,37 @@ private static ServerSettings LoadLegacySettings() MyList_UpdateFrequency = legacy.AniDB_MyList_UpdateFrequency, Calendar_UpdateFrequency = legacy.AniDB_Calendar_UpdateFrequency, Anime_UpdateFrequency = legacy.AniDB_Anime_UpdateFrequency, - MyListStats_UpdateFrequency = legacy.AniDB_MyListStats_UpdateFrequency, File_UpdateFrequency = legacy.AniDB_File_UpdateFrequency, DownloadCharacters = legacy.AniDB_DownloadCharacters, DownloadCreators = legacy.AniDB_DownloadCreators, MaxRelationDepth = legacy.AniDB_MaxRelationDepth }, - WebCache = new WebCacheSettings - { - Address = legacy.WebCache_Address, - XRefFileEpisode_Get = legacy.WebCache_XRefFileEpisode_Get, - XRefFileEpisode_Send = legacy.WebCache_XRefFileEpisode_Send, - TvDB_Get = legacy.WebCache_TvDB_Get, - TvDB_Send = legacy.WebCache_TvDB_Send, - Trakt_Get = legacy.WebCache_Trakt_Get, - Trakt_Send = legacy.WebCache_Trakt_Send - }, - TvDB = - new TvDBSettings + TMDB = + new TMDBSettings { - AutoLink = legacy.TvDB_AutoLink, - AutoFanart = legacy.TvDB_AutoFanart, - AutoFanartAmount = legacy.TvDB_AutoFanartAmount, - AutoWideBanners = legacy.TvDB_AutoWideBanners, - AutoWideBannersAmount = legacy.TvDB_AutoWideBannersAmount, - AutoPosters = legacy.TvDB_AutoPosters, - AutoPostersAmount = legacy.TvDB_AutoPostersAmount, - UpdateFrequency = legacy.TvDB_UpdateFrequency, - Language = legacy.TvDB_Language - }, - MovieDb = - new MovieDbSettings - { - AutoFanart = legacy.MovieDB_AutoFanart, - AutoFanartAmount = legacy.MovieDB_AutoFanartAmount, - AutoPosters = legacy.MovieDB_AutoPosters, - AutoPostersAmount = legacy.MovieDB_AutoPostersAmount + AutoDownloadBackdrops = legacy.MovieDB_AutoFanart, + MaxAutoBackdrops = legacy.MovieDB_AutoFanartAmount, + AutoDownloadPosters = legacy.MovieDB_AutoPosters, + MaxAutoPosters = legacy.MovieDB_AutoPostersAmount }, Import = new ImportSettings { VideoExtensions = legacy.VideoExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(), - DefaultSeriesLanguage = legacy.DefaultSeriesLanguage, - DefaultEpisodeLanguage = legacy.DefaultEpisodeLanguage, RunOnStart = legacy.RunImportOnStart, ScanDropFoldersOnStart = legacy.ScanDropFoldersOnStart, - Hash_CRC32 = legacy.Hash_CRC32, - Hash_MD5 = legacy.Hash_MD5, - Hash_SHA1 = legacy.Hash_SHA1, + Hasher = new() + { + CRC = legacy.Hash_CRC32, + MD5 = legacy.Hash_MD5, + SHA1 = legacy.Hash_SHA1, + }, UseExistingFileWatchedStatus = legacy.Import_UseExistingFileWatchedStatus }, Plex = new PlexSettings { - ThumbnailAspects = legacy.PlexThumbnailAspects, Libraries = legacy.Plex_Libraries.ToList(), - Token = legacy.Plex_Token, Server = legacy.Plex_Server }, AutoGroupSeries = legacy.AutoGroupSeries, @@ -169,27 +141,29 @@ private static ServerSettings LoadLegacySettings() AutoGroupSeriesUseScoreAlgorithm = legacy.AutoGroupSeriesUseScoreAlgorithm, FileQualityFilterEnabled = legacy.FileQualityFilterEnabled, FileQualityPreferences = legacy.FileQualityFilterPreferences, - LanguagePreference = legacy.LanguagePreference.Split(',').ToList(), - EpisodeLanguagePreference = legacy.EpisodeLanguagePreference.Split(',').ToList(), - LanguageUseSynonyms = legacy.LanguageUseSynonyms, - CloudWatcherTime = legacy.CloudWatcherTime, - EpisodeTitleSource = legacy.EpisodeTitleSource, - SeriesDescriptionSource = legacy.SeriesDescriptionSource, - SeriesNameSource = legacy.SeriesNameSource, + Language = new() + { + UseSynonyms = legacy.LanguageUseSynonyms, + SeriesTitleLanguageOrder = legacy.LanguagePreference.Split(',').ToList(), + SeriesTitleSourceOrder = [legacy.SeriesNameSource], + EpisodeTitleLanguageOrder = legacy.EpisodeLanguagePreference.Split(',').ToList(), + EpisodeTitleSourceOrder = [legacy.EpisodeTitleSource], + DescriptionSourceOrder = [legacy.SeriesDescriptionSource], + }, TraktTv = new TraktSettings { Enabled = legacy.Trakt_IsEnabled, - PIN = legacy.Trakt_PIN, AuthToken = legacy.Trakt_AuthToken, RefreshToken = legacy.Trakt_RefreshToken, TokenExpirationDate = legacy.Trakt_TokenExpirationDate, UpdateFrequency = legacy.Trakt_UpdateFrequency, SyncFrequency = legacy.Trakt_SyncFrequency }, - UpdateChannel = legacy.UpdateChannel, Linux = new LinuxSettings { - UID = legacy.Linux_UID, GID = legacy.Linux_GID, Permission = legacy.Linux_Permission + UID = legacy.Linux_UID, + GID = legacy.Linux_GID, + Permission = legacy.Linux_Permission }, TraceLog = legacy.TraceLog, Database = new DatabaseSettings @@ -306,7 +280,7 @@ public void SaveSettings() if (!Validator.TryValidateObject(Instance, context, results)) { - results.ForEach(s => _logger.LogError(s.ErrorMessage)); + results.ForEach(s => _logger.LogError("{ex}", s.ErrorMessage)); throw new ValidationException(); } @@ -384,22 +358,17 @@ private static bool IsPrimitive(Type type) return false; } - private static IEnumerable<object> ToEnum(Array a) - { - for (var i = 0; i < a.Length; i++) { yield return a.GetValue(i); } - } - public void DebugSettingsToLog() { #region System Info _logger.LogInformation("-------------------- SYSTEM INFO -----------------------"); - var a = Assembly.GetEntryAssembly(); try { + var a = Assembly.GetEntryAssembly(); var serverVersion = new ComponentVersion { Version = Utils.GetApplicationVersion() }; - var extraVersionDict = Utils.GetApplicationExtraVersion(); + var extraVersionDict = Utils.GetApplicationExtraVersion(a); if (extraVersionDict.TryGetValue("tag", out var tag)) serverVersion.Tag = tag; if (extraVersionDict.TryGetValue("commit", out var commit)) @@ -428,7 +397,7 @@ public void DebugSettingsToLog() } catch (Exception ex) { - // oopps, can't create file + // whops, can't create file logger.Warn("Error in log (database version lookup: {0}", ex.Message); } */ @@ -440,13 +409,13 @@ public void DebugSettingsToLog() var tempVersion = MediaInfo.GetVersion(); if (tempVersion != null) mediaInfoVersion = $"MediaInfo: {tempVersion}"; - _logger.LogInformation(mediaInfoVersion); + _logger.LogInformation("{msg}", mediaInfoVersion); var hasherInfoVersion = "**** Hasher - DLL NOT found *****"; tempVersion = Hasher.GetVersion(); if (tempVersion != null) hasherInfoVersion = $"RHash: {tempVersion}"; - _logger.LogInformation(hasherInfoVersion); + _logger.LogInformation("{msg}", hasherInfoVersion); } catch (Exception ex) { diff --git a/Shoko.Server/Settings/TMDBSettings.cs b/Shoko.Server/Settings/TMDBSettings.cs new file mode 100644 index 000000000..df5c5e322 --- /dev/null +++ b/Shoko.Server/Settings/TMDBSettings.cs @@ -0,0 +1,181 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Extensions; + +#nullable enable +namespace Shoko.Server.Settings; + +public class TMDBSettings +{ + /// <summary> + /// Automagically link AniDB anime to TMDB shows and movies. + /// </summary> + public bool AutoLink { get; set; } = false; + + /// <summary> + /// Automagically link restricted AniDB anime to TMDB shows and movies. + /// <see cref="AutoLink"/> also needs to be set for this setting to take + /// effect. + /// </summary> + public bool AutoLinkRestricted { get; set; } = false; + + /// <summary> + /// Indicates that all titles should be stored locally for the TMDB entity, + /// otherwise it will use + /// <seealso cref="LanguageSettings.SeriesTitleLanguageOrder"/> or + /// <seealso cref="LanguageSettings.EpisodeTitleLanguageOrder"/> depending + /// on the entity type to determine which titles to store locally. + /// </summary> + public bool DownloadAllTitles { get; set; } = false; + + /// <summary> + /// Indicates that all overviews should be stored locally for the TMDB + /// entity, otherwise it will use + /// <seealso cref="LanguageSettings.DescriptionLanguageOrder"/> to determine + /// which overviews should be stored locally. + /// </summary> + public bool DownloadAllOverviews { get; set; } = false; + + /// <summary> + /// Indicates that all content-ratings should be stored locally for the TMDB + /// entity, otherwise it will use + /// <seealso cref="LanguageSettings.SeriesTitleLanguageOrder"/> or + /// <seealso cref="LanguageSettings.EpisodeTitleLanguageOrder"/> depending + /// on the entity type to determine which content-ratings to store locally. + /// </summary> + public bool DownloadAllContentRatings { get; set; } = false; + + /// <summary> + /// Image language preference order, in text form for storage. + /// </summary> + [JsonProperty(nameof(ImageLanguageOrder))] + [UsedImplicitly] + public List<string> InternalImageLanguageOrder + { + get => ImageLanguageOrder + .Select(x => x.GetString()) + .ToList(); + set => ImageLanguageOrder = value + .Select(x => x.GetTitleLanguage()) + .Distinct() + .Where(x => x is not TitleLanguage.Unknown) + .ToList(); + } + + /// <summary> + /// Image language preference order, as enum values for consumption. + /// </summary> + [JsonIgnore] + public List<TitleLanguage> ImageLanguageOrder { get; set; } = [TitleLanguage.None, TitleLanguage.Main, TitleLanguage.English]; + + /// <summary> + /// Automagically download crew and cast for movies and tv shows in the + /// local collection. + /// </summary> + public bool AutoDownloadCrewAndCast { get; set; } = false; + + /// <summary> + /// Automagically download collections for movies and tv shows in the local + /// collection. + /// </summary> + public bool AutoDownloadCollections { get; set; } = false; + + /// <summary> + /// Automagically download episode groups to use with alternate ordering + /// for tv shows. + /// </summary> + public bool AutoDownloadAlternateOrdering { get; set; } = false; + + /// <summary> + /// Automagically download backdrops for TMDB entities that supports + /// backdrops up to <seealso cref="MaxAutoBackdrops"/> images per entity. + /// </summary> + public bool AutoDownloadBackdrops { get; set; } = true; + + /// <summary> + /// The maximum number of backdrops to download for each TMDB entity that + /// supports backdrops. + /// </summary> + /// <remarks> + /// Set to <code>0</code> to disable the limit. + /// </remarks> + [Range(0, 30)] + public int MaxAutoBackdrops { get; set; } = 10; + + /// <summary> + /// Automagically download posters for TMDB entities that supports + /// posters up to <seealso cref="MaxAutoPosters"/> images per entity. + /// </summary> + public bool AutoDownloadPosters { get; set; } = true; + + /// <summary> + /// The maximum number of posters to download for each TMDB entity that + /// supports posters. + /// </summary> + /// <remarks> + /// Set to <code>0</code> to disable the limit. + /// </remarks> + [Range(0, 30)] + public int MaxAutoPosters { get; set; } = 10; + + /// <summary> + /// Automagically download logos for TMDB entities that supports + /// logos up to <seealso cref="MaxAutoLogos"/> images per entity. + /// </summary> + public bool AutoDownloadLogos { get; set; } = true; + + /// <summary> + /// The maximum number of logos to download for each TMDB entity that + /// supports logos. + /// </summary> + /// <remarks> + /// Set to <code>0</code> to disable the limit. + /// </remarks> + [Range(0, 30)] + public int MaxAutoLogos { get; set; } = 10; + + /// <summary> + /// Automagically download thumbnail images for TMDB entities that supports + /// thumbnails. + /// </summary> + public bool AutoDownloadThumbnails { get; set; } = true; + + /// <summary> + /// The maximum number of thumbnail images to download for each TMDB entity + /// that supports thumbnail images. + /// </summary> + /// <remarks> + /// Set to <code>0</code> to disable the limit. + /// </remarks> + [Range(0, 30)] + public int MaxAutoThumbnails { get; set; } = 1; + + /// <summary> + /// Automagically download staff member and voice-actor images. + /// </summary> + public bool AutoDownloadStaffImages { get; set; } = true; + + /// <summary> + /// The maximum number of staff member and voice-actor images to download + /// for each TMDB entity that supports staff member and voice-actor images. + /// </summary> + /// <remarks> + /// Set to <code>0</code> to disable the limit. + /// </remarks> + [Range(0, 30)] + public int MaxAutoStaffImages { get; set; } = 10; + + /// <summary> + /// Automagically download studio and company images. + /// </summary> + public bool AutoDownloadStudioImages { get; set; } = true; + + /// <summary> + /// Optional. User provided TMDB API key to use. + /// </summary> + public string? UserApiKey { get; set; } = null; +} diff --git a/Shoko.Server/Settings/TraktSettings.cs b/Shoko.Server/Settings/TraktSettings.cs index 4eadc7cd5..80c580694 100644 --- a/Shoko.Server/Settings/TraktSettings.cs +++ b/Shoko.Server/Settings/TraktSettings.cs @@ -6,7 +6,7 @@ public class TraktSettings { public bool Enabled { get; set; } = false; - public string PIN { get; set; } = string.Empty; + public bool AutoLink { get; set; } = false; public string AuthToken { get; set; } = string.Empty; diff --git a/Shoko.Server/Settings/TvDBSettings.cs b/Shoko.Server/Settings/TvDBSettings.cs deleted file mode 100644 index cae2823a4..000000000 --- a/Shoko.Server/Settings/TvDBSettings.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Shoko.Models.Enums; - -namespace Shoko.Server.Settings; - -public class TvDBSettings -{ - public bool AutoLink { get; set; } = false; - - public bool AutoFanart { get; set; } = true; - - public int AutoFanartAmount { get; set; } = 10; - - public bool AutoWideBanners { get; set; } = true; - - public int AutoWideBannersAmount { get; set; } = 10; - - public bool AutoPosters { get; set; } = true; - - public int AutoPostersAmount { get; set; } = 10; - - public ScheduledUpdateFrequency UpdateFrequency { get; set; } = ScheduledUpdateFrequency.HoursTwelve; - - public string Language { get; set; } = "en"; -} diff --git a/Shoko.Server/Settings/WebCacheSettings.cs b/Shoko.Server/Settings/WebCacheSettings.cs deleted file mode 100644 index edd7beeb5..000000000 --- a/Shoko.Server/Settings/WebCacheSettings.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; - -namespace Shoko.Server.Settings; - -public class WebCacheSettings -{ - public bool Enabled { get; set; } = false; - public string Address { get; set; } = "https://localhost:44307"; - - public string BannedReason { get; set; } - - public DateTime? BannedExpiration { get; set; } - - public bool XRefFileEpisode_Get { get; set; } = true; - - public bool XRefFileEpisode_Send { get; set; } = true; - - public bool TvDB_Get { get; set; } = true; - - public bool TvDB_Send { get; set; } = true; - - public bool Trakt_Get { get; set; } = true; - - public bool Trakt_Send { get; set; } = true; -} diff --git a/Shoko.Server/Shoko.Server.csproj b/Shoko.Server/Shoko.Server.csproj index b8eab5fb0..004cf4ac4 100644 --- a/Shoko.Server/Shoko.Server.csproj +++ b/Shoko.Server/Shoko.Server.csproj @@ -60,7 +60,7 @@ <PackageReference Include="JetBrains.Annotations" Version="2023.3.0" /> <PackageReference Include="Libuv" Version="1.10.0" /> <PackageReference Include="Magick.NET-Q8-AnyCPU" Version="13.6.0" /> - <PackageReference Include="MessagePack" Version="2.5.140" /> + <PackageReference Include="MessagePack" Version="2.5.187" /> <PackageReference Include="MessagePackAnalyzer" Version="2.5.140"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> @@ -85,7 +85,7 @@ <PackageReference Include="MySqlConnector" Version="2.3.6" /> <PackageReference Include="Nancy.Rest.Annotations" Version="1.4.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> - <PackageReference Include="NHibernate" Version="5.5.1" /> + <PackageReference Include="NHibernate" Version="5.5.2" /> <PackageReference Include="NHibernate.Driver.MySqlConnector" Version="2.0.5" /> <PackageReference Include="NLog" Version="5.2.8" /> <PackageReference Include="NLog.Web.AspNetCore" Version="5.3.8" /> @@ -106,7 +106,7 @@ <!-- This needs to be explicit because of https://github.com/MySqlBackupNET/MySqlBackup.Net/issues/61 --> <PackageReference Include="System.Threading.ThreadPool" Version="4.3.0" /> <PackageReference Include="TaskScheduler" Version="2.10.1" /> - <PackageReference Include="TMDbLib" Version="1.6.0" /> + <PackageReference Include="TMDbLib" Version="2.2.0" /> <PackageReference Include="Trinet.Core.IO.Ntfs" Version="4.1.1" /> <PackageReference Include="TvDbSharper" Version="3.2.0" /> </ItemGroup> diff --git a/Shoko.Server/Tasks/AnimeGroupCreator.cs b/Shoko.Server/Tasks/AnimeGroupCreator.cs index 83f85218b..42cf7b3a6 100644 --- a/Shoko.Server/Tasks/AnimeGroupCreator.cs +++ b/Shoko.Server/Tasks/AnimeGroupCreator.cs @@ -371,7 +371,7 @@ public SVR_AnimeGroup GetOrCreateSingleGroupForSeries(SVR_AnimeSeries series) // Override the group name if the group is not manually named. if (animeGroup.IsManuallyNamed == 0) { - animeGroup.GroupName = series.SeriesName; + animeGroup.GroupName = series.PreferredTitle; } // Override the group desc. if the group doesn't have an override. if (animeGroup.OverrideDescription == 0) diff --git a/Shoko.Server/Tasks/AutoAnimeGroupCalculator.cs b/Shoko.Server/Tasks/AutoAnimeGroupCalculator.cs index c8cd8537a..61a0ff8a0 100644 --- a/Shoko.Server/Tasks/AutoAnimeGroupCalculator.cs +++ b/Shoko.Server/Tasks/AutoAnimeGroupCalculator.cs @@ -187,7 +187,7 @@ INNER JOIN AniDB_Anime toAnime } /// <summary> - /// Gets the ID of the anime represents the group containing the specified <see cref="animeId"/>. + /// Gets the ID of the anime represents the group containing the specified <paramref name="animeId"/>. /// </summary> /// <param name="animeId">The ID of the anime to get the group's anime ID for.</param> /// <returns>The group's representative anime ID. For anime that don't have any suitable relations, diff --git a/Shoko.Server/Utilities/AVDumpHelper.cs b/Shoko.Server/Utilities/AVDumpHelper.cs index abed42b72..b35fcde18 100644 --- a/Shoko.Server/Utilities/AVDumpHelper.cs +++ b/Shoko.Server/Utilities/AVDumpHelper.cs @@ -5,14 +5,15 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using NLog; using SharpCompress.Common; using SharpCompress.Readers; -using Shoko.Commons.Utils; using Shoko.Plugin.Abstractions; +using Shoko.Plugin.Abstractions.Events; using Shoko.Server.Models; using Shoko.Server.Repositories; @@ -378,7 +379,9 @@ private static bool PrepareAVDump(bool force = false) { try { - using var stream = Misc.DownloadWebBinary(AVDumpURL); + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", "JMM"); + using var stream = client.GetStreamAsync(AVDumpURL).ConfigureAwait(false).GetAwaiter().GetResult(); if (stream == null) return false; diff --git a/Shoko.Server/Utilities/FileQualityFilter.cs b/Shoko.Server/Utilities/FileQualityFilter.cs index a1739f7d1..97135371d 100644 --- a/Shoko.Server/Utilities/FileQualityFilter.cs +++ b/Shoko.Server/Utilities/FileQualityFilter.cs @@ -6,6 +6,7 @@ using Shoko.Models.Enums; using Shoko.Models.MediaInfo; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels; using Shoko.Server.Extensions; using Shoko.Server.Models; @@ -54,91 +55,56 @@ reference said enum through a CompareByType #region Checks - public static bool CheckFileKeep(SVR_VideoLocal file) + public static bool CheckFileKeep(SVR_VideoLocal video) { - var result = true; - var allowUnknown = Utils.SettingsProvider.GetSettings().FileQualityPreferences.AllowDeletingFilesWithMissingInfo; - - var aniFile = file?.AniDBFile; // Don't delete files with missing info. If it's not getting updated, then do it manually - if (IsNullOrUnknown(aniFile) && !allowUnknown) return true; + var anidbFile = video.AniDBFile; + var allowUnknown = Utils.SettingsProvider.GetSettings().FileQualityPreferences.AllowDeletingFilesWithMissingInfo; + if (IsNullOrUnknown(anidbFile) && !allowUnknown) return true; + var result = true; + var media = video.MediaInfo as IMediaInfo; foreach (var type in Settings.RequiredTypes) { - if (!result) + result &= type switch { - break; - } + FileQualityFilterType.AUDIOCODEC => + CheckAudioCodec(media), + FileQualityFilterType.AUDIOSTREAMCOUNT => + CheckAudioStreamCount(media), + FileQualityFilterType.CHAPTER => + CheckChaptered(anidbFile, media), + FileQualityFilterType.RESOLUTION => + CheckResolution(media), + FileQualityFilterType.SOURCE => + CheckSource(anidbFile), + FileQualityFilterType.SUBGROUP => + CheckSubGroup(anidbFile), + FileQualityFilterType.SUBSTREAMCOUNT => + CheckSubStreamCount(video), + FileQualityFilterType.VERSION => + CheckDeprecated(anidbFile), + FileQualityFilterType.VIDEOCODEC => + CheckVideoCodec(media), + _ => true, + }; - switch (type) - { - case FileQualityFilterType.AUDIOCODEC: - result &= CheckAudioCodec(file); - break; - case FileQualityFilterType.AUDIOSTREAMCOUNT: - result &= CheckAudioStreamCount(file); - break; - case FileQualityFilterType.CHAPTER: - if (aniFile == null) - { - return false; - } - - result &= CheckChaptered(file); - break; - case FileQualityFilterType.RESOLUTION: - result &= CheckResolution(file); - break; - case FileQualityFilterType.SOURCE: - if (aniFile == null) - { - return false; - } - - result &= CheckSource(aniFile); - break; - case FileQualityFilterType.SUBGROUP: - if (aniFile == null) - { - return false; - } - - result &= CheckSubGroup(aniFile); - break; - case FileQualityFilterType.SUBSTREAMCOUNT: - result &= CheckSubStreamCount(file); - break; - case FileQualityFilterType.VERSION: - if (aniFile == null) - { - return false; - } - - result &= CheckDeprecated(aniFile); - break; - case FileQualityFilterType.VIDEOCODEC: - if (aniFile == null) - { - return false; - } - - result &= CheckVideoCodec(file); - break; - } + if (!result) + break; } return result; } - private static bool CheckAudioCodec(SVR_VideoLocal aniFile) + private static bool CheckAudioCodec(IMediaInfo media) { - var codecs = - aniFile?.MediaInfo?.AudioStreams.Select(LegacyMediaUtils.TranslateCodec).OrderBy(a => a) - .ToArray() ?? Array.Empty<string>(); - if (codecs.Length == 0) - { + var codecs = media?.AudioStreams + .Select(stream => stream.Codec.Simplified) + .Where(codec => codec is not "unknown") + .OrderBy(codec => codec) + .ToList() ?? []; + if (codecs.Count == 0) return false; - } var operationType = Settings.RequiredAudioCodecs.Operator; return operationType switch @@ -149,27 +115,27 @@ private static bool CheckAudioCodec(SVR_VideoLocal aniFile) }; } - private static bool CheckAudioStreamCount(SVR_VideoLocal aniFile) + private static bool CheckAudioStreamCount(IMediaInfo media) { - var streamCount = aniFile?.MediaInfo?.AudioStreams.Count ?? -1; + var streamCount = media?.AudioStreams.Count ?? -1; if (streamCount == -1) - { return true; - } - var operationType = Settings.RequiredAudioStreamCount.Operator; - return operationType switch + return Settings.RequiredAudioStreamCount.Operator switch { - FileQualityFilterOperationType.EQUALS => streamCount == Settings.RequiredAudioStreamCount.Value, - FileQualityFilterOperationType.GREATER_EQ => streamCount >= Settings.RequiredAudioStreamCount.Value, - FileQualityFilterOperationType.LESS_EQ => streamCount <= Settings.RequiredAudioStreamCount.Value, - _ => true + FileQualityFilterOperationType.EQUALS => + streamCount == Settings.RequiredAudioStreamCount.Value, + FileQualityFilterOperationType.GREATER_EQ => + streamCount >= Settings.RequiredAudioStreamCount.Value, + FileQualityFilterOperationType.LESS_EQ => + streamCount <= Settings.RequiredAudioStreamCount.Value, + _ => true, }; } - private static bool CheckChaptered(SVR_VideoLocal aniFile) + private static bool CheckChaptered(AniDB_File anidbFile, IMediaInfo media) { - return aniFile?.AniDBFile?.IsChaptered ?? (aniFile?.MediaInfo?.MenuStreams.Any() ?? false); + return anidbFile?.IsChaptered ?? media?.Chapters.Any() ?? false; } private static bool CheckDeprecated(AniDB_File aniFile) @@ -177,75 +143,35 @@ private static bool CheckDeprecated(AniDB_File aniFile) return !(aniFile?.IsDeprecated ?? false); } - private static bool CheckResolution(SVR_VideoLocal videoLocal) + private static bool CheckResolution(IMediaInfo media) { - var resTuple = GetResolutionInternal(videoLocal); - var res = MediaInfoUtils.GetStandardResolution(resTuple); - if (res == null) - { + if (media?.VideoStream is not { } videoStream || videoStream.Width == 0 || videoStream.Height == 0) return true; - } - - var resArea = resTuple.Item1 * resTuple.Item2; - - var operationType = Settings.RequiredResolutions.Operator; - switch (operationType) - { - case FileQualityFilterOperationType.EQUALS: - return res.Equals(Settings.RequiredResolutions.Value.FirstOrDefault()); - case FileQualityFilterOperationType.GREATER_EQ: - var keysGT = MediaInfoUtils.ResolutionArea.Keys.Where(a => resArea >= a).ToList(); - keysGT.AddRange(MediaInfoUtils.ResolutionArea43.Keys.Where(a => resArea >= a)); - var valuesGT = new List<string>(); - foreach (var key in keysGT) - { - if (MediaInfoUtils.ResolutionArea.TryGetValue(key, out var value)) - { - valuesGT.Add(value); - } - - if (MediaInfoUtils.ResolutionArea43.TryGetValue(key, out var value1)) - { - valuesGT.Add(value1); - } - } - - if (valuesGT.FindInEnumerable(Settings.RequiredResolutions.Value)) - { - return true; - } - break; - case FileQualityFilterOperationType.LESS_EQ: - var keysLT = MediaInfoUtils.ResolutionArea.Keys.Where(a => resArea <= a).ToList(); - keysLT.AddRange(MediaInfoUtils.ResolutionArea43.Keys.Where(a => resArea <= a)); - var valuesLT = new List<string>(); - foreach (var key in keysLT) - { - if (MediaInfoUtils.ResolutionArea.TryGetValue(key, out var value)) - { - valuesLT.Add(value); - } - - if (MediaInfoUtils.ResolutionArea43.TryGetValue(key, out var value1)) - { - valuesLT.Add(value1); - } - } - - if (valuesLT.FindInEnumerable(Settings.RequiredResolutions.Value)) - { - return true; - } - - break; - case FileQualityFilterOperationType.IN: - return Settings.RequiredResolutions.Value.Contains(res); - case FileQualityFilterOperationType.NOTIN: - return !Settings.RequiredResolutions.Value.Contains(res); - } - - return false; + var resolution = MediaInfoUtils.GetStandardResolution(new(videoStream.Width, videoStream.Height)); + var resolutionArea = videoStream.Width * videoStream.Height; + return Settings.RequiredResolutions.Operator switch + { + FileQualityFilterOperationType.EQUALS => + resolution.Equals(Settings.RequiredResolutions.Value.FirstOrDefault()), + FileQualityFilterOperationType.GREATER_EQ => + MediaInfoUtils.ResolutionArea169 + .Concat(MediaInfoUtils.ResolutionArea43) + .Where(pair => resolutionArea >= pair.Key) + .Select(pair => pair.Value) + .FindInEnumerable(Settings.RequiredResolutions.Value), + FileQualityFilterOperationType.LESS_EQ => + MediaInfoUtils.ResolutionArea169 + .Concat(MediaInfoUtils.ResolutionArea43) + .Where(pair => resolutionArea <= pair.Key) + .Select(pair => pair.Value) + .FindInEnumerable(Settings.RequiredResolutions.Value), + FileQualityFilterOperationType.IN => + Settings.RequiredResolutions.Value.Contains(resolution), + FileQualityFilterOperationType.NOTIN => + !Settings.RequiredResolutions.Value.Contains(resolution), + _ => false, + }; } private static bool CheckSource(SVR_AniDB_File aniFile) @@ -306,24 +232,23 @@ private static bool CheckSubStreamCount(SVR_VideoLocal file) }; } - private static bool CheckVideoCodec(SVR_VideoLocal aniFile) + private static bool CheckVideoCodec(IMediaInfo media) { - var codecs = - aniFile?.MediaInfo?.media.track.Where(a => a.type == StreamType.Video) - .Select(LegacyMediaUtils.TranslateCodec) - .OrderBy(a => a).ToArray() ?? Array.Empty<string>(); - - if (codecs.Length == 0) - { + var codecs = media?.TextStreams + .Select(stream => stream.Codec.Simplified) + .Where(codec => codec is not "unknown") + .OrderBy(codec => codec) + .ToList() ?? []; + if (codecs.Count == 0) return false; - } - var operationType = Settings.RequiredVideoCodecs.Operator; - return operationType switch + return Settings.RequiredVideoCodecs.Operator switch { - FileQualityFilterOperationType.IN => Settings.RequiredVideoCodecs.Value.FindInEnumerable(codecs), - FileQualityFilterOperationType.NOTIN => !Settings.RequiredVideoCodecs.Value.FindInEnumerable(codecs), - _ => true + FileQualityFilterOperationType.IN => + Settings.RequiredVideoCodecs.Value.FindInEnumerable(codecs), + FileQualityFilterOperationType.NOTIN => + !Settings.RequiredVideoCodecs.Value.FindInEnumerable(codecs), + _ => true, }; } @@ -332,317 +257,262 @@ private static bool CheckVideoCodec(SVR_VideoLocal aniFile) #region Comparisons // -1 if oldFile is to be deleted, 0 if they are comparatively equal, 1 if the oldFile is better - public static int CompareTo(this SVR_VideoLocal newFile, SVR_VideoLocal oldFile) + public static int CompareTo(SVR_VideoLocal newVideo, SVR_VideoLocal oldVideo) { - var oldEp = oldFile?.AniDBFile; - var newEp = newFile?.AniDBFile; - var result = 0; + if (newVideo == null && oldVideo == null) + return 0; + if (newVideo == null) + return 1; + if (oldVideo == null) + return -1; + var newMedia = newVideo.MediaInfo; + var newAnidbFile = newVideo.AniDBFile; + var oldMedia = oldVideo.MediaInfo; + var oldAnidbFile = oldVideo.AniDBFile; foreach (var type in Settings.PreferredTypes) { - switch (type) + var result = (type) switch { - case FileQualityFilterType.AUDIOCODEC: - result = CompareAudioCodecTo(newFile, oldFile); - break; - - case FileQualityFilterType.AUDIOSTREAMCOUNT: - result = CompareAudioStreamCountTo(newFile, oldFile); - break; - - case FileQualityFilterType.CHAPTER: - result = CompareChapterTo(newFile, newEp, oldFile, oldEp); - break; - - case FileQualityFilterType.RESOLUTION: - result = CompareResolutionTo(newFile, oldFile); - break; - - case FileQualityFilterType.SOURCE: - if (IsNullOrUnknown(newEp) && IsNullOrUnknown(oldEp)) - { - return 0; - } - - if (IsNullOrUnknown(newEp)) - { - return 1; - } - - if (IsNullOrUnknown(oldEp)) - { - return -1; - } - - result = CompareSourceTo(newEp, oldEp); - break; - - case FileQualityFilterType.SUBGROUP: - if (IsNullOrUnknown(newEp) && IsNullOrUnknown(oldEp)) - { - return 0; - } - - if (IsNullOrUnknown(newEp)) - { - return 1; - } - - if (IsNullOrUnknown(oldEp)) - { - return -1; - } - - result = CompareSubGroupTo(newEp, oldEp); - break; - - case FileQualityFilterType.SUBSTREAMCOUNT: - result = CompareSubStreamCountTo(newFile, oldFile); - break; - - case FileQualityFilterType.VERSION: - if (newEp == null) - { - return 1; - } - - if (oldEp == null) - { - return -1; - } - - result = CompareVersionTo(newFile, oldFile); - break; - - case FileQualityFilterType.VIDEOCODEC: - result = CompareVideoCodecTo(newFile, oldFile); - break; - } + FileQualityFilterType.AUDIOCODEC => + CompareAudioCodecTo(newMedia, oldMedia), + FileQualityFilterType.AUDIOSTREAMCOUNT => + CompareAudioStreamCountTo(newMedia, oldMedia), + FileQualityFilterType.CHAPTER => + CompareChapterTo(newMedia, newAnidbFile, oldMedia, oldAnidbFile), + FileQualityFilterType.RESOLUTION => + CompareResolutionTo(newMedia, oldMedia), + FileQualityFilterType.SOURCE => + CompareSourceTo(newAnidbFile, oldAnidbFile), + FileQualityFilterType.SUBGROUP => + CompareSubGroupTo(newAnidbFile, oldAnidbFile), + FileQualityFilterType.SUBSTREAMCOUNT => + CompareSubStreamCountTo(newMedia, oldMedia), + FileQualityFilterType.VERSION => + CompareVersionTo(newAnidbFile, oldAnidbFile, newMedia, oldMedia), + FileQualityFilterType.VIDEOCODEC => + CompareVideoCodecTo(newMedia, oldMedia), + _ => 0, + }; if (result != 0) - { return result; - } } return 0; } - private static int CompareAudioCodecTo(SVR_VideoLocal newFile, SVR_VideoLocal oldFile) + private static int CompareAudioCodecTo(IMediaInfo newMedia, IMediaInfo oldMedia) { - var newCodecs = newFile?.MediaInfo?.AudioStreams?.Select(LegacyMediaUtils.TranslateCodec) - .Where(a => a != null).OrderBy(a => a).ToArray() ?? Array.Empty<string>(); - var oldCodecs = oldFile?.MediaInfo?.AudioStreams?.Select(LegacyMediaUtils.TranslateCodec) - .Where(a => a != null).OrderBy(a => a).ToArray() ?? Array.Empty<string>(); + var newCodecs = newMedia?.AudioStreams + .Select(stream => stream.Codec.Simplified) + .Where(codec => codec is not "unknown") + .OrderBy(codec => codec) + .ToList() ?? []; + var oldCodecs = oldMedia?.AudioStreams + .Select(stream => stream.Codec.Simplified) + .Where(codec => codec is not "unknown") + .OrderBy(codec => codec) + .ToList() ?? []; // compare side by side, average codec quality would be vague and annoying, defer to number of audio tracks - if (newCodecs.Length != oldCodecs.Length) - { + if (newCodecs.Count != oldCodecs.Count) return 0; - } - for (var i = 0; i < Math.Min(newCodecs.Length, oldCodecs.Length); i++) + var max = Math.Min(newCodecs.Count, oldCodecs.Count); + for (var i = 0; i < max; i++) { var newCodec = newCodecs[i]; var oldCodec = oldCodecs[i]; var newIndex = Settings.PreferredAudioCodecs.IndexOf(newCodec); var oldIndex = Settings.PreferredAudioCodecs.IndexOf(oldCodec); - if (newIndex < 0 || oldIndex < 0) - { + if (newIndex == -1 || oldIndex == -1) continue; - } var result = newIndex.CompareTo(oldIndex); if (result != 0) - { return result; - } } return 0; } - private static int CompareAudioStreamCountTo(SVR_VideoLocal newFile, SVR_VideoLocal oldFile) + private static int CompareAudioStreamCountTo(IMediaInfo newMedia, IMediaInfo oldMedia) { - var newStreamCount = newFile?.MediaInfo?.AudioStreams.Count ?? 0; - var oldStreamCount = oldFile?.MediaInfo?.AudioStreams.Count ?? 0; + var newStreamCount = newMedia?.AudioStreams.Count ?? 0; + var oldStreamCount = oldMedia?.AudioStreams.Count ?? 0; return oldStreamCount.CompareTo(newStreamCount); } - private static int CompareChapterTo(SVR_VideoLocal newFile, AniDB_File newAniFile, SVR_VideoLocal oldFile, - AniDB_File oldAniFile) + private static int CompareChapterTo(IMediaInfo newMedia, SVR_AniDB_File newFile, IMediaInfo oldMedia, SVR_AniDB_File oldFile) { - if ((newAniFile?.IsChaptered ?? (newFile?.MediaInfo?.MenuStreams.Any() ?? false)) && - !(oldAniFile?.IsChaptered ?? (oldFile?.MediaInfo?.MenuStreams.Any() ?? false))) - { - return -1; - } - - if (!(newAniFile?.IsChaptered ?? (newFile?.MediaInfo?.MenuStreams.Any() ?? false)) && - (oldAniFile?.IsChaptered ?? (oldFile?.MediaInfo?.MenuStreams.Any() ?? false))) - { - return 1; - } - - return (oldAniFile?.IsChaptered ?? (oldFile?.MediaInfo?.MenuStreams.Any() ?? false)).CompareTo( - newAniFile?.IsChaptered ?? (newFile?.MediaInfo?.MenuStreams.Any() ?? false)); + var newIsChaptered = newFile?.IsChaptered ?? newMedia?.Chapters.Any() ?? false; + var oldIsChaptered = oldFile?.IsChaptered ?? oldMedia?.Chapters.Any() ?? false; + return oldIsChaptered.CompareTo(newIsChaptered); } - private static int CompareResolutionTo(SVR_VideoLocal newFile, SVR_VideoLocal oldFile) + private static int CompareResolutionTo(IMediaInfo newMedia, IMediaInfo oldMedia) { - var oldRes = GetResolution(oldFile); - var newRes = GetResolution(newFile); - - switch (newRes) - { - case null when oldRes == null: - return 0; - case null: - return 1; - } - - if (oldRes == null) - { + var newRes = newMedia?.VideoStream is { } newVideo ? newVideo.Resolution : "unknown"; + var oldRes = oldMedia?.VideoStream is { } oldVideo ? oldVideo.Resolution : "unknown"; + if (newRes == "unknown" && oldRes == "unknown") + return 0; + if (newRes == "unknown") + return 1; + if (oldRes == "unknown") return -1; - } - var res = Settings.PreferredResolutions.ToArray(); - switch (res.Contains(newRes)) - { - case false when !res.Contains(oldRes): - return 0; - case false: - return 1; - } - - if (!res.Contains(oldRes)) - { + var newIndex = Settings.PreferredResolutions.IndexOf(newRes); + var oldIndex = Settings.PreferredResolutions.IndexOf(oldRes); + if (newIndex == -1 && oldIndex == -1) + return 0; + if (newIndex == -1) + return 1; + if (oldIndex == -1) return -1; - } - var newIndex = Array.IndexOf(res, newRes); - var oldIndex = Array.IndexOf(res, oldRes); return newIndex.CompareTo(oldIndex); } - private static int CompareSourceTo(AniDB_File newFile, AniDB_File oldFile) + private static int CompareSourceTo(SVR_AniDB_File newFile, SVR_AniDB_File oldFile) { - var newSource = newFile.File_Source.ToLowerInvariant(); - if (FileQualityPreferences.SimplifiedSources.TryGetValue(newSource, out var source)) - { - newSource = source; - } + var newAnidbFileIsNullOrUnknown = IsNullOrUnknown(newFile); + var oldAnidbFileIsNullOrUnknown = IsNullOrUnknown(oldFile); + if (newAnidbFileIsNullOrUnknown && oldAnidbFileIsNullOrUnknown) + return 0; + if (newAnidbFileIsNullOrUnknown) + return 1; + if (oldAnidbFileIsNullOrUnknown) + return -1; - var oldSource = oldFile.File_Source.ToLowerInvariant(); - if (FileQualityPreferences.SimplifiedSources.TryGetValue(oldSource, out var simplifiedSource)) - { - oldSource = simplifiedSource; - } + var newSource = newFile!.File_Source.ToLowerInvariant(); + if (FileQualityPreferences.SimplifiedSources.TryGetValue(newSource, out var value)) + newSource = value; + + var oldSource = oldFile!.File_Source.ToLowerInvariant(); + if (FileQualityPreferences.SimplifiedSources.TryGetValue(oldSource, out value)) + oldSource = value; var newIndex = Settings.PreferredSources.IndexOf(newSource); var oldIndex = Settings.PreferredSources.IndexOf(oldSource); + if (newIndex == -1 && oldIndex == -1) + return 0; + if (newIndex == -1) + return 1; + if (oldIndex == -1) + return -1; return newIndex.CompareTo(oldIndex); } private static int CompareSubGroupTo(SVR_AniDB_File newFile, SVR_AniDB_File oldFile) { - if (IsNullOrUnknown(newFile) || IsNullOrUnknown(oldFile)) - { + var newAnidbFileIsNullOrUnknown = IsNullOrUnknown(newFile); + var oldAnidbFileIsNullOrUnknown = IsNullOrUnknown(oldFile); + if (newAnidbFileIsNullOrUnknown && oldAnidbFileIsNullOrUnknown) return 0; - } - - if (!Settings.PreferredSubGroups.Contains(newFile.Anime_GroupName.ToLowerInvariant()) && - !Settings.PreferredSubGroups.Contains(newFile.Anime_GroupNameShort.ToLowerInvariant())) - { - return 0; - } + if (newAnidbFileIsNullOrUnknown) + return 1; + if (oldAnidbFileIsNullOrUnknown) + return -1; - if (!Settings.PreferredSubGroups.Contains(oldFile.Anime_GroupName.ToLowerInvariant()) && - !Settings.PreferredSubGroups.Contains(oldFile.Anime_GroupNameShort.ToLowerInvariant())) - { + var newIndex = -1; + var newGroup = newFile!.ReleaseGroup; + if (!string.IsNullOrEmpty(newGroup.GroupName)) + newIndex = Settings.PreferredSubGroups.IndexOf(newGroup.GroupName); + if (newIndex == -1 && !string.IsNullOrEmpty(newGroup.GroupNameShort)) + newIndex = Settings.PreferredSubGroups.IndexOf(newGroup.GroupNameShort); + + var oldIndex = -1; + var oldGroup = oldFile!.ReleaseGroup; + if (!string.IsNullOrEmpty(oldGroup.GroupName)) + oldIndex = Settings.PreferredSubGroups.IndexOf(oldGroup.GroupName); + if (oldIndex == -1 && !string.IsNullOrEmpty(oldGroup.GroupNameShort)) + oldIndex = Settings.PreferredSubGroups.IndexOf(oldGroup.GroupNameShort); + + if (newIndex == -1 && oldIndex == -1) return 0; - } - - // The above ensures that _subgroups contains both, so no need to check for -1 in this case - var newIndex = Settings.PreferredSubGroups.IndexOf(newFile.Anime_GroupName.ToLowerInvariant()); if (newIndex == -1) - { - newIndex = Settings.PreferredSubGroups.IndexOf(newFile.Anime_GroupNameShort.ToLowerInvariant()); - } - - var oldIndex = Settings.PreferredSubGroups.IndexOf(oldFile.Anime_GroupName.ToLowerInvariant()); + return 1; if (oldIndex == -1) - { - oldIndex = Settings.PreferredSubGroups.IndexOf(oldFile.Anime_GroupNameShort.ToLowerInvariant()); - } - + return -1; return newIndex.CompareTo(oldIndex); } - private static int CompareSubStreamCountTo(SVR_VideoLocal newFile, SVR_VideoLocal oldFile) + private static int CompareSubStreamCountTo(IMediaInfo newMedia, IMediaInfo oldMedia) { - var newStreamCount = newFile?.MediaInfo?.TextStreams?.Count ?? 0; - var oldStreamCount = oldFile?.MediaInfo?.TextStreams?.Count ?? 0; + var newStreamCount = newMedia?.TextStreams.Count ?? 0; + var oldStreamCount = oldMedia?.TextStreams.Count ?? 0; return oldStreamCount.CompareTo(newStreamCount); } - private static int CompareVersionTo(SVR_VideoLocal newFile, SVR_VideoLocal oldFile) + private static int CompareVersionTo(SVR_AniDB_File newFile, SVR_AniDB_File oldFile, IMediaInfo newMedia, IMediaInfo oldMedia) { - var newAni = newFile?.AniDBFile; - var oldAni = oldFile?.AniDBFile; - if (IsNullOrUnknown(newAni) || IsNullOrUnknown(oldAni))return 0; - if (!newAni.Anime_GroupName.Equals(oldAni.Anime_GroupName))return 0; - if (!(newFile.MediaInfo?.VideoStream?.BitDepth).Equals(oldFile.MediaInfo?.VideoStream?.BitDepth))return 0; - if (!string.Equals(newFile.MediaInfo?.VideoStream?.CodecID, oldFile.MediaInfo?.VideoStream?.CodecID))return 0; - - return oldAni.FileVersion.CompareTo(newAni.FileVersion); + var newAnidbFileIsNullOrUnknown = IsNullOrUnknown(newFile); + var oldAnidbFileIsNullOrUnknown = IsNullOrUnknown(oldFile); + if (newAnidbFileIsNullOrUnknown && oldAnidbFileIsNullOrUnknown) + return 0; + if (newAnidbFileIsNullOrUnknown) + return 1; + if (oldAnidbFileIsNullOrUnknown) + return -1; + + if (newFile!.GroupID != oldFile!.GroupID) + return 0; + + var newBitDepth = newMedia?.VideoStream?.BitDepth ?? -1; + var oldBitDepth = oldMedia?.VideoStream?.BitDepth ?? -1; + if (newBitDepth != oldBitDepth) + return 0; + + var newSimpleCodec = newMedia?.VideoStream?.Codec.Simplified; + var oldSimpleCodec = oldMedia?.VideoStream?.Codec.Simplified; + if (!string.Equals(newSimpleCodec, oldSimpleCodec)) + return 0; + + return oldFile.FileVersion.CompareTo(newFile.FileVersion); } - private static int CompareVideoCodecTo(SVR_VideoLocal newLocal, SVR_VideoLocal oldLocal) + private static int CompareVideoCodecTo(IMediaInfo newMedia, IMediaInfo oldMedia) { - var newCodecs = - newLocal?.MediaInfo?.media?.track?.Where(a => a?.type == StreamType.Video) - .Select(LegacyMediaUtils.TranslateCodec).Where(a => a != null).OrderBy(a => a).ToArray() ?? - Array.Empty<string>(); - var oldCodecs = - oldLocal?.MediaInfo?.media?.track?.Where(a => a?.type == StreamType.Video) - .Select(LegacyMediaUtils.TranslateCodec).Where(a => a != null).OrderBy(a => a).ToArray() ?? - Array.Empty<string>(); + var newCodecs = newMedia?.VideoStreams + .Select(stream => stream.Codec.Simplified) + .Where(codec => codec is not "unknown") + .OrderBy(codec => codec) + .ToList() ?? []; + var oldCodecs = oldMedia?.VideoStreams + .Select(stream => stream.Codec.Simplified) + .Where(codec => codec is not "unknown") + .OrderBy(codec => codec) + .ToList() ?? []; // compare side by side, average codec quality would be vague and annoying, defer to number of audio tracks - if (newCodecs.Length != oldCodecs.Length) - { + if (newCodecs.Count != oldCodecs.Count) return 0; - } - for (var i = 0; i < Math.Min(newCodecs.Length, oldCodecs.Length); i++) + var max = Math.Min(newCodecs.Count, oldCodecs.Count); + for (var i = 0; i < max; i++) { var newCodec = newCodecs[i]; var oldCodec = oldCodecs[i]; var newIndex = Settings.PreferredVideoCodecs.IndexOf(newCodec); var oldIndex = Settings.PreferredVideoCodecs.IndexOf(oldCodec); - if (newIndex < 0 || oldIndex < 0) + if (newIndex == -1 || oldIndex == -1) { continue; } var result = newIndex.CompareTo(oldIndex); if (result != 0) - { return result; - } - if (newLocal?.MediaInfo?.VideoStream?.BitDepth == null || - oldLocal?.MediaInfo?.VideoStream?.BitDepth == null) - { + var newBitDepth = newMedia?.VideoStream?.BitDepth ?? -1; + var oldBitDepth = oldMedia?.VideoStream?.BitDepth ?? -1; + if (newBitDepth == -1 || oldBitDepth == -1) continue; - } - switch (newLocal.MediaInfo.VideoStream.BitDepth) - { - case 8 when oldLocal.MediaInfo.VideoStream.BitDepth == 10: - return Settings.Prefer8BitVideo ? -1 : 1; - case 10 when oldLocal.MediaInfo.VideoStream.BitDepth == 8: - return Settings.Prefer8BitVideo ? 1 : -1; - } + if (newBitDepth == 8 && oldBitDepth == 10) + return Settings.Prefer8BitVideo ? -1 : 1; + + if (newBitDepth == 10 && oldBitDepth == 8) + return Settings.Prefer8BitVideo ? 1 : -1; } return 0; @@ -652,107 +522,26 @@ private static int CompareVideoCodecTo(SVR_VideoLocal newLocal, SVR_VideoLocal o #region Information from Models (Operations that aren't simple) - public static string GetResolution(SVR_VideoLocal videoLocal) - { - return MediaInfoUtils.GetStandardResolution(GetResolutionInternal(videoLocal)); - } - - public static string GetResolution(string res) - { - if (string.IsNullOrEmpty(res)) - { - return null; - } - - var parts = res.Split('x'); - if (parts.Length != 2) - { - return null; - } - - if (!int.TryParse(parts[0], out var width)) - { - return null; - } - - if (!int.TryParse(parts[1], out var height)) - { - return null; - } - - return MediaInfoUtils.GetStandardResolution(new Tuple<int, int>(width, height)); - } - - private static Tuple<int, int> GetResolutionInternal(SVR_VideoLocal videoLocal) - { - var oldHeight = 0; - var oldWidth = 0; - var stream = videoLocal?.MediaInfo?.VideoStream; - if (stream != null) - { - oldWidth = stream.Width; - oldHeight = stream.Height; - } - - if (oldHeight == 0 || oldWidth == 0) - { - return null; - } - - return new Tuple<int, int>(oldWidth, oldHeight); - } - private static bool IsNullOrUnknown([NotNullWhen(false)][MaybeNullWhen(true)] SVR_AniDB_File file) { - if (file == null) - { - return true; - } - - if (string.IsNullOrWhiteSpace(file.File_Source)) - { - return true; - } - - if (string.IsNullOrWhiteSpace(file.Anime_GroupName)) - { - return true; - } - - if (string.IsNullOrWhiteSpace(file.Anime_GroupNameShort)) - { - return true; - } - - if (file.Anime_GroupName.EqualsInvariantIgnoreCase("unknown")) - { + // Check file. + if (file is null || + string.IsNullOrWhiteSpace(file.File_Source) || + string.Equals(file.File_Source, "unknown", StringComparison.InvariantCultureIgnoreCase) || + string.Equals(file.File_Source, "raw", StringComparison.InvariantCultureIgnoreCase)) return true; - } - if (file.Anime_GroupNameShort.EqualsInvariantIgnoreCase("unknown")) - { + // Check release group. + var releaseGroup = file.ReleaseGroup; + if (string.IsNullOrWhiteSpace(releaseGroup.GroupName) || + string.Equals(releaseGroup.GroupName, "unknown", StringComparison.InvariantCultureIgnoreCase) || + string.Equals(releaseGroup.GroupName, "raw", StringComparison.InvariantCultureIgnoreCase)) return true; - } - if (file.Anime_GroupName.EqualsInvariantIgnoreCase("raw")) - { + if (string.IsNullOrWhiteSpace(releaseGroup.GroupNameShort) || + string.Equals(releaseGroup.GroupNameShort, "unknown", StringComparison.InvariantCultureIgnoreCase) || + string.Equals(releaseGroup.GroupNameShort, "raw", StringComparison.InvariantCultureIgnoreCase)) return true; - } - - if (file.Anime_GroupNameShort.EqualsInvariantIgnoreCase("raw")) - { - return true; - } - - if (file.File_Source.EqualsInvariantIgnoreCase("unknown")) - { - return true; - } - - if (file.File_Source.EqualsInvariantIgnoreCase("raw")) - { - return true; - } return false; } diff --git a/Shoko.Server/Utilities/ImageUtils.cs b/Shoko.Server/Utilities/ImageUtils.cs new file mode 100644 index 000000000..c19e77305 --- /dev/null +++ b/Shoko.Server/Utilities/ImageUtils.cs @@ -0,0 +1,273 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using Shoko.Commons.Extensions; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Extensions; +using Shoko.Server.Models; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.Actions; + +#nullable enable +namespace Shoko.Server.Utilities; + +public class ImageUtils +{ + private static ISchedulerFactory? __schedulerFactory = null; + + private static ISchedulerFactory _schedulerFactory + => __schedulerFactory ??= Utils.ServiceContainer.GetService<ISchedulerFactory>()!; + + public static string? ResolvePath(string? relativePath) + { + if (string.IsNullOrEmpty(relativePath)) + return null; + + var filePath = Path.Join(Path.TrimEndingDirectorySeparator(GetBaseImagesPath()), relativePath); + var dirPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dirPath) && !Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + return filePath; + } + + public static string GetBaseImagesPath() + { + var settings = Utils.SettingsProvider?.GetSettings(); + var baseDirPath = !string.IsNullOrEmpty(settings?.ImagesPath) ? + Path.Combine(Utils.ApplicationPath, settings.ImagesPath) : Utils.DefaultImagePath; + if (!Directory.Exists(baseDirPath)) + Directory.CreateDirectory(baseDirPath); + + return baseDirPath; + } + + public static string GetBaseAniDBImagesPath() + { + var dirPath = Path.Combine(GetBaseImagesPath(), "AniDB"); + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + return dirPath; + } + + public static string GetBaseAniDBCharacterImagesPath() + { + var dirPath = Path.Combine(GetBaseImagesPath(), "AniDB_Char"); + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + return dirPath; + } + + public static string GetBaseAniDBCreatorImagesPath() + { + var dirPath = Path.Combine(GetBaseImagesPath(), "AniDB_Creator"); + + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + return dirPath; + } + + public static string GetAniDBCharacterImagePath(int charID) + { + var sid = charID.ToString(); + var subFolder = sid.Length == 1 ? sid : sid[..2]; + var dirPath = Path.Combine(GetBaseAniDBCharacterImagesPath(), subFolder); + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + return dirPath; + } + + public static string GetAniDBCreatorImagePath(int creatorID) + { + var sid = creatorID.ToString(); + var subFolder = sid.Length == 1 ? sid : sid[..2]; + var dirPath = Path.Combine(GetBaseAniDBCreatorImagesPath(), subFolder); + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + return dirPath; + } + + public static string GetAniDBImagePath(int animeID) + { + var sid = animeID.ToString(); + var subFolder = sid.Length == 1 ? sid : sid[..2]; + var dirPath = Path.Combine(GetBaseAniDBImagesPath(), subFolder); + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + return dirPath; + } + + public static IImageMetadata? GetImageMetadata(CL_ImageEntityType imageEntityType, int imageId) + => GetImageMetadata(imageEntityType.ToServerSource(), imageEntityType.ToServerType(), imageId); + + public static IImageMetadata? GetImageMetadata(DataSourceEnum dataSource, ImageEntityType imageType, int imageId) + => GetImageMetadata(dataSource.ToDataSourceType(), imageType, imageId); + + public static IImageMetadata? GetImageMetadata(DataSourceType dataSource, ImageEntityType imageType, int imageId) + => dataSource switch + { + DataSourceType.AniDB => imageType switch + { + ImageEntityType.Character => RepoFactory.AniDB_Character.GetByCharID(imageId)?.GetImageMetadata(), + ImageEntityType.Person => RepoFactory.AniDB_Creator.GetByCreatorID(imageId)?.GetImageMetadata(), + ImageEntityType.Poster => RepoFactory.AniDB_Anime.GetByAnimeID(imageId)?.GetImageMetadata(), + _ => null, + }, + DataSourceType.Shoko => imageType switch + { + ImageEntityType.Character => RepoFactory.AnimeCharacter.GetByID(imageId)?.GetImageMetadata(), + ImageEntityType.Person => RepoFactory.AnimeStaff.GetByID(imageId)?.GetImageMetadata(), + _ => null, + }, + DataSourceType.TMDB => RepoFactory.TMDB_Image.GetByID(imageId), + _ => null, + }; + + public static IImageMetadata? GetRandomImageID(CL_ImageEntityType imageType) + => GetRandomImageID(imageType.ToServerSource(), imageType.ToServerType()); + + public static IImageMetadata? GetRandomImageID(DataSourceType dataSource, ImageEntityType imageType) + { + return dataSource switch + { + DataSourceType.AniDB => imageType switch + { + ImageEntityType.Poster => RepoFactory.AniDB_Anime.GetAll() + .Where(a => a?.PosterPath is not null && !a.GetAllTags().Contains("18 restricted")) + .GetRandomElement()?.GetImageMetadata(false), + ImageEntityType.Character => RepoFactory.AniDB_Anime.GetAll() + .Where(a => a is not null && !a.GetAllTags().Contains("18 restricted")) + .SelectMany(a => a.Characters).Select(a => a.GetCharacter()).WhereNotNull() + .GetRandomElement()?.GetImageMetadata(), + ImageEntityType.Person => RepoFactory.AniDB_Anime.GetAll() + .Where(a => a is not null && !a.GetAllTags().Contains("18 restricted")) + .SelectMany(a => a.Characters) + .SelectMany(a => RepoFactory.AniDB_Character_Creator.GetByCharacterID(a.CharID)) + .Select(a => RepoFactory.AniDB_Creator.GetByCreatorID(a.CreatorID)).WhereNotNull() + .GetRandomElement()?.GetImageMetadata(), + _ => null, + }, + DataSourceType.Shoko => imageType switch + { + ImageEntityType.Character => RepoFactory.AniDB_Anime.GetAll() + .Where(a => a is not null && !a.GetAllTags().Contains("18 restricted")) + .SelectMany(a => RepoFactory.CrossRef_Anime_Staff.GetByAnimeID(a.AnimeID)) + .Where(a => a.RoleType == (int)StaffRoleType.Seiyuu && a.RoleID.HasValue) + .Select(a => RepoFactory.AnimeCharacter.GetByID(a.RoleID!.Value)) + .GetRandomElement()?.GetImageMetadata(), + ImageEntityType.Person => RepoFactory.AniDB_Anime.GetAll() + .Where(a => a is not null && !a.GetAllTags().Contains("18 restricted")) + .SelectMany(a => RepoFactory.CrossRef_Anime_Staff.GetByAnimeID(a.AnimeID)) + .Select(a => RepoFactory.AnimeStaff.GetByID(a.StaffID)) + .GetRandomElement()?.GetImageMetadata(), + _ => null, + }, + DataSourceType.TMDB => RepoFactory.TMDB_Image.GetByType(imageType) + .GetRandomElement(), + _ => null, + }; + } + + public static SVR_AnimeSeries? GetFirstSeriesForImage(IImageMetadata metadata) + { + switch (metadata.Source) + { + case DataSourceEnum.AniDB: + switch (metadata.ImageType) + { + case ImageEntityType.Poster: + return RepoFactory.AnimeSeries.GetByAnimeID(metadata.ID); + } + + return null; + + case DataSourceEnum.TMDB: + var tmdbImage = metadata as TMDB_Image ?? RepoFactory.TMDB_Image.GetByID(metadata.ID); + if (tmdbImage is null || !(tmdbImage.TmdbMovieID.HasValue || tmdbImage.TmdbShowID.HasValue)) + return null; + + if (tmdbImage.TmdbMovieID.HasValue) + { + var movieXref = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByTmdbMovieID(tmdbImage.TmdbMovieID.Value) is { Count: > 0 } movieXrefs ? movieXrefs[0] : null; + if (movieXref == null) + return null; + + return RepoFactory.AnimeSeries.GetByAnimeID(movieXref.AnidbAnimeID); + } + + if (tmdbImage.TmdbShowID.HasValue) + { + var showXref = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(tmdbImage.TmdbShowID.Value) is { Count: > 0 } showXrefs ? showXrefs[0] : null; + if (showXref == null) + return null; + + return RepoFactory.AnimeSeries.GetByAnimeID(showXref.AnidbAnimeID); + } + + return null; + + default: + return null; + } + } + + public static bool SetEnabled(DataSourceType dataSource, ImageEntityType imageType, int imageId, bool value = true) + { + var animeIDs = new HashSet<int>(); + switch (dataSource) + { + case DataSourceType.AniDB: + switch (imageType) + { + case ImageEntityType.Poster: + var anime = RepoFactory.AniDB_Anime.GetByAnimeID(imageId); + if (anime == null) + return false; + + anime.ImageEnabled = value ? 1 : 0; + RepoFactory.AniDB_Anime.Save(anime); + break; + + default: + return false; + } + break; + + case DataSourceType.TMDB: + var tmdbImage = RepoFactory.TMDB_Image.GetByID(imageId); + if (tmdbImage == null) + return false; + + tmdbImage.IsEnabled = value; + RepoFactory.TMDB_Image.Save(tmdbImage); + if (tmdbImage.TmdbShowID.HasValue) + foreach (var xref in RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(tmdbImage.TmdbShowID.Value)) + animeIDs.Add(xref.AnidbAnimeID); + if (tmdbImage.TmdbMovieID.HasValue) + foreach (var xref in RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByTmdbMovieID(tmdbImage.TmdbMovieID.Value)) + animeIDs.Add(xref.AnidbAnimeID); + break; + + default: + return false; + } + + var scheduler = _schedulerFactory.GetScheduler().ConfigureAwait(false).GetAwaiter().GetResult(); + foreach (var animeID in animeIDs) + scheduler.StartJob<RefreshAnimeStatsJob>(a => a.AnimeID = animeID).GetAwaiter().GetResult(); + + return true; + } +} diff --git a/Shoko.Server/Utilities/JsonExtensions.cs b/Shoko.Server/Utilities/JsonExtensions.cs index cf5eb52e5..e9b67f01f 100644 --- a/Shoko.Server/Utilities/JsonExtensions.cs +++ b/Shoko.Server/Utilities/JsonExtensions.cs @@ -25,38 +25,10 @@ public static IEnumerable<T> FromJSONArray<T>(this string jsonArray) return new List<T>(); } - try { - /*var settings = new JsonSerializerSettings - { - Error = (sender, args) => - { - if (System.Diagnostics.Debugger.IsAttached) - { - System.Diagnostics.Debugger.Break(); - } - } - };*/ - - //var result = JsonConvert.DeserializeObject<IEnumerable<T>>(jsonArray, settings); var result = JsonConvert.DeserializeObject<IEnumerable<T>>(jsonArray); return result ?? new List<T>(); - - /*using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(jsonArray))) - { - var ser = new DataContractJsonSerializer(typeof(IEnumerable<T>)); - var result = (IEnumerable<T>)ser.ReadObject(ms); - - if (result == null) - { - return new List<T>(); - } - else - { - return result; - } - }*/ } catch (Exception ex) { diff --git a/Shoko.Server/Utilities/Languages.cs b/Shoko.Server/Utilities/Languages.cs index 8e893c7b8..65acc0839 100644 --- a/Shoko.Server/Utilities/Languages.cs +++ b/Shoko.Server/Utilities/Languages.cs @@ -1,62 +1,151 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.Repositories; +#nullable enable namespace Shoko.Server.Utilities; public static class Languages { + private static readonly TitleLanguage[] _invalidLanguages = [TitleLanguage.Unknown, TitleLanguage.None]; + public static List<NamingLanguage> AllNamingLanguages => - Enum.GetValues<TitleLanguage>().Select(l => new NamingLanguage(l)).ToList(); + Enum.GetValues<TitleLanguage>() + .Except(_invalidLanguages) + .Select(l => new NamingLanguage(l)) + .ToList(); + + private static List<NamingLanguage>? _preferredNamingLanguages; - private static List<NamingLanguage> _preferredNamingLanguages; + private static readonly object _lockObj = new(); public static List<NamingLanguage> PreferredNamingLanguages { get { - if (_preferredNamingLanguages != null) + if (_preferredNamingLanguages is not null) return _preferredNamingLanguages; - var preference = Utils.SettingsProvider.GetSettings().LanguagePreference ?? new(); - return _preferredNamingLanguages = preference - .Where(l => !string.IsNullOrEmpty(l)) - .Select(l => new NamingLanguage(l)) - .Where(l => l.Language != TitleLanguage.Unknown) - .ToList(); + lock (_lockObj) + { + if (_preferredNamingLanguages is not null) + return _preferredNamingLanguages; + + _preferredNamingLanguages = null; + var preference = Utils.SettingsProvider.GetSettings().Language.SeriesTitleLanguageOrder ?? []; + _preferredNamingLanguages = preference + .Where(l => !string.IsNullOrEmpty(l)) + .Select(l => new NamingLanguage(l)) + .ExceptBy(_invalidLanguages, l => l.Language) + .ToList(); + + return _preferredNamingLanguages; + } + } + set + { + if (Utils.SettingsProvider is null) + return; + + lock (_lockObj) + { + var preference = Utils.SettingsProvider.GetSettings().Language.SeriesTitleLanguageOrder ?? []; + _preferredNamingLanguages = preference + .Where(l => !string.IsNullOrEmpty(l)) + .Select(l => new NamingLanguage(l)) + .ExceptBy(_invalidLanguages, l => l.Language) + .ToList(); + + // Reset all preferred titles when the language setting has been updated. + Task.Run(() => RepoFactory.AnimeSeries.GetAll().AsParallel().ForAll(series => series.ResetPreferredTitle())); + Task.Run(() => RepoFactory.AniDB_Anime.GetAll().AsParallel().ForAll(anime => anime.ResetPreferredTitle())); + } } - set => _preferredNamingLanguages = value; } - private static List<TitleLanguage> _preferredNamingLanguageNames; - public static List<TitleLanguage> PreferredNamingLanguageNames + private static List<NamingLanguage>? _preferredEpisodeNamingLanguages; + + public static List<NamingLanguage> PreferredEpisodeNamingLanguages { get { - if (_preferredNamingLanguageNames != null) return _preferredNamingLanguageNames; - _preferredNamingLanguageNames = PreferredNamingLanguages.Select(a => a.Language).ToList(); - return _preferredNamingLanguageNames; + if (_preferredEpisodeNamingLanguages is not null) + return _preferredEpisodeNamingLanguages; + lock (_lockObj) + { + if (_preferredEpisodeNamingLanguages is not null) + return _preferredEpisodeNamingLanguages; + + var preference = Utils.SettingsProvider.GetSettings().Language.EpisodeTitleLanguageOrder ?? []; + _preferredEpisodeNamingLanguages = preference + .Where(l => !string.IsNullOrEmpty(l)) + .Select(l => new NamingLanguage(l)) + .ExceptBy(_invalidLanguages, l => l.Language) + .ToList(); + + return _preferredEpisodeNamingLanguages; + } + } + set + { + if (Utils.SettingsProvider is null) + return; + + lock (_lockObj) + { + var preference = Utils.SettingsProvider.GetSettings().Language.EpisodeTitleLanguageOrder ?? []; + _preferredEpisodeNamingLanguages = preference + .Where(l => !string.IsNullOrEmpty(l)) + .Select(l => new NamingLanguage(l)) + .ExceptBy(_invalidLanguages, l => l.Language) + .ToList(); + } } - set => _preferredNamingLanguageNames = value; } - private static List<NamingLanguage> _preferredEpisodeNamingLanguages; + private static List<NamingLanguage>? _preferredDescriptionNamingLanguages; - public static List<NamingLanguage> PreferredEpisodeNamingLanguages + public static List<NamingLanguage> PreferredDescriptionNamingLanguages { get { - if (_preferredEpisodeNamingLanguages != null) - return _preferredEpisodeNamingLanguages; + if (_preferredDescriptionNamingLanguages is not null) + return _preferredDescriptionNamingLanguages; + lock (_lockObj) + { + if (_preferredDescriptionNamingLanguages is not null) + return _preferredDescriptionNamingLanguages; + + var preference = Utils.SettingsProvider.GetSettings().Language.DescriptionLanguageOrder ?? []; + _preferredDescriptionNamingLanguages = preference + .Where(l => !string.IsNullOrEmpty(l)) + .Select(l => new NamingLanguage(l)) + .ExceptBy(_invalidLanguages, l => l.Language) + .ToList(); + + return _preferredDescriptionNamingLanguages; + } + } + set + { + if (Utils.SettingsProvider is null) + return; + + lock (_lockObj) + { + var preference = Utils.SettingsProvider.GetSettings().Language.DescriptionLanguageOrder ?? []; + _preferredDescriptionNamingLanguages = preference + .Where(l => !string.IsNullOrEmpty(l)) + .Select(l => new NamingLanguage(l)) + .ExceptBy(_invalidLanguages, l => l.Language) + .ToList(); - var preference = Utils.SettingsProvider.GetSettings().EpisodeLanguagePreference ?? new(); - return _preferredEpisodeNamingLanguages = preference - .Where(l => !string.IsNullOrEmpty(l)) - .Select(l => new NamingLanguage(l)) - .Where(l => l.Language != TitleLanguage.Unknown) - .ToList(); + // Reset all preferred overviews when the language setting has been updated. + Task.Run(() => RepoFactory.AnimeSeries.GetAll().AsParallel().ForAll(ser => ser.ResetPreferredOverview())); + } } - set => _preferredEpisodeNamingLanguages = value; } } diff --git a/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs b/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs index 3bccf9b61..6251ff280 100644 --- a/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs +++ b/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs @@ -62,7 +62,7 @@ private static MediaContainer GetMediaInfo_Internal(string filename) // assuming json, as it starts with { var m = JsonConvert.DeserializeObject<MediaContainer>(output, settings); - if (m == null) + if (m == null || m.media == null) { throw new Exception($"Unable to deserialize MediaInfo response: {output}"); } diff --git a/Shoko.Server/Utilities/MediaInfoLib/MediaInfoDLLInternal.cs b/Shoko.Server/Utilities/MediaInfoLib/MediaInfoDLLInternal.cs index 1d1dd5efc..7ecba1572 100644 --- a/Shoko.Server/Utilities/MediaInfoLib/MediaInfoDLLInternal.cs +++ b/Shoko.Server/Utilities/MediaInfoLib/MediaInfoDLLInternal.cs @@ -205,7 +205,7 @@ public MediaInfoDLLInternal() { Handle = MediaInfo_New(); } - catch (Exception ex) + catch { Handle = (IntPtr)0; } diff --git a/Shoko.Server/Utilities/MediaInfoLib/MediaInfoParserInternal.cs b/Shoko.Server/Utilities/MediaInfoLib/MediaInfoParserInternal.cs index 1ee820ad7..0e5668b91 100644 --- a/Shoko.Server/Utilities/MediaInfoLib/MediaInfoParserInternal.cs +++ b/Shoko.Server/Utilities/MediaInfoLib/MediaInfoParserInternal.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Runtime; -using System.Runtime.ExceptionServices; using System.Threading.Tasks; using NLog; using Shoko.Models.PlexAndKodi; @@ -594,7 +593,6 @@ private static void CloseMediaInfo() _mInstance = null; } - [HandleProcessCorruptedStateExceptions] private static Media GetMediaInfo(string filename) { lock (Lock) @@ -722,7 +720,7 @@ private static Media GetMediaInfo(string filename) } catch (Exception e) { - _logger.Error($"Unable to parse video stream in {filename}"); + _logger.Error(e, $"Unable to parse video stream in {filename}"); } } } @@ -774,7 +772,7 @@ private static Media GetMediaInfo(string filename) } catch (Exception e) { - _logger.Error($"Unable to parse audio stream in {filename}"); + _logger.Error(e, $"Unable to parse audio stream in {filename}"); } } } @@ -806,7 +804,7 @@ private static Media GetMediaInfo(string filename) } catch (Exception e) { - _logger.Error($"Unable to parse subtitle stream in {filename}"); + _logger.Error(e, $"Unable to parse subtitle stream in {filename}"); } } } diff --git a/Shoko.Server/Utilities/NamingLanguage.cs b/Shoko.Server/Utilities/NamingLanguage.cs index df959f458..6335f2e07 100644 --- a/Shoko.Server/Utilities/NamingLanguage.cs +++ b/Shoko.Server/Utilities/NamingLanguage.cs @@ -11,10 +11,6 @@ public class NamingLanguage public string LanguageDescription => Language.GetDescription(); - public NamingLanguage() - { - } - public NamingLanguage(TitleLanguage language) { Language = language; diff --git a/Shoko.Server/Utilities/PocoCache.cs b/Shoko.Server/Utilities/PocoCache.cs index 73ab748e1..2aa52e525 100644 --- a/Shoko.Server/Utilities/PocoCache.cs +++ b/Shoko.Server/Utilities/PocoCache.cs @@ -318,13 +318,15 @@ public S this[T key] return; } - if (_inverse.TryGetValue(oldValue, out var inverseValue) && inverseValue.Contains(key)) + if (_valueIsNullable && oldValue is null) + _inverseNullValueSet?.Remove(key); + else if (_inverse.TryGetValue(oldValue, out var inverseValue) && inverseValue.Contains(key)) inverseValue.Remove(key); } - if (_valueIsNullable && value == null) + if (_valueIsNullable && value is null) { - _inverseNullValueSet ??= new HashSet<T>(); + _inverseNullValueSet ??= []; _inverseNullValueSet.Add(key); } else @@ -332,7 +334,7 @@ public S this[T key] if (_inverse.TryGetValue(value, out var set)) set.Add(key); else - _inverse.Add(value, new HashSet<T>{key}); + _inverse.Add(value, [key]); } _direct[key] = value; diff --git a/Shoko.Server/Utilities/SeriesSearch.cs b/Shoko.Server/Utilities/SeriesSearch.cs index c8bc40cd4..491ca9a68 100644 --- a/Shoko.Server/Utilities/SeriesSearch.cs +++ b/Shoko.Server/Utilities/SeriesSearch.cs @@ -85,6 +85,7 @@ public static SearchResult<T> DiceFuzzySearch<T>(string text, string pattern, T return new(); // Always search the longer string for the shorter one. + var match = text; if (pattern.Length > text.Length) (text, pattern) = (pattern, text); @@ -101,7 +102,7 @@ public static SearchResult<T> DiceFuzzySearch<T>(string text, string pattern, T { Distance = result, LengthDifference = Math.Abs(pattern.Length - text.Length), - Match = text, + Match = match, Result = value, }; } @@ -297,7 +298,7 @@ private static List<SearchResult<SVR_AnimeSeries>> SearchTagsExact(string query, }) .Where(a => a != null) ) - .OrderBy(a => a.Result.SeriesName) + .OrderBy(a => a.Result.PreferredTitle) .Take(limit) ); @@ -325,7 +326,7 @@ private static List<SearchResult<SVR_AnimeSeries>> SearchTagsExact(string query, .Where(a => a != null) ) .OrderBy(a => a) - .ThenBy(a => a.Result.SeriesName) + .ThenBy(a => a.Result.PreferredTitle) .Take(limit) ); return seriesList; @@ -375,7 +376,7 @@ private static List<SearchResult<SVR_AnimeSeries>> SearchTagsFuzzy(string query, .Where(b => b != null) ) .OrderBy(a => a) - .ThenBy(b => b.Result.SeriesName) + .ThenBy(b => b.Result.PreferredTitle) .Take(limit)); limit -= seriesList.Count; @@ -413,7 +414,7 @@ private static List<SearchResult<SVR_AnimeSeries>> SearchTagsFuzzy(string query, .Where(a => a != null) ) .OrderBy(a => a) - .ThenBy(a => a.Result.SeriesName) + .ThenBy(a => a.Result.PreferredTitle) .Take(limit) ); return seriesList; @@ -423,11 +424,11 @@ private static Func<SVR_AnimeSeries, IEnumerable<string>> CreateSeriesTitleDeleg { var settings = Utils.SettingsProvider.GetSettings(); var languages = new HashSet<string> { "en", "x-jat" }; - languages.UnionWith(settings.LanguagePreference); + languages.UnionWith(settings.Language.SeriesTitleLanguageOrder); return series => RepoFactory.AniDB_Anime_Title.GetByAnimeID(series.AniDB_ID) .Where(title => title.TitleType == TitleType.Main || languages.Contains(title.LanguageCode)) .Select(title => title.Title) - .Append(series.SeriesName) + .Append(series.PreferredTitle) .Distinct(); } @@ -511,5 +512,16 @@ public int CompareTo(SearchResult<T> other) return string.Compare(Match, other.Match); } + + public SearchResult<Y> Map<Y>(Y result) + => new() + { + ExactMatch = ExactMatch, + Index = Index, + Distance = Distance, + LengthDifference = LengthDifference, + Match = Match, + Result = result, + }; } } diff --git a/Shoko.Server/Utilities/TagFilter.cs b/Shoko.Server/Utilities/TagFilter.cs index ec5a3ca8d..9614dad5d 100644 --- a/Shoko.Server/Utilities/TagFilter.cs +++ b/Shoko.Server/Utilities/TagFilter.cs @@ -606,16 +606,19 @@ public static bool IsTagBlackListed(string tag, Filter flags) public class TagFilter<T> where T : class { +#nullable enable private readonly Func<T, string> _nameSelector; - private readonly Func<string, T> _lookup; - private readonly Func<string, T> _ctor; + private readonly Func<string, T?> _lookup; + private readonly Func<string, T?> _ctor; - public TagFilter(Func<string, T> lookup, Func<T, string> nameSelector, Func<string, T> ctor = null) + public TagFilter(Func<string, T?> lookup, Func<T, string> nameSelector, Func<string, T?>? ctor = null) { _nameSelector = nameSelector; - _ctor = ctor ?? (typeof(T) == typeof(string) ? new Func<string, T>(name => name as T) : name => (T)Activator.CreateInstance(typeof(T), name)); + // explicit delegate prevents a warning + _ctor = ctor ?? (typeof(T) == typeof(string) ? new Func<string, T?>(name => name as T) : name => Activator.CreateInstance(typeof(T), name) as T); _lookup = lookup; } +#nullable disable private string GetTagName(T tag) { diff --git a/Shoko.Server/Utilities/UPnP.cs b/Shoko.Server/Utilities/UPnP.cs deleted file mode 100644 index a36d77f4d..000000000 --- a/Shoko.Server/Utilities/UPnP.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; -using System.Xml; - -namespace UPnP; - -public class NAT -{ - private static TimeSpan _timeout = new(0, 0, 0, 3); - - public static TimeSpan TimeOut - { - get => _timeout; - set => _timeout = value; - } - - private static string _descUrl, _serviceUrl, _eventUrl; - - public static bool Discover() - { - var s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, 1); - var req = "M-SEARCH * HTTP/1.1\r\n" + - "HOST: 239.255.255.250:1900\r\n" + - "ST:upnp:rootdevice\r\n" + - "MAN:\"ssdp:discover\"\r\n" + - "MX:3\r\n\r\n"; - var data = Encoding.ASCII.GetBytes(req); - var ipe = new IPEndPoint(IPAddress.Broadcast, 1900); - var buffer = new byte[0x1000]; - - var start = DateTime.Now; - - do - { - s.SendTo(data, ipe); - s.SendTo(data, ipe); - s.SendTo(data, ipe); - - var length = 0; - do - { - length = s.Receive(buffer); - - var resp = Encoding.ASCII.GetString(buffer, 0, length).ToLower(); - if (resp.Contains("upnp:rootdevice")) - { - resp = resp.Substring(resp.ToLower().IndexOf("location:") + 9); - resp = resp.Substring(0, resp.IndexOf("\r")).Trim(); - if (!string.IsNullOrEmpty(_serviceUrl = GetServiceUrl(resp))) - { - _descUrl = resp; - return true; - } - } - } while (length > 0); - } while (start.Subtract(DateTime.Now) < _timeout); - - return false; - } - - private static string GetServiceUrl(string resp) - { -#if !DEBUG - try - { -#endif - var desc = new XmlDocument(); - desc.Load(WebRequest.Create(resp).GetResponse().GetResponseStream()); - var nsMgr = new XmlNamespaceManager(desc.NameTable); - nsMgr.AddNamespace("tns", "urn:schemas-upnp-org:device-1-0"); - var typen = desc.SelectSingleNode("//tns:device/tns:deviceType/text()", nsMgr); - if (!typen.Value.Contains("InternetGatewayDevice")) - { - return null; - } - - var node = - desc.SelectSingleNode( - "//tns:service[tns:serviceType=\"urn:schemas-upnp-org:service:WANIPConnection:1\"]/tns:controlURL/text()", - nsMgr); - if (node == null) - { - return null; - } - - var eventnode = - desc.SelectSingleNode( - "//tns:service[tns:serviceType=\"urn:schemas-upnp-org:service:WANIPConnection:1\"]/tns:eventSubURL/text()", - nsMgr); - _eventUrl = CombineUrls(resp, eventnode.Value); - return CombineUrls(resp, node.Value); -#if !DEBUG - } - catch { return null; } -#endif - } - - private static string CombineUrls(string resp, string p) - { - var n = resp.IndexOf("://"); - n = resp.IndexOf('/', n + 3); - return resp.Substring(0, n) + p; - } - - public static void ForwardPort(int port, ProtocolType protocol, string description) - { - if (string.IsNullOrEmpty(_serviceUrl)) - { - throw new Exception("No UPnP service available or Discover() has not been called"); - } - - var xdoc = SOAPRequest(_serviceUrl, - "<u:AddPortMapping xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\">" + - "<NewRemoteHost></NewRemoteHost><NewExternalPort>" + port + - "</NewExternalPort><NewProtocol>" + - protocol.ToString().ToUpper() + "</NewProtocol>" + - "<NewInternalPort>" + port + "</NewInternalPort><NewInternalClient>" + - Dns.GetHostAddresses(Dns.GetHostName())[0] + - "</NewInternalClient><NewEnabled>1</NewEnabled><NewPortMappingDescription>" + description + - "</NewPortMappingDescription><NewLeaseDuration>0</NewLeaseDuration></u:AddPortMapping>", - "AddPortMapping"); - } - - public static void DeleteForwardingRule(int port, ProtocolType protocol) - { - if (string.IsNullOrEmpty(_serviceUrl)) - { - throw new Exception("No UPnP service available or Discover() has not been called"); - } - - var xdoc = SOAPRequest(_serviceUrl, - "<u:DeletePortMapping xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\">" + - "<NewRemoteHost>" + - "</NewRemoteHost>" + - "<NewExternalPort>" + port + "</NewExternalPort>" + - "<NewProtocol>" + protocol.ToString().ToUpper() + "</NewProtocol>" + - "</u:DeletePortMapping>", "DeletePortMapping"); - } - - public static IPAddress GetExternalIP() - { - if (string.IsNullOrEmpty(_serviceUrl)) - { - throw new Exception("No UPnP service available or Discover() has not been called"); - } - - var xdoc = SOAPRequest(_serviceUrl, - "<u:GetExternalIPAddress xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\">" + - "</u:GetExternalIPAddress>", "GetExternalIPAddress"); - var nsMgr = new XmlNamespaceManager(xdoc.NameTable); - nsMgr.AddNamespace("tns", "urn:schemas-upnp-org:device-1-0"); - var IP = xdoc.SelectSingleNode("//NewExternalIPAddress/text()", nsMgr).Value; - return IPAddress.Parse(IP); - } - - private static XmlDocument SOAPRequest(string url, string soap, string function) - { - var req = "<?xml version=\"1.0\"?>" + - "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">" + - "<s:Body>" + - soap + - "</s:Body>" + - "</s:Envelope>"; - var r = WebRequest.Create(url); - r.Method = "POST"; - var b = Encoding.UTF8.GetBytes(req); - r.Headers.Add("SOAPACTION", "\"urn:schemas-upnp-org:service:WANIPConnection:1#" + function + "\""); - r.ContentType = "text/xml; charset=\"utf-8\""; - r.ContentLength = b.Length; - r.GetRequestStream().Write(b, 0, b.Length); - var resp = new XmlDocument(); - var wres = r.GetResponse(); - var ress = wres.GetResponseStream(); - resp.Load(ress); - return resp; - } - - public static bool UPnPJMMFilePort(int jmmfileport) - { - try - { - if (Discover()) - { - ForwardPort(jmmfileport, ProtocolType.Tcp, "JMM File Port"); - UPnPPortAvailable = true; - } - else - { - UPnPPortAvailable = false; - } - } - catch (Exception) - { - UPnPPortAvailable = false; - } - - return UPnPPortAvailable; - } - - public static bool UPnPPortAvailable { get; private set; } - private static IPAddress CachedAddress; - private static DateTime LastChange = DateTime.MinValue; - private static bool IPThreadLock; - private static bool IPFirstTime; - - public static IPAddress GetExternalAddress() - { - try - { - if (LastChange < DateTime.Now) - { - if (IPFirstTime) - { - IPFirstTime = false; - CachedAddress = GetExternalIP(); - } - else if (!IPThreadLock) - { - IPThreadLock = true; - LastChange = DateTime.Now.AddMinutes(2); - ThreadPool.QueueUserWorkItem(a => - { - CachedAddress = GetExternalIP(); - IPThreadLock = false; - }); - } - } - } - catch (Exception) - { - return null; - } - - return CachedAddress; - } -} diff --git a/Shoko.Server/Utilities/Utils.cs b/Shoko.Server/Utilities/Utils.cs index d05944ae6..6550011b5 100644 --- a/Shoko.Server/Utilities/Utils.cs +++ b/Shoko.Server/Utilities/Utils.cs @@ -16,6 +16,7 @@ using NLog.Filters; using NLog.Targets; using NLog.Targets.Wrappers; +using Quartz.Logging; using Shoko.Models.Enums; using Shoko.Server.API.SignalR.NLog; using Shoko.Server.Providers.AniDB.Titles; @@ -27,10 +28,13 @@ namespace Shoko.Server.Utilities; public static class Utils { public static ShokoServer ShokoServer { get; set; } + public static IServiceProvider ServiceContainer { get; set; } + public static ISettingsProvider SettingsProvider { get; set; } - private static string _applicationPath { get; set; } = null; + private static string _applicationPath = null; + public static string ApplicationPath { get @@ -50,9 +54,13 @@ public static string ApplicationPath DefaultInstance); } } + public static string DefaultInstance { get; set; } = Assembly.GetEntryAssembly().GetName().Name; + public static string DefaultImagePath => Path.Combine(ApplicationPath, "images"); + public static string AnimeXmlDirectory { get; set; } = Path.Combine(ApplicationPath, "Anime_HTTP"); + public static string MyListDirectory { get; set; } = Path.Combine(ApplicationPath, "MyList"); public static string GetDistinctPath(string fullPath) @@ -61,12 +69,12 @@ public static string GetDistinctPath(string fullPath) return string.IsNullOrEmpty(parent) ? fullPath : Path.Combine(Path.GetFileName(parent), Path.GetFileName(fullPath)); } - private static string? GetInstanceFromCommandLineArguments() + private static string GetInstanceFromCommandLineArguments() { - const int notFound = -1; - var args = Environment.GetCommandLineArgs(); - var idx = Array.FindIndex(args, x => string.Equals(x, "instance", StringComparison.InvariantCultureIgnoreCase)); - if (idx is notFound) + const int NotFound = -1; + var args = Environment.GetCommandLineArgs(); + var idx = Array.FindIndex(args, x => string.Equals(x, "instance", StringComparison.InvariantCultureIgnoreCase)); + if (idx is NotFound) return null; if (idx >= args.Length - 1) return null; @@ -124,6 +132,8 @@ public static void InitLogger() } } + LogProvider.SetLogProvider(new NLog.Extensions.Logging.NLogLoggerFactory()); + LogManager.ReconfigExistingLoggers(); } @@ -145,314 +155,7 @@ public static void SetTraceLogging(bool enabled) LogManager.ReconfigExistingLoggers(); } - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool IsWow64Process(IntPtr hProcess, [MarshalAs(UnmanagedType.Bool)] out bool isWow64); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr GetCurrentProcess(); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto)] - private static extern IntPtr GetModuleHandle(string moduleName); - - [DllImport("kernel32.dll", CharSet = CharSet.Ansi, SetLastError = true)] - private static extern IntPtr GetProcAddress(IntPtr hModule, string methodName); - - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - - public static string CalculateSHA1(string text, Encoding enc) - { - var buffer = enc.GetBytes(text); - var cryptoTransformSHA1 = - new SHA1CryptoServiceProvider(); - var hash = BitConverter.ToString(cryptoTransformSHA1.ComputeHash(buffer)).Replace("-", string.Empty); - - return hash; - } - - public static readonly List<string> Paths = new() - { - "JMMServerImage", - "JMMServerBinary", - "JMMServerMetro", - "JMMServerMetroImage", - "JMMServerStreaming", - string.Empty - }; - - public static void NetSh(this StreamWriter sw, string path, string verb, string port) - { - if (verb == "add") - { - sw.WriteLine($@"netsh http add urlacl url=http://+:{port}/{path} sddl=D:(A;;GA;;;S-1-1-0)"); - } - else - { - sw.WriteLine($@"netsh http delete urlacl url=http://+:{port}/{path}"); - } - } - - public static string acls = ":\\s+(http://(\\*|\\+):({0}).*?/)\\s?\r\n"; - - public static void CleanUpDefaultPortsInNetSh(this StreamWriter sw, int[] ports) - { - var acl = new Regex(string.Format(acls, string.Join("|", ports.Select(a => a.ToString()))), - RegexOptions.Singleline); - var proc = new Process - { - StartInfo = - { - FileName = "netsh", - Arguments = "http show urlacl", - Verb = "runas", - CreateNoWindow = true, - RedirectStandardOutput = true, - WindowStyle = ProcessWindowStyle.Hidden, - UseShellExecute = false - } - }; - proc.Start(); - var sr = proc.StandardOutput; - var str = sr.ReadToEnd(); - proc.WaitForExit(); - foreach (Match m in acl.Matches(str)) - { - if (m.Success) - { - sw.WriteLine($@"netsh http delete urlacl url={m.Groups[1].Value}"); - } - } - } - - /// <summary> - /// Setup system with needed network settings for JMMServer operation. Will invoke an escalation prompt to user. If changing port numbers please give new and old port. - /// Do NOT add nancy hosted URLs to this. Nancy has an issue with ServiceHost stealing the reservations, and will handle its URLs itself. - /// </summary> - /// <param name="OldPort">The port JMMServer was set to run on.</param> - /// <param name="Port">The port JMMServer will be running on.</param> - /// <param name="FilePort">The port JMMServer will use for files.</param> - /// <param name="OldFilePort">The port JMMServer was set to use for files.</param> - public static bool SetNetworkRequirements(string Port, string FilePort, string OldPort, string OldFilePort) - { - var BatchFile = Path.Combine(Path.GetTempPath(), "NetworkConfig.bat"); - var proc = new Process - { - StartInfo = - { - FileName = "cmd.exe", - Arguments = $@"/c {BatchFile}", - Verb = "runas", - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, - UseShellExecute = true - } - }; - - try - { - var BatchFileStream = new StreamWriter(BatchFile); - - //Cleanup previous - try - { - BatchFileStream.CleanUpDefaultPortsInNetSh(new[] { int.Parse(OldPort), int.Parse(OldFilePort) }); - BatchFileStream.WriteLine( - "netsh advfirewall firewall delete rule name=\"JMM Server - Client Port\""); - BatchFileStream.WriteLine("netsh advfirewall firewall delete rule name=\"JMM Server - File Port\""); - BatchFileStream.WriteLine( - $@"netsh advfirewall firewall add rule name=""JMM Server - Client Port"" dir=in action=allow protocol=TCP localport={ - Port - }"); - Paths.ForEach(a => BatchFileStream.NetSh(a, "add", Port)); - BatchFileStream.WriteLine( - $@"netsh advfirewall firewall add rule name=""JMM Server - File Port"" dir=in action=allow protocol=TCP localport={ - FilePort - }"); - BatchFileStream.NetSh("", "add", FilePort); - } - finally - { - BatchFileStream.Close(); - } - - proc.Start(); - proc.WaitForExit(); - var exitCode = proc.ExitCode; - proc.Close(); - File.Delete(BatchFile); - if (exitCode != 0) - { - logger.Error("Temporary batch process for netsh and firewall settings returned error code: " + - exitCode); - return false; - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - return false; - } - - logger.Info("Network requirements set."); - - return true; - } - - /* - public static bool SetNetworkRequirements(string Port = null, string FilePort = null, string oldPort = null, - string oldFilePort = null) - { - string BatchFile = Path.Combine(System.IO.Path.GetTempPath(), "NetworkConfig.bat"); - int exitCode = -1; - Process proc = new Process(); - - proc.StartInfo.FileName = "cmd.exe"; - proc.StartInfo.Arguments = string.Format(@"/c {0}", BatchFile); - proc.StartInfo.Verb = "runas"; - proc.StartInfo.CreateNoWindow = true; - proc.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; - proc.StartInfo.UseShellExecute = true; - - try - { - StreamWriter BatchFileStream = new StreamWriter(BatchFile); - - #region Cleanup Default Ports - - if (ServerSettings.JMMServerPort != 8111.ToString()) - { - BatchFileStream.WriteLine(string.Format( - @"netsh http delete urlacl url=http://+:{0}/JMMServerImage", 8111)); - BatchFileStream.WriteLine(string.Format( - @"netsh http delete urlacl url=http://+:{0}/JMMServerBinary", 8111)); - BatchFileStream.WriteLine(string.Format( - @"netsh http delete urlacl url=http://+:{0}/JMMServerMetro", 8111)); - BatchFileStream.WriteLine( - string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerMetroImage", 8111)); - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerPlex", - 8111)); - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerKodi", - 8111)); - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerREST", - 8111)); - BatchFileStream.WriteLine( - string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerStreaming", 8111)); - BatchFileStream.WriteLine( - string.Format( - @"netsh advfirewall firewall delete rule name=""JMM Server - Client Port"" protocol=TCP localport={0}", - 8111)); - } - - if (ServerSettings.JMMServerFilePort != 8112.ToString()) - { - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMFilePort", - 8112)); - BatchFileStream.WriteLine( - string.Format( - @"netsh advfirewall firewall delete rule name=""JMM Server - File Port"" protocol=TCP localport={0}", - 8112)); - } - - #endregion - - if (!string.IsNullOrEmpty(Port)) - { - BatchFileStream.WriteLine( - string.Format( - @"netsh advfirewall firewall add rule name=""JMM Server - Client Port"" dir=in action=allow protocol=TCP localport={0}", - Port)); - BatchFileStream.WriteLine( - string.Format( - @"netsh advfirewall firewall delete rule name=""JMM Server - Client Port"" protocol=TCP localport={0}", - oldPort)); - - BatchFileStream.WriteLine( - string.Format(@"netsh http add urlacl url=http://+:{0}/JMMServerImage user=everyone", - Port)); - BatchFileStream.WriteLine( - string.Format(@"netsh http add urlacl url=http://+:{0}/JMMServerBinary user=everyone", - Port)); - BatchFileStream.WriteLine( - string.Format(@"netsh http add urlacl url=http://+:{0}/JMMServerMetro user=everyone", - Port)); - BatchFileStream.WriteLine(string.Format(@"netsh http add urlacl url=http://+:{0} user=everyone",Port)); - BatchFileStream.WriteLine(string.Format( - @"netsh http add urlacl url=http://+:{0}/JMMServerMetroImage user=everyone", Port)); - BatchFileStream.WriteLine( - string.Format(@"netsh http add urlacl url=http://+:{0}/JMMServerPlex user=everyone", Port)); - BatchFileStream.WriteLine( - string.Format(@"netsh http add urlacl url=http://+:{0}/JMMServerKodi user=everyone", Port)); - BatchFileStream.WriteLine( - string.Format(@"netsh http add urlacl url=http://+:{0}/JMMServerREST user=everyone", Port)); - BatchFileStream.WriteLine(string.Format( - @"netsh http add urlacl url=http://+:{0}/JMMServerStreaming user=everyone", Port)); - } - if (!string.IsNullOrEmpty(FilePort)) - { - BatchFileStream.WriteLine( - string.Format( - @"netsh advfirewall firewall add rule name=""JMM Server - File Port"" dir=in action=allow protocol=TCP localport={0}", - FilePort)); - BatchFileStream.WriteLine( - string.Format( - @"netsh advfirewall firewall delete rule name=""JMM Server - File Port"" protocol=TCP localport={0}", - oldFilePort)); - - BatchFileStream.WriteLine( - string.Format(@"netsh http add urlacl url=http://+:{0}/JMMFilePort user=everyone", - FilePort)); - } - - if (!string.IsNullOrEmpty(oldPort)) - { - BatchFileStream.WriteLine(string.Format( - @"netsh http delete urlacl url=http://+:{0}/JMMServerImage", oldPort)); - BatchFileStream.WriteLine(string.Format( - @"netsh http delete urlacl url=http://+:{0}/JMMServerBinary", oldPort)); - BatchFileStream.WriteLine(string.Format( - @"netsh http delete urlacl url=http://+:{0}/JMMServerMetro", oldPort)); - BatchFileStream.WriteLine( - string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerMetroImage", oldPort)); - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerPlex", - oldPort)); - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerKodi", - oldPort)); - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerREST", - oldPort)); - BatchFileStream.WriteLine( - string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMServerStreaming", oldPort)); - } - if (!string.IsNullOrEmpty(oldFilePort)) - BatchFileStream.WriteLine(string.Format(@"netsh http delete urlacl url=http://+:{0}/JMMFilePort", - oldFilePort)); - - BatchFileStream.Close(); - - proc.Start(); - proc.WaitForExit(); - exitCode = proc.ExitCode; - proc.Close(); - File.Delete(BatchFile); - } - catch (Exception ex) - { - logger.Error( ex,ex.ToString()); - return false; - } - - logger.Info("Network requirements set."); - - return true; - } - */ - // Function to display parent function - public static string GetParentMethodName() - { - var stackTrace = new StackTrace(); - var stackFrame = stackTrace.GetFrame(2); - var methodBase = stackFrame.GetMethod(); - return methodBase.Name; - } + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); public class ErrorEventArgs : EventArgs { @@ -502,37 +205,33 @@ public static void MainThreadDispatch(Action a) public static void ShowErrorMessage(Exception ex, string message = null) { - //MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); ErrorMessage?.Invoke(null, new ErrorEventArgs { Message = message ?? ex.Message }); - logger.Error(ex, message); + _logger.Error(ex, message); } public static void ShowErrorMessage(string msg) { - //MessageBox.Show(msg, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); ErrorMessage?.Invoke(null, new ErrorEventArgs { Message = msg }); - logger.Error(msg); + _logger.Error(msg); } public static void ShowErrorMessage(string title, string msg) { - //MessageBox.Show(msg, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); ErrorMessage?.Invoke(null, new ErrorEventArgs { Message = msg, Title = title }); - logger.Error(msg); + _logger.Error(msg); } - + public static string GetApplicationVersion(Assembly a = null) { a ??= Assembly.GetExecutingAssembly(); return a.GetName().Version.ToString(); } - public static Dictionary<string, string> GetApplicationExtraVersion() + public static Dictionary<string, string> GetApplicationExtraVersion(Assembly assembly = null) { - var version = Assembly.GetExecutingAssembly().GetCustomAttribute(typeof(AssemblyInformationalVersionAttribute)) - as AssemblyInformationalVersionAttribute; - if (version == null) - return new(); + assembly ??= Assembly.GetExecutingAssembly(); + if (assembly.GetCustomAttribute(typeof(AssemblyInformationalVersionAttribute)) is not AssemblyInformationalVersionAttribute version) + return []; return version.InformationalVersion.Split(",") .Select(raw => raw.Split("=")) @@ -540,213 +239,6 @@ public static Dictionary<string, string> GetApplicationExtraVersion() .ToDictionary(pair => pair[0], pair => pair[1]); } - private static string[] escapes = { "SOURCE", "TAKEN", "FROM", "HTTP", "ANN", "ANIMENFO", "ANIDB", "ANIMESUKI" }; - - public static string ReparseDescription(string description) - { - if (string.IsNullOrEmpty(description)) - { - return string.Empty; - } - - var val = description; - val = val.Replace("<br />", Environment.NewLine) - .Replace("<br/>", Environment.NewLine) - .Replace("<i>", string.Empty) - .Replace("</i>", string.Empty) - .Replace("<b>", string.Empty) - .Replace("</b>", string.Empty) - .Replace("[i]", string.Empty) - .Replace("[/i]", string.Empty) - .Replace("[b]", string.Empty) - .Replace("[/b]", string.Empty); - val = val.Replace("<BR />", Environment.NewLine) - .Replace("<BR/>", Environment.NewLine) - .Replace("<I>", string.Empty) - .Replace("</I>", string.Empty) - .Replace("<B>", string.Empty) - .Replace("</B>", string.Empty) - .Replace("[I]", string.Empty) - .Replace("[/I]", string.Empty) - .Replace("[B]", string.Empty) - .Replace("[/B]", string.Empty); - - var vup = val.ToUpper(); - while (vup.Contains("[URL") || vup.Contains("[/URL]")) - { - var a = vup.IndexOf("[URL", StringComparison.Ordinal); - if (a >= 0) - { - var b = vup.IndexOf("]", a + 1, StringComparison.Ordinal); - if (b >= 0) - { - val = val.Substring(0, a) + val.Substring(b + 1); - vup = val.ToUpper(); - } - } - - a = vup.IndexOf("[/URL]", StringComparison.Ordinal); - if (a < 0) - { - continue; - } - - val = val.Substring(0, a) + val.Substring(a + 6); - vup = val.ToUpper(); - } - - while (vup.Contains("HTTP:")) - { - var a = vup.IndexOf("HTTP:", StringComparison.Ordinal); - if (a < 0) - { - continue; - } - - var b = vup.IndexOf(" ", a + 1, StringComparison.Ordinal); - if (b < 0) - { - break; - } - - if (vup[b + 1] == '[') - { - var c = vup.IndexOf("]", b + 1, StringComparison.Ordinal); - val = val.Substring(0, a) + " " + val.Substring(b + 2, c - b - 2) + val.Substring(c + 1); - } - else - { - val = val.Substring(0, a) + val.Substring(b); - } - - vup = val.ToUpper(); - } - - var d = -1; - do - { - if (d + 1 >= vup.Length) - { - break; - } - - d = vup.IndexOf("[", d + 1, StringComparison.Ordinal); - if (d == -1) - { - continue; - } - - var b = vup.IndexOf("]", d + 1, StringComparison.Ordinal); - if (b == -1) - { - continue; - } - - var cont = vup.Substring(d, b - d); - var dome = escapes.Any(s => cont.Contains(s)); - if (!dome) - { - continue; - } - - val = val.Substring(0, d) + val.Substring(b + 1); - vup = val.ToUpper(); - } while (d != -1); - - d = -1; - do - { - if (d + 1 >= vup.Length) - { - break; - } - - d = vup.IndexOf("(", d + 1, StringComparison.Ordinal); - if (d == -1) - { - continue; - } - - var b = vup.IndexOf(")", d + 1, StringComparison.Ordinal); - if (b == -1) - { - continue; - } - - var cont = vup.Substring(d, b - d); - var dome = escapes.Any(s => cont.Contains(s)); - if (!dome) - { - continue; - } - - val = val.Substring(0, d) + val.Substring(b + 1); - vup = val.ToUpper(); - } while (d != -1); - - d = vup.IndexOf("SOURCE:", StringComparison.Ordinal); - if (d == -1) - { - d = vup.IndexOf("SOURCE :", StringComparison.Ordinal); - } - - if (d > 0) - { - val = val.Substring(0, d); - } - - return val.Trim(); - } - - public static string FormatSecondsToDisplayTime(int secs) - { - var t = TimeSpan.FromSeconds(secs); - - if (t.Hours > 0) - { - return $"{t.Hours}:{t.Minutes.ToString().PadLeft(2, '0')}:{t.Seconds.ToString().PadLeft(2, '0')}"; - } - - return $"{t.Minutes}:{t.Seconds.ToString().PadLeft(2, '0')}"; - } - - public static string FormatAniDBRating(double rat) - { - // the episode ratings from UDP are out of 1000, while the HTTP AP gives it out of 10 - rat /= 100; - - return $"{rat:0.00}"; - } - - public static int? ProcessNullableInt(string sint) - { - if (string.IsNullOrEmpty(sint)) - { - return null; - } - - return int.Parse(sint); - } - - public static string RemoveInvalidFolderNameCharacters(string folderName) - { - var ret = folderName.Replace(@"*", string.Empty); - ret = ret.Replace(@"|", string.Empty); - ret = ret.Replace(@"\", string.Empty); - ret = ret.Replace(@"/", string.Empty); - ret = ret.Replace(@":", string.Empty); - ret = ret.Replace("\"", string.Empty); // double quote - ret = ret.Replace(@">", string.Empty); - ret = ret.Replace(@"<", string.Empty); - ret = ret.Replace(@"?", string.Empty); - while (ret.EndsWith(".")) - { - ret = ret.Substring(0, ret.Length - 1); - } - - return ret.Trim(); - } - public static string ReplaceInvalidFolderNameCharacters(string folderName) { var ret = folderName.Replace(@"*", "\u2605"); // ★ (BLACK STAR) @@ -759,165 +251,67 @@ public static string ReplaceInvalidFolderNameCharacters(string folderName) ret = ret.Replace(@"<", "\u2039"); // ‹ (SINGLE LEFT-POINTING ANGLE QUOTATION MARK) ret = ret.Replace(@"?", "\uff1f"); // ? (FULL WIDTH QUESTION MARK) ret = ret.Replace(@"...", "\u2026"); // … (HORIZONTAL ELLIPSIS) - if (ret.StartsWith(".", StringComparison.Ordinal)) + if (ret.StartsWith('.')) { - ret = "․" + ret.Substring(1, ret.Length - 1); + ret = string.Concat("․", ret.AsSpan(1, ret.Length - 1)); } - if (ret.EndsWith(".", StringComparison.Ordinal)) // U+002E + if (ret.EndsWith('.')) // U+002E { - ret = ret.Substring(0, ret.Length - 1) + "․"; // U+2024 + ret = string.Concat(ret.AsSpan(0, ret.Length - 1), "․"); // U+2024 } return ret.Trim(); } - public static string GetSortName(string name) - { - var newName = name; - if (newName.ToLower().StartsWith("the ", StringComparison.InvariantCultureIgnoreCase)) - { - newName = newName.Remove(0, 4); - } - - if (newName.ToLower().StartsWith("a ", StringComparison.InvariantCultureIgnoreCase)) - { - newName = newName.Remove(0, 2); - } - - return newName; - } - public static string GetOSInfo() { return RuntimeInformation.OSDescription; } - public static string GetMd5Hash(string input) - { - using (var md5Hash = MD5.Create()) - { - return GetMd5Hash(md5Hash, input); - } - } - - public static string GetMd5Hash(MD5 md5Hash, string input) - { - // Convert the input string to a byte array and compute the hash. - var data = md5Hash.ComputeHash(Encoding.UTF8.GetBytes(input)); - - // Create a new Stringbuilder to collect the bytes - // and create a string. - var sBuilder = new StringBuilder(); - - // Loop through each byte of the hashed data - // and format each one as a hexadecimal string. - for (var i = 0; i < data.Length; i++) - { - sBuilder.Append(data[i].ToString("x2")); - } - - // Return the hexadecimal string. - return sBuilder.ToString(); - } - - public static int GetOSArchitecture() - { - return Is64BitOperatingSystem ? 64 : 32; - } - - public static bool Is64BitProcess => IntPtr.Size == 8; - - public static bool Is64BitOperatingSystem - { - get - { - // Clearly if this is a 64-bit process we must be on a 64-bit OS. - if (Is64BitProcess) - { - return true; - } - - // Ok, so we are a 32-bit process, but is the OS 64-bit? - // If we are running under Wow64 than the OS is 64-bit. - return ModuleContainsFunction("kernel32.dll", "IsWow64Process") && - IsWow64Process(GetCurrentProcess(), out var isWow64) && - isWow64; - } - } - - private static bool ModuleContainsFunction(string moduleName, string methodName) - { - var hModule = GetModuleHandle(moduleName); - if (hModule != IntPtr.Zero) - { - return GetProcAddress(hModule, methodName) != IntPtr.Zero; - } - - return false; - } - - #region PrettyFilesize - - [DllImport("Shlwapi.dll", CharSet = CharSet.Auto)] - private static extern long StrFormatByteSize(long fileSize, - [MarshalAs(UnmanagedType.LPTStr)] StringBuilder buffer, int bufferSize); - - public static string FormatByteSize(long fileSize) - { - if (IsRunningOnLinuxOrMac()) - { - return GetBytesReadable(fileSize); - } - - var sbBuffer = new StringBuilder(20); - StrFormatByteSize(fileSize, sbBuffer, 20); - return sbBuffer.ToString(); - } - - // Returns the human-readable file size for an arbitrary, 64-bit file size + // Returns the human-readable file size for an arbitrary, 64-bit file size // The default format is "0.### XB", e.g. "4.2 KB" or "1.434 GB" // http://www.somacon.com/p576.php - public static string GetBytesReadable(long i) + public static string FormatByteSize(long fileSize) { // Get absolute value - var absolute_i = i < 0 ? -i : i; + var absolute_i = fileSize < 0 ? -fileSize : fileSize; // Determine the suffix and readable value string suffix; double readable; if (absolute_i >= 0x1000000000000000) // Exabyte { suffix = "EB"; - readable = i >> 50; + readable = fileSize >> 50; } else if (absolute_i >= 0x4000000000000) // Petabyte { suffix = "PB"; - readable = i >> 40; + readable = fileSize >> 40; } else if (absolute_i >= 0x10000000000) // Terabyte { suffix = "TB"; - readable = i >> 30; + readable = fileSize >> 30; } else if (absolute_i >= 0x40000000) // Gigabyte { suffix = "GB"; - readable = i >> 20; + readable = fileSize >> 20; } else if (absolute_i >= 0x100000) // Megabyte { suffix = "MB"; - readable = i >> 10; + readable = fileSize >> 10; } else if (absolute_i >= 0x400) // Kilobyte { suffix = "KB"; - readable = i; + readable = fileSize; } else { - return i.ToString("0 B"); // Byte + return fileSize.ToString("0 B"); // Byte } // Divide by 1024 to get fractional value @@ -926,38 +320,6 @@ public static string GetBytesReadable(long i) return readable.ToString("0.### ") + suffix; } - #endregion - - public static List<string> GetPossibleSubtitleFiles(string fileName) - { - var subtileFiles = new List<string> - { - Path.Combine(Path.GetDirectoryName(fileName), - Path.GetFileNameWithoutExtension(fileName) + ".srt"), - Path.Combine(Path.GetDirectoryName(fileName), - Path.GetFileNameWithoutExtension(fileName) + ".ass"), - Path.Combine(Path.GetDirectoryName(fileName), - Path.GetFileNameWithoutExtension(fileName) + ".ssa"), - Path.Combine(Path.GetDirectoryName(fileName), - Path.GetFileNameWithoutExtension(fileName) + ".idx"), - Path.Combine(Path.GetDirectoryName(fileName), - Path.GetFileNameWithoutExtension(fileName) + ".sub"), - Path.Combine(Path.GetDirectoryName(fileName), - Path.GetFileNameWithoutExtension(fileName) + ".rar") - }; - return subtileFiles; - } - - /// <summary> - /// This method attempts to take a video resolution, and return something that is closer to a standard - /// </summary> - /// <param name="res"></param> - /// <returns></returns> - public static string GetStandardisedVideoResolution(string res) - { - return FileQualityFilter.GetResolution(res) ?? res; - } - public static int GetVideoWidth(string videoResolution) { var videoWidth = 0; @@ -990,23 +352,15 @@ public static int GetVideoHeight(string videoResolution) public static int GetScheduledHours(ScheduledUpdateFrequency freq) { - switch (freq) + return freq switch { - case ScheduledUpdateFrequency.Daily: - return 24; - case ScheduledUpdateFrequency.HoursSix: - return 6; - case ScheduledUpdateFrequency.HoursTwelve: - return 12; - case ScheduledUpdateFrequency.WeekOne: - return 24 * 7; - case ScheduledUpdateFrequency.MonthOne: - return 24 * 30; - case ScheduledUpdateFrequency.Never: - return int.MaxValue; - } - - return int.MaxValue; + ScheduledUpdateFrequency.HoursSix => 6, + ScheduledUpdateFrequency.HoursTwelve => 12, + ScheduledUpdateFrequency.Daily => 24, + ScheduledUpdateFrequency.WeekOne => 24 * 7, + ScheduledUpdateFrequency.MonthOne => 24 * 30, + _ => int.MaxValue, + }; } public static void GetFilesForImportFolder(DirectoryInfo sDir, ref List<string> fileList) @@ -1015,14 +369,14 @@ public static void GetFilesForImportFolder(DirectoryInfo sDir, ref List<string> { if (sDir == null) { - logger.Error("Filesystem not found"); + _logger.Error("Filesystem not found"); return; } // get root level files if (!sDir.Exists) { - logger.Error($"Unable to retrieve folder {sDir.FullName}"); + _logger.Error($"Unable to retrieve folder {sDir.FullName}"); return; } @@ -1036,98 +390,8 @@ public static void GetFilesForImportFolder(DirectoryInfo sDir, ref List<string> } catch (Exception excpt) { - logger.Error(excpt.Message); - } - } - - public static void ExecuteCommandSync(object command) - { - try - { - // create the ProcessStartInfo using "cmd" as the program to be run, - // and "/c " as the parameters. - // Incidentally, /c tells cmd that we want it to execute the command that follows, - // and then exit. - var procStartInfo = - new ProcessStartInfo("cmd", "/c " + command) - { - // The following commands are needed to redirect the standard output. - // This means that it will be redirected to the Process.StandardOutput StreamReader. - RedirectStandardOutput = true, - UseShellExecute = false, - // Do not create the black window. - CreateNoWindow = true - }; - // Now we create a process, assign its ProcessStartInfo and start it - var proc = new Process { StartInfo = procStartInfo }; - proc.Start(); - // Get the output into a string - var result = proc.StandardOutput.ReadToEnd(); - // Display the command output. - logger.Info(result); - } - catch - { - // Log the exception - } - } - - public static void ClearAutoUpdateCache() - { - // rmdir /s /q "%userprofile%\wc" - ExecuteCommandSync("rmdir /s /q \"%userprofile%\\wc\""); - } - - public static bool IsAdministrator() - { - var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - return principal.IsInRole(WindowsBuiltInRole.Administrator); - } - - ///<summary>Returns the end of a text reader.</summary> - ///<param name="reader">The reader to read from.</param> - ///<param name="lineCount">The number of lines to return.</param> - ///<returns>The last lneCount lines from the reader.</returns> - public static string[] Tail(this TextReader reader, int lineCount) - { - var buffer = new List<string>(lineCount); - string line; - for (var i = 0; i < lineCount; i++) - { - line = reader.ReadLine(); - if (line == null) - { - return buffer.ToArray(); - } - - buffer.Add(line); - } - - var lastLine = - lineCount - - 1; //The index of the last line read from the buffer. Everything > this index was read earlier than everything <= this indes - - while (null != (line = reader.ReadLine())) - { - lastLine++; - if (lastLine == lineCount) - { - lastLine = 0; - } - - buffer[lastLine] = line; - } - - if (lastLine == lineCount - 1) - { - return buffer.ToArray(); + _logger.Error(excpt.Message); } - - var retVal = new string[lineCount]; - buffer.CopyTo(lastLine + 1, retVal, 0, lineCount - lastLine - 1); - buffer.CopyTo(0, retVal, lineCount - lastLine - 1, lastLine + 1); - return retVal; } public static bool IsDirectoryWritable(string dirPath, bool throwIfFails = false) @@ -1164,11 +428,6 @@ public static bool IsRunningOnLinuxOrMac() return !RuntimeInformation.IsOSPlatform(OSPlatform.Windows); } - public static bool IsRunningOnMono() - { - return Type.GetType("Mono.Runtime") != null; - } - /// <summary> /// Determines an encoded string's encoding by analyzing its byte order mark (BOM). /// Defaults to ASCII when detection of the text file's endianness fails. diff --git a/Shoko.Tests/Shoko.Tests/FilterTests.cs b/Shoko.Tests/Shoko.Tests/FilterTests.cs index f26e587a2..d3ff17608 100644 --- a/Shoko.Tests/Shoko.Tests/FilterTests.cs +++ b/Shoko.Tests/Shoko.Tests/FilterTests.cs @@ -16,14 +16,16 @@ namespace Shoko.Tests; public class FilterTests { #region TestData + private const string GroupFilterableString = - "{\"MissingEpisodes\":0,\"MissingEpisodesCollecting\":0,\"Tags\":[\"Earth\",\"Japan\",\"Asia\",\"friendship\",\"daily life\",\"high school\",\"school life\",\"comedy\",\"Kyoto\",\"dynamic\",\"themes\",\"source material\",\"setting\",\"elements\",\"place\",\"manga\",\"funny expressions\",\"storytelling\",\"character driven\",\"facial distortion\",\"narration\",\"origin\",\"episodic\",\"Japanese production\"],\"CustomTags\":[],\"Years\":[2022],\"Seasons\":[{\"Item1\":2022,\"Item2\":1}],\"HasTvDBLink\":true,\"HasMissingTvDbLink\":false,\"HasTMDbLink\":true,\"HasMissingTMDbLink\":false,\"HasTraktLink\":false,\"HasMissingTraktLink\":true,\"IsFinished\":true,\"AirDate\":\"2022-04-06T20:00:00\",\"LastAirDate\":\"2022-06-22T20:00:00\",\"AddedDate\":\"2023-05-05T14:42:24.4477733\",\"LastAddedDate\":\"2023-05-05T13:37:50.32298\",\"EpisodeCount\":12,\"TotalEpisodeCount\":12,\"LowestAniDBRating\":7.4,\"HighestAniDBRating\":7.4,\"VideoSources\":[\"Web\"],\"AnimeTypes\":[\"TVSeries\"],\"AudioLanguages\":[\"japanese\"],\"SubtitleLanguages\":[\"english\"]}"; + "{\"MissingEpisodes\":0,\"MissingEpisodesCollecting\":0,\"Tags\":[\"Earth\",\"Japan\",\"Asia\",\"friendship\",\"daily life\",\"high school\",\"school life\",\"comedy\",\"Kyoto\",\"dynamic\",\"themes\",\"source material\",\"setting\",\"elements\",\"place\",\"manga\",\"funny expressions\",\"storytelling\",\"character driven\",\"facial distortion\",\"narration\",\"origin\",\"episodic\",\"Japanese production\"],\"CustomTags\":[],\"Years\":[2022],\"Seasons\":[{\"Item1\":2022,\"Item2\":1}],\"HasTvDBLink\":false,\"HasMissingTvDbLink\":false,\"HasTmdbLink\":true,\"HasMissingTmdbLink\":false,\"HasTraktLink\":false,\"HasMissingTraktLink\":true,\"IsFinished\":true,\"AirDate\":\"2022-04-06T20:00:00\",\"LastAirDate\":\"2022-06-22T20:00:00\",\"AddedDate\":\"2023-05-05T14:42:24.4477733\",\"LastAddedDate\":\"2023-05-05T13:37:50.32298\",\"EpisodeCount\":12,\"TotalEpisodeCount\":12,\"LowestAniDBRating\":7.4,\"HighestAniDBRating\":7.4,\"VideoSources\":[\"Web\"],\"AnimeTypes\":[\"TVSeries\"],\"AudioLanguages\":[\"japanese\"],\"SubtitleLanguages\":[\"english\"]}"; private const string GroupUserFilterableString = "{\"IsFavorite\":false,\"WatchedEpisodes\":12,\"UnwatchedEpisodes\":0,\"HasVotes\":false,\"HasPermanentVotes\":false,\"MissingPermanentVotes\":false,\"WatchedDate\":\"2023-05-05T13:42:13.3933582\",\"LastWatchedDate\":\"2023-05-05T13:43:21.3729042\",\"LowestUserRating\":0.0,\"HighestUserRating\":0.0}"; private const string SeriesFilterableString = - "{\"MissingEpisodes\":0,\"MissingEpisodesCollecting\":0,\"Tags\":[\"high school\",\"dynamic\",\"themes\",\"source material\",\"setting\",\"elements\",\"place\",\"Earth\",\"Japan\",\"Kyoto\",\"manga\",\"Asia\",\"comedy\",\"friendship\",\"daily life\",\"school life\",\"funny expressions\",\"storytelling\",\"character driven\",\"facial distortion\",\"narration\",\"origin\",\"episodic\",\"Japanese production\"],\"CustomTags\":[],\"Years\":[2022],\"Seasons\":[{\"Item1\":2022,\"Item2\":1}],\"HasTvDBLink\":true,\"HasMissingTvDbLink\":false,\"HasTMDbLink\":false,\"HasMissingTMDbLink\":false,\"HasTraktLink\":false,\"HasMissingTraktLink\":true,\"IsFinished\":true,\"AirDate\":\"2022-04-06T20:00:00\",\"LastAirDate\":\"2022-06-22T20:00:00\",\"AddedDate\":\"2023-05-05T14:42:24.3131538\",\"LastAddedDate\":\"2023-05-05T13:37:50.32298\",\"EpisodeCount\":12,\"TotalEpisodeCount\":12,\"LowestAniDBRating\":7.4,\"HighestAniDBRating\":7.4,\"VideoSources\":[\"Web\"],\"AnimeTypes\":[\"TVSeries\"],\"AudioLanguages\":[\"japanese\"],\"SubtitleLanguages\":[\"english\"]}"; + "{\"MissingEpisodes\":0,\"MissingEpisodesCollecting\":0,\"Tags\":[\"high school\",\"dynamic\",\"themes\",\"source material\",\"setting\",\"elements\",\"place\",\"Earth\",\"Japan\",\"Kyoto\",\"manga\",\"Asia\",\"comedy\",\"friendship\",\"daily life\",\"school life\",\"funny expressions\",\"storytelling\",\"character driven\",\"facial distortion\",\"narration\",\"origin\",\"episodic\",\"Japanese production\"],\"CustomTags\":[],\"Years\":[2022],\"Seasons\":[{\"Item1\":2022,\"Item2\":1}],\"HasTvDBLink\":false,\"HasMissingTvDbLink\":false,\"HasTmdbLink\":false,\"HasMissingTmdbLink\":false,\"HasTraktLink\":false,\"HasMissingTraktLink\":true,\"IsFinished\":true,\"AirDate\":\"2022-04-06T20:00:00\",\"LastAirDate\":\"2022-06-22T20:00:00\",\"AddedDate\":\"2023-05-05T14:42:24.3131538\",\"LastAddedDate\":\"2023-05-05T13:37:50.32298\",\"EpisodeCount\":12,\"TotalEpisodeCount\":12,\"LowestAniDBRating\":7.4,\"HighestAniDBRating\":7.4,\"VideoSources\":[\"Web\"],\"AnimeTypes\":[\"TVSeries\"],\"AudioLanguages\":[\"japanese\"],\"SubtitleLanguages\":[\"english\"]}"; private const string SeriesUserFilterableString = "{\"IsFavorite\":false,\"WatchedEpisodes\":12,\"UnwatchedEpisodes\":0,\"HasVotes\":false,\"HasPermanentVotes\":false,\"MissingPermanentVotes\":false,\"WatchedDate\":\"2023-05-05T13:42:13.3933582\",\"LastWatchedDate\":\"2023-05-05T13:43:21.3729042\",\"LowestUserRating\":0.0,\"HighestUserRating\":0.0}"; + #endregion public static readonly IEnumerable<object[]> GroupFilterable = new[] { new[] { JsonConvert.DeserializeObject<TestFilterable>(GroupFilterableString, new IReadOnlySetConverter()) }}; diff --git a/Shoko.Tests/Shoko.Tests/TestFilterable.cs b/Shoko.Tests/Shoko.Tests/TestFilterable.cs index 39ebc9e27..19fcb23b0 100644 --- a/Shoko.Tests/Shoko.Tests/TestFilterable.cs +++ b/Shoko.Tests/Shoko.Tests/TestFilterable.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Filters.Interfaces; namespace Shoko.Tests; @@ -18,10 +19,12 @@ public class TestFilterable : IFilterable public IReadOnlySet<string> CustomTags { get; init; } public IReadOnlySet<int> Years { get; init; } public IReadOnlySet<(int year, AnimeSeason season)> Seasons { get; init; } - public bool HasTvDBLink { get; init; } - public bool HasMissingTvDbLink { get; init; } - public bool HasTMDbLink { get; init; } - public bool HasMissingTMDbLink { get; init; } + public IReadOnlySet<ImageEntityType> AvailableImageTypes { get; } + public IReadOnlySet<ImageEntityType> PreferredImageTypes { get; } + public bool HasTmdbLink { get; init; } + public bool HasMissingTmdbLink { get; init; } + public int AutomaticTmdbEpisodeLinks { get; init; } + public int UserVerifiedTmdbEpisodeLinks { get; init; } public bool HasTraktLink { get; init; } public bool HasMissingTraktLink { get; init; } public bool IsFinished { get; init; } @@ -42,5 +45,7 @@ public class TestFilterable : IFilterable public IReadOnlySet<string> SubtitleLanguages { get; init; } public IReadOnlySet<string> SharedSubtitleLanguages { get; init; } public IReadOnlySet<string> Resolutions { get; init; } + public IReadOnlySet<string> ImportFolderIDs { get; init; } + public IReadOnlySet<string> ImportFolderNames { get; init; } public IReadOnlySet<string> FilePaths { get; init; } } diff --git a/Shoko.Tests/Shoko.Tests/TestServerSettings.cs b/Shoko.Tests/Shoko.Tests/TestServerSettings.cs index ae65298fa..268299672 100644 --- a/Shoko.Tests/Shoko.Tests/TestServerSettings.cs +++ b/Shoko.Tests/Shoko.Tests/TestServerSettings.cs @@ -17,9 +17,7 @@ public class TestServerSettings public LogRotatorSettings LogRotator { get; set; } = new(); public DatabaseSettings Database { get; set; } = new(); public AniDbSettings AniDb { get; set; } = new(); - public WebCacheSettings WebCache { get; set; } = new(); - public TvDBSettings TvDB { get; set; } = new(); - public MovieDbSettings MovieDb { get; set; } = new(); + public TMDBSettings TMDB { get; set; } = new(); public ImportSettings Import { get; set; } = new(); public PlexSettings Plex { get; set; } = new(); public PluginSettings Plugins { get; set; } = new();