diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..c6670e9f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.3", + "commands": [ + "dotnet-ef" + ] + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..c7cc7196 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "Development Shokofin Server", + "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", + // restores nuget packages, installs the dotnet workloads and installs the dev https certificate + "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", + // reads the extensions list and installs them + "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", + "features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "8.0", + "aspNetCoreRuntimeVersions": "8.0" + }, + "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + "preserve_apt_list": false, + "packages": ["libfontconfig1"] + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} + }, + "hostRequirements": { + "memory": "8gb", + "cpus": 4 + } +} diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..e79a9391 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,86 @@ +name: Shokofin Bug Report 101 +description: Report any bugs here! +labels: [] +projects: [] +assignees: + - revam +body: + - type: markdown + attributes: + value: | + ## Shokofin Bug Report + **Important:** This form is exclusively for reporting bugs. If your issue is not due to a bug but you requires assistance (e.g. with setup) or if you just have a question or inquiry, then please seek help on our [Discord](https://discord.gg/shokoanime) server instead. Our Discord community is eager to assist, and we often respond faster and can provide more immediate support on Discord. + + To help us understand and resolve your bug report more efficiently, please fill out the following information. + + And remember, for quicker assistance on any inquiries, Discord is the way to go! + - type: input + id: jelly + attributes: + label: Jellyfin version. + placeholder: "E.g. `10.8.12`" + validations: + required: true + - type: input + id: shokofin + attributes: + label: Shokofin version. + placeholder: "E.g. `3.0.1.0`" + validations: + required: true + - type: input + id: Shokoserver + attributes: + label: Shoko Server version, release channel, and commit hash. + placeholder: "E.g. `1.0.0 Stable` or `1.0.0 Dev (efefefe)`" + validations: + required: true + - type: textarea + id: fileStructure + attributes: + label: File structure of your _Media Library Folder in Jellyfin_/_Import Folder in Shoko Server_. + placeholder: "E.g. ../Anime A/Episode 1.avi or ../Anime A/Season 1/Episode 1.avi" + validations: + required: true + - type: textarea + id: screenshot + attributes: + label: Screenshot of the "library settings" section of the plugin settings. + validations: + required: true + - type: markdown + attributes: + value: | + Library type and metadata/image providers enabled for the library/libaries in Jellyfin. + - type: dropdown + id: library + attributes: + label: Library Type(s). + multiple: true + options: + - Shows + - Movies + - Movies & Shows + validations: + required: true + - type: checkboxes + id: metadataCheck + attributes: + label: "Do the issue persists after creating a library with Shoko set as the only metadata provider? (Now is your time to check if you haven't already.)" + options: + - label: "Yes, I hereby confirm that the issue persists after creating a library with Shoko set as the only metadata provider." + required: true + validations: + required: true + - type: textarea + id: issue + attributes: + label: Issue + description: Try to explain your issue in simple terms. We'll ask for details if it's needed. + validations: + required: true + - type: textarea + id: stackTrace + attributes: + label: Stack Trace + description: If relevant, paste here. diff --git a/.github/ISSUE_TEMPLATE/features.yml b/.github/ISSUE_TEMPLATE/features.yml new file mode 100644 index 00000000..912a9657 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/features.yml @@ -0,0 +1,34 @@ +name: Shokofin Feature Request 101 +description: Request your features here! +labels: [] +projects: [] +assignees: + - revam +body: + - type: markdown + attributes: + value: | + **Feature Request** + Suggest a request or idea that will help the project! + - type: textarea + id: description + attributes: + label: Description + description: Please describe the feature you would like to request. + placeholder: Describe your feature here. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Suggested Solution + description: How would you like the feature to be implemented? + placeholder: Describe your solution here. + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Information + description: Any additional information you would like to provide? + placeholder: Provide any additional information here. \ No newline at end of file diff --git a/.github/images/jellyfin.png b/.github/images/jellyfin.png new file mode 100644 index 00000000..0541db46 Binary files /dev/null and b/.github/images/jellyfin.png differ diff --git a/.github/workflows/git-log-json.mjs b/.github/workflows/git-log-json.mjs new file mode 100755 index 00000000..b1b72548 --- /dev/null +++ b/.github/workflows/git-log-json.mjs @@ -0,0 +1,118 @@ +#! /bin/env node +import { execSync } from "child_process"; +import process from "process"; + +// https://git-scm.com/docs/pretty-formats/2.21.0 + +// Get the range or hash from the command line arguments +const RangeOrHash = process.argv[2] || ""; + +// Form the git log command +const GitLogCommandBase = `git log ${RangeOrHash}`; + +const Placeholders = { + "H": "commit", + "P": "parents", + "T": "tree", + "s": "subject", + "b": "body", + "an": "author_name", + "ae": "author_email", + "aI": "author_date", + "cn": "committer_name", + "ce": "committer_email", + "cI": "committer_date", +}; + +const commitOrder = []; +const commits = {}; + +for (const [placeholder, name] of Object.entries(Placeholders)) { + const gitCommand = `${GitLogCommandBase} --format="%H2>>>>> %${placeholder}"`; + const output = execSync(gitCommand).toString(); + const lines = output.split(/\r\n|\r|\n/g); + let commitId = ""; + + for (const line of lines) { + const match = line.match(/^([0-9a-f]{40})2>>>>> /); + if (match) { + commitId = match[1]; + if (!commits[commitId]) { + commitOrder.push(commitId); + commits[commitId] = {}; + } + // Handle multiple parent hashes + if (name === "parents") { + commits[commitId][name] = line.substring(match[0].length).trim().split(" "); + } + else { + commits[commitId][name] = line.substring(match[0].length).trimEnd(); + } + } + else if (commitId) { + if (name === "parents") { + const commits = line.trim().split(" ").filter(l => l); + if (commits.length) + commits[commitId][name].push(...commits); + } + else { + commits[commitId][name] += "\n" + line.trimEnd(); + } + } + } +} + +// Trim trailing newlines from all values in the commits object +for (const commit of Object.values(commits)) { + for (const key in commit) { + if (typeof commit[key] === "string") { + commit[key] = commit[key].trimEnd(); + } + } +} + +// Convert commits object to a list of values +const commitsList = commitOrder.slice().reverse() + .map((commitId) => commits[commitId]) + .map(({ commit, parents, tree, subject, body, author_name, author_email, author_date, committer_name, committer_email, committer_date }) => ({ + commit, + parents, + tree, + subject: /^\s*\w+: /i.test(subject) ? subject.split(":").slice(1).join(":").trim() : subject.trim(), + type: /^\s*\w+: /i.test(subject) ? + subject.split(":")[0].toLowerCase() + : subject.startsWith("Partially revert ") ? + "revert" + : parents.length > 1 ? + "merge" + : /^fix/i.test(subject) ? + "fix" + : "misc", + body, + author: { + name: author_name, + email: author_email, + date: new Date(author_date).toISOString(), + timeZone: author_date.substring(19) === "Z" ? "+00:00" : author_date.substring(19), + }, + committer: { + name: committer_name, + email: committer_email, + date: new Date(committer_date).toISOString(), + timeZone: committer_date.substring(19) === "Z" ? "+00:00" : committer_date.substring(19), + }, + })) + .map((commit) => ({ + ...commit, + subject: /[a-z]/.test(commit.subject[0]) ? commit.subject[0].toUpperCase() + commit.subject.slice(1) : commit.subject, + type: commit.type == "feature" ? "feat" : commit.type === "refacor" ? "refactor" : commit.type == "mics" ? "misc" : commit.type, + })) + .map((commit) => ({ + ...commit, + subject: commit.subject.replace(/\[(?:skip|no) *ci\]/ig, "").trim().replace(/[\.:]+^/, ""), + body: commit.body ? commit.body.replace(/\[(?:skip|no) *ci\]/ig, "").trimEnd() : commit.body, + isSkipCI: /\[(?:skip|no) *ci\]/i.test(commit.subject) || Boolean(commit.body && /\[(?:skip|no) *ci\]/i.test(commit.body)), + })) + .filter((commit) => !(commit.type === "misc" && (commit.subject === "update unstable manifest" || commit.subject === "Update repo manifest" || commit.subject === "Update unstable repo manifest"))); + +process.stdout.write(JSON.stringify(commitsList, null, 2)); diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml new file mode 100644 index 00000000..ea7ff26d --- /dev/null +++ b/.github/workflows/release-daily.yml @@ -0,0 +1,180 @@ +name: Build & Publish Dev Release + +on: + push: + branches: + - dev + +jobs: + current_info: + runs-on: ubuntu-latest + + name: Current Information + + outputs: + version: ${{ steps.release_info.outputs.version }} + tag: ${{ steps.release_info.outputs.tag }} + date: ${{ steps.commit_date_iso8601.outputs.date }} + sha: ${{ github.sha }} + sha_short: ${{ steps.commit_info.outputs.sha }} + changelog: ${{ steps.generate_changelog.outputs.CHANGELOG }} + + steps: + - name: Checkout master + uses: actions/checkout@master + with: + ref: "${{ github.ref }}" + fetch-depth: 0 # This is set to download the full git history for the repo + + - 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: 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 + with: + script: | + const sha = context.sha.substring(0, 7); + core.setOutput("sha", sha); + + - name: Generate Changelog + id: generate_changelog + env: + PREVIOUS_COMMIT: ${{ steps.previous_release_info.outputs.commit }} + NEXT_COMMIT: ${{ github.sha }} + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" + node .github/workflows/git-log-json.mjs $PREVIOUS_COMMIT..$NEXT_COMMIT | jq -r '.[] | "\n`\(.type)`: **\(.subject)**" + if .body != null and .body != "" then if .isSkipCI then ": (_Skip CI_)\n\n\(.body)" else ":\n\n\(.body)" end else if .isSkipCI then ". (_Skip CI_)" else "." end end' >> "$GITHUB_OUTPUT" + echo -e "\n$EOF" >> "$GITHUB_OUTPUT" + + build_plugin: + runs-on: ubuntu-latest + + needs: + - current_info + + name: Build & Release (Dev) + + steps: + - name: Checkout + uses: actions/checkout@master + with: + ref: ${{ github.ref }} + fetch-depth: 0 # This is set to download the full git history for the repo + + - name: Fetch Dev Manifest from Metadata Branch + run: | + git checkout origin/metadata -- dev/manifest.json; + git reset; + rm manifest.json; + mv dev/manifest.json manifest.json; + rmdir dev; + + - name: Setup .Net + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 6.0.x + + - name: Restore Nuget Packages + run: dotnet restore Shokofin/Shokofin.csproj + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install JPRM + run: python -m pip install jprm + + - name: Run JPRM + env: + CHANGELOG: ${{ needs.current_info.outputs.changelog }} + run: python build_plugin.py --repo ${{ github.repository }} --version=${{ needs.current_info.outputs.version }} --tag=${{ needs.current_info.outputs.tag }} --prerelease=True + + - name: Change to Metadata Branch + run: | + mkdir dev; + mv manifest.json dev + git add ./dev/manifest.json; + git stash push --staged --message "Temp release details"; + git reset --hard; + git checkout origin/metadata -B metadata; + git stash apply || git checkout --theirs dev/manifest.json; + git reset; + + - name: Create Pre-Release + uses: softprops/action-gh-release@v1 + with: + files: ./artifacts/shoko_*.zip + name: "Shokofin Dev ${{ needs.current_info.outputs.version }}" + tag_name: ${{ needs.current_info.outputs.tag }} + body: | + Update your plugin using the [dev manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/dev/manifest.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.tag }}) and installing it manually! + + **Changes since last build**: + ${{ needs.current_info.outputs.changelog }} + prerelease: true + fail_on_unmatched_files: true + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update Dev Manifest + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: metadata + commit_message: "misc: update dev manifest" + file_pattern: dev/manifest.json + skip_fetch: true + + discord-notify: + runs-on: ubuntu-latest + + name: Send notifications about the new daily build + + needs: + - current_info + - build_plugin + + steps: + - name: Notify Discord Users + uses: tsickert/discord-webhook@v6.0.0 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK }} + embed-color: 9985983 + embed-timestamp: ${{ needs.current_info.outputs.date }} + embed-author-name: Shokofin | New Dev Build + embed-author-icon-url: https://raw.githubusercontent.com/${{ github.repository }}/dev/.github/images/jellyfin.png + embed-author-url: https://github.com/${{ github.repository }} + embed-description: | + **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) + + Update your plugin using the [dev manifest](https://raw.githubusercontent.com/${{ github.repository }}/metadata/dev/manifest.json) or by downloading the release from [GitHub Releases](https://github.com/${{ github.repository }}/releases/tag/${{ needs.current_info.outputs.tag }}) and installing it manually! + + **Changes since last build**: + ${{ needs.current_info.outputs.changelog }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..62272267 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Build Stable Release + +on: + release: + types: + - released + +jobs: + current_info: + runs-on: ubuntu-latest + + name: Current Information + + outputs: + version: ${{ steps.release_info.outputs.version }} + tag: ${{ steps.release_info.outputs.tag }} + + steps: + - name: Checkout master + uses: actions/checkout@master + with: + ref: "${{ github.ref }}" + fetch-depth: 0 # This is set to download the full git history for the repo + + - name: Get Current Version + id: release_info + uses: revam/gh-action-get-tag-and-version@v1 + with: + branch: false + prefix: "v" + prefixRegex: "[vV]?" + suffixRegex: "dev" + suffix: "dev" + + build_plugin: + runs-on: ubuntu-latest + + needs: + - current_info + + name: Build Release + + steps: + - name: Checkout + uses: actions/checkout@master + with: + ref: ${{ github.ref }} + fetch-depth: 0 # This is set to download the full git history for the repo + + - name: Fetch Stable Manifest from Metadata Branch + run: | + git checkout origin/metadata -- stable/manifest.json; + git reset; + rm manifest.json; + mv stable/manifest.json manifest.json; + rmdir stable; + + - name: Setup .Net + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.0.x + + - name: Restore Nuget Packages + run: dotnet restore Shokofin/Shokofin.csproj + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install JPRM + run: python -m pip install jprm + + - name: Run JPRM + run: python build_plugin.py --repo ${{ github.repository }} --version=${{ needs.current_info.outputs.version }} --tag=${{ needs.current_info.outputs.tag }} + + - name: Change to Metadata Branch + run: | + mkdir stable; + mv manifest.json stable + git add ./stable/manifest.json; + git stash push --staged --message "Temp release details"; + git reset --hard; + git checkout origin/metadata -B metadata; + git stash apply || git checkout --theirs stable/manifest.json; + git reset; + + - name: Update Release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ./artifacts/shoko_*.zip + tag: ${{ github.ref }} + file_glob: true + + - name: Update Stable Manifest + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: metadata + commit_message: "misc: update stable manifest" + file_pattern: stable/manifest.json + skip_fetch: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f95a8599 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ + # Common IntelliJ Platform excludes + +# User specific +**/.idea/**/workspace.xml +**/.idea/**/tasks.xml +**/.idea/shelf/* +**/.idea/dictionaries +**/.idea/httpRequests/ + +# Sensitive or high-churn files +**/.idea/**/dataSources/ +**/.idea/**/dataSources.ids +**/.idea/**/dataSources.xml +**/.idea/**/dataSources.local.xml +**/.idea/**/sqlDataSources.xml +**/.idea/**/dynamic.xml + +# Rider +# Rider auto-generates .iml files, and contentModel.xml +**/.idea/**/*.iml +**/.idea/**/contentModel.xml +**/.idea/**/modules.xml + +*.suo +*.user +.vs/ +[Bb]in/ +[Oo]bj/ +_UpgradeReport_Files/ +[Pp]ackages/ + +Thumbs.db +Desktop.ini +.DS_Store +/.idea/ +/.venv +artifacts diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..c1aa147a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "ms-dotnettools.vscode-dotnet-runtime", + "ms-dotnettools.csdevkit", + "eamodio.gitlens", + "streetsidesoftware.code-spell-checker" + ], + "unwantedRecommendations": [] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ef19470d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,54 @@ +{ + "editor.tabSize": 4, + "files.trimTrailingWhitespace": false, + "files.trimFinalNewlines": false, + "files.insertFinalNewline": false, + "dotnet.defaultSolution": "Shokofin.sln", + "cSpell.words": [ + "anidb", + "apikey", + "automagic", + "automagically", + "boxset", + "dlna", + "ecchi", + "emby", + "eroge", + "fanart", + "fanarts", + "Gainax", + "hentai", + "imdb", + "imdbid", + "interrobang", + "jellyfin", + "josei", + "jprm", + "kodomo", + "koma", + "linkbutton", + "manhua", + "manhwa", + "mina", + "nfo", + "nfos", + "outro", + "registrator", + "scrobble", + "scrobbled", + "scrobbling", + "seinen", + "seiyuu", + "shoko", + "shokofin", + "shoujo", + "shounen", + "signalr", + "tmdb", + "tvshow", + "tvshows", + "viewshow", + "webui", + "whitespaces" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a5a685c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Shoko - Anime Cataloging Program + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..d04bc9fa --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# Shokofin + +A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with +[Shoko Server](https://shokoanime.com/downloads/shoko-server/). + +## Read this before installing + +**This plugin requires that you have already set up and are using Shoko Server**, +and that the files you intend to include in Jellyfin are **indexed** (and +optionally managed) by Shoko Server. **Otherwise, the plugin won't be able to +provide metadata for your files**, since there is no metadata to find for them. + +### What Is Shoko? + +Shoko is an anime cataloging program designed to automate the cataloging of your +collection regardless of the size and amount of files you have. Unlike other +anime cataloging programs which make you manually add your series or link the +files to them, Shoko removes the tedious, time-consuming and boring task of +having to manually add every file and manually input the file information. You +have better things to do with your time like actually watching the series in +your collection so let Shoko handle all the heavy lifting. + +Learn more about Shoko at https://shokoanime.com/. + +## Install + +There are many ways to install the plugin, but the recommended way is to use +the official Jellyfin repository. Alternatively, it can be installed from this +GitHub repository, or you can build it from source. + +Below is a version compatibility matrix for which version of Shokofin is +compatible with what. + +| Shokofin | Jellyfin | Shoko Server | +|------------|----------|---------------| +| `0.x.x` | `10.7` | `4.0.0-4.1.2` | +| `1.x.x` | `10.7` | `4.1.0-4.1.2` | +| `2.x.x` | `10.8` | `4.1.2` | +| `3.x.x` | `10.8` | `4.2.0` | +| `unstable` | `10.9` | `4.2.2` | + +### Official Repository + +1. **Access Plugin Repositories:** + - Go to `Dashboard` -> `Plugins` -> `Repositories`. + +2. **Add New Repository:** + - Add a new repository with the following details: + * **Repository Name:** `Shokofin Stable` + * **Repository URL:** `https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/stable/manifest.json` + +3. **Install Shokofin:** + - Go to the catalog in the plugins page. + - Find and install `Shoko` from the `Metadata` section. + +4. **Restart Jellyfin:** + - Restart your server to apply the changes. + +### Github Releases + +1. **Download the Plugin:** + - Go to the latest release on GitHub [here](https://github.com/ShokoAnime/shokofin/releases/latest). + - Download the `shoko_*.zip` file. + +2. **Extract and Place Files:** + - Extract all `.dll` files and `meta.json` from the zip file. + - Put them in a folder named `Shoko`. + - Copy this `Shoko` folder to the `plugins` folder in your Jellyfin program + data directory or inside the Jellyfin install directory. For help finding + your Jellyfin install location, check the "Data Directory" section on + [this page](https://jellyfin.org/docs/general/administration/configuration.html). + +3. **Restart Jellyfin:** + - Start or restart your Jellyfin server to apply the changes. + +### Build Process + +1. **Clone or Download the Repository:** + - Clone or download the repository from GitHub. + +2. **Set Up .NET Core SDK:** + - Make sure you have the .NET Core SDK installed on your computer. + +3. **Build the Plugin:** + - Open a terminal and navigate to the repository directory. + - Run the following commands to restore and publish the project: + + ```sh + $ dotnet restore Shokofin/Shokofin.csproj + $ dotnet publish -c Release Shokofin/Shokofin.csproj + ``` +4. **Copy Built Files:** + - After building, go to the `bin/Release/net8.0/` directory. + - Copy all `.dll` files to a folder named `Shoko`. + - Place this `Shoko` folder in the `plugins` directory of your Jellyfin + program data directory or inside the portable install directory. For help + finding your Jellyfin install location, check the "Data Directory" section + on [this page](https://jellyfin.org/docs/general/administration/configuration.html). + +## Feature Overview + +- [ ] Metadata integration + + - [X] Basic metadata, e.g. titles, description, dates, etc. + + - [X] Customizable main title for items + + - [X] Optional customizable alternate/original title for items + + - [X] Customizable description source for items + + Choose between AniDB, TvDB, TMDB, or a mix of the three. + + - [X] Support optionally adding titles and descriptions for all episodes for + multi-entry files. + + - [X] Genres + + With settings to choose which tags to add as genres. + + - [X] Tags + + With settings to choose which tags to add as tags. + + - [X] Official Ratings + + Currently only _assumed_ ratings using AniDB tags or manual overrides using custom user tags are available. Also with settings to choose which providers to use. + + - [X] Production Locations + + With settings to chose which provider to use. + + - [ ] Staff + + - [X] Displayed on the Show/Season/Movie items + + - [X] Images + + - [ ] Metadata Provider + + _Needs to add endpoints to the Shoko Server side first._ + + - [ ] Studios + + - [X] Displayed on the Show/Season/Movie items + + - [ ] Images + + _Needs to add support and endpoints to the Shoko Server side **or** fake it client-side first._ + + - [ ] Metadata Provider + + _Needs to add support and endpoints to the Shoko Server side **or** fake it client-side first._ + +- [X] Library integration + + - [X] Support for different library types + + - [X] Show library + + - [X] Movie library + + - [X] Mixed show/movie library. + + _As long as the VFS is in use for the media library. Also keep in mind that this library type is poorly supported in Jellyfin Core, and we can't work around the poor internal support, so you'll have to take what you get or leave it as is._ + + - [X] Supports adding local trailers + + - [X] on Show items + + - [X] on Season items + + - [X] on Movie items + + - [X] Specials and extra features. + + - [X] Customize how Specials are placed in your library. I.e. if they are + mapped to the normal seasons, or if they are strictly kept in season zero. + + - [X] Extra features. The plugin will map specials stored in Shoko such as + interviews, etc. as extra features, and all other specials as episodes in + season zero. + + - [X] Map OPs/EDs to Theme Videos, so they can be displayed as background video + while you browse your library. + + - [X] Support merging multi-version episodes/movies into a single entry. + + Tidying up the UI if you have multiple versions of the same episode or + movie. + + - [X] Auto merge after library scan (if enabled). + + - [X] Manual merge/split tasks + + - [X] Support optionally setting other provider IDs Shoko knows about on some item types when an ID is available for the items in Shoko. + + _Only AniDB and TMDB IDs are available for now._ + + - [X] Multiple ways to organize your library. + + - [X] Choose between two ways to group your Shows/Seasons; using AniDB Anime structure (the default mode), or using Shoko Groups. + + _For the best compatibility if you're not using the VFS it is **strongly** advised **not** to use "season" folders with anime as it limits which grouping you can use, you can still create "seasons" in the UI using Shoko's groups._ + + - [X] Optionally create Collections for… + + - [X] Movies using the Shoko series. + + - [X] Movies and Shows using the Shoko groups. + + - [X] Supports separating your on-disc library into a two Show and Movie + libraries. + + _Provided you apply the workaround to support it_. + + - [X] Automatically populates all missing episodes not in your collection, so + you can see at a glance what you are missing out on. + + - [X] Optionally react to events sent from Shoko. + +- [X] User data + + - [X] Able to sync the watch data to/from Shoko on a per-user basis in + multiple ways. And Shoko can further sync the to/from other linked services. + + - [X] During import. + + - [X] Player events (play/pause/resume/stop events) + + - [X] After playback (stop event) + + - [X] Live scrobbling (every 1 minute during playback after the last + play/resume event or when jumping) + + - [X] Import and export user data tasks + +- [X] Virtual File System (VFS) + + _Allows us to disregard the underlying disk file structure while automagically meeting Jellyfin's requirements for file organization._ diff --git a/Shokofin.sln b/Shokofin.sln new file mode 100644 index 00000000..d0bcca4c --- /dev/null +++ b/Shokofin.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shokofin", "Shokofin\Shokofin.csproj", "{1DD876AE-9E68-4867-BDF6-B9050E63E936}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Shokofin/API/Info/CollectionInfo.cs b/Shokofin/API/Info/CollectionInfo.cs new file mode 100644 index 00000000..3d1fa48d --- /dev/null +++ b/Shokofin/API/Info/CollectionInfo.cs @@ -0,0 +1,36 @@ + +using System.Collections.Generic; +using Shokofin.API.Models; + +namespace Shokofin.API.Info; + +public class CollectionInfo +{ + public string Id; + + public string? ParentId; + + public string TopLevelId; + + public bool IsTopLevel; + + public string Name; + + public Group Shoko; + + public IReadOnlyList Shows; + + public IReadOnlyList SubCollections; + + public CollectionInfo(Group group, List shows, List subCollections) + { + Id = group.IDs.Shoko.ToString(); + ParentId = group.IDs.ParentGroup?.ToString(); + TopLevelId = group.IDs.TopLevelGroup.ToString(); + IsTopLevel = group.IDs.TopLevelGroup == group.IDs.Shoko; + Name = group.Name; + Shoko = group; + Shows = shows; + SubCollections = subCollections; + } +} diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs new file mode 100644 index 00000000..e7c43f34 --- /dev/null +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -0,0 +1,27 @@ +using System.Linq; +using Shokofin.API.Models; +using Shokofin.Utils; + +namespace Shokofin.API.Info; + +public class EpisodeInfo +{ + public string Id; + + public MediaBrowser.Model.Entities.ExtraType? ExtraType; + + public Episode Shoko; + + public Episode.AniDB AniDB; + + public Episode.TvDB? TvDB; + + public EpisodeInfo(Episode episode) + { + Id = episode.IDs.Shoko.ToString(); + ExtraType = Ordering.GetExtraType(episode.AniDBEntity); + Shoko = episode; + AniDB = episode.AniDBEntity; + TvDB = episode.TvDBEntityList?.FirstOrDefault(); + } +} diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs new file mode 100644 index 00000000..012bc516 --- /dev/null +++ b/Shokofin/API/Info/FileInfo.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using Shokofin.API.Models; + +namespace Shokofin.API.Info; + +public class FileInfo +{ + public string Id; + + public string SeriesId; + + public MediaBrowser.Model.Entities.ExtraType? ExtraType; + + public File Shoko; + + public List<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)> EpisodeList; + + public List> AlternateEpisodeLists; + + public FileInfo(File file, List> groupedEpisodeLists, string seriesId) + { + var episodeList = groupedEpisodeLists.FirstOrDefault() ?? new(); + var alternateEpisodeLists = groupedEpisodeLists.Count > 1 ? groupedEpisodeLists.GetRange(1, groupedEpisodeLists.Count - 1) : new(); + Id = file.Id.ToString(); + SeriesId = seriesId; + ExtraType = episodeList.FirstOrDefault(tuple => tuple.Episode.ExtraType != null).Episode?.ExtraType; + Shoko = file; + EpisodeList = episodeList; + AlternateEpisodeLists = alternateEpisodeLists; + } +} diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs new file mode 100644 index 00000000..581a7933 --- /dev/null +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shokofin.API.Models; + +using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; +using PersonKind = Jellyfin.Data.Enums.PersonKind; + +namespace Shokofin.API.Info; + +public class SeasonInfo +{ + public readonly string Id; + + public readonly IReadOnlyList ExtraIds; + + public readonly Series Shoko; + + public readonly Series.AniDBWithDate AniDB; + + public readonly Series.TvDB? TvDB; + + public readonly SeriesType Type; + + /// + /// Indicates that the season have been mapped to a different type, either + /// manually or automagically. + /// + public bool IsCustomType => Type != AniDB.Type; + + /// + /// The date of the earliest imported file, or when the series was created + /// in shoko if no files are imported yet. + /// + public readonly DateTime? EarliestImportedAt; + + /// + /// The date of the last imported file, or when the series was created + /// in shoko if no files are imported yet. + /// + public readonly DateTime? LastImportedAt; + + public readonly string? AssumedContentRating; + + public readonly IReadOnlyList Tags; + + public readonly IReadOnlyList Genres; + + public readonly IReadOnlyList ProductionLocations; + + public readonly IReadOnlyList Studios; + + public readonly IReadOnlyList Staff; + + /// + /// All episodes (of all type) that belong to this series. + /// + /// Unordered. + /// + public readonly IReadOnlyList RawEpisodeList; + + /// + /// A pre-filtered list of normal episodes that belong to this series. + /// + /// Ordered by AniDb air-date. + /// + public readonly List EpisodeList; + + /// + /// A pre-filtered list of "unknown" episodes that belong to this series. + /// + /// Ordered by AniDb air-date. + /// + public readonly List AlternateEpisodesList; + + /// + /// A pre-filtered list of "extra" videos that belong to this series. + /// + /// Ordered by AniDb air-date. + /// + public readonly List ExtrasList; + + /// + /// A dictionary holding mappings for the previous normal episode for every special episode in a series. + /// + public readonly IReadOnlyDictionary SpecialsAnchors; + + /// + /// A pre-filtered list of special episodes without an ExtraType + /// attached. + /// + /// Ordered by AniDb episode number. + /// + public readonly List SpecialsList; + + /// + /// Related series data available in Shoko. + /// + public readonly IReadOnlyList Relations; + + /// + /// Map of related series with type. + /// + public readonly IReadOnlyDictionary RelationMap; + + public SeasonInfo(Series series, SeriesType? customType, IEnumerable extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List episodes, List cast, List relations, string[] genres, string[] tags, string[] productionLocations, string? contentRating) + { + var seriesId = series.IDs.Shoko.ToString(); + var studios = cast + .Where(r => r.Type == CreatorRoleType.Studio) + .Select(r => r.Staff.Name) + .ToArray(); + var staff = cast + .Select(RoleToPersonInfo) + .OfType() + .ToArray(); + var relationMap = relations + .Where(r => r.RelatedIDs.Shoko.HasValue) + .DistinctBy(r => r.RelatedIDs.Shoko!.Value) + .ToDictionary(r => r.RelatedIDs.Shoko!.Value.ToString(), r => r.Type); + var specialsAnchorDictionary = new Dictionary(); + var specialsList = new List(); + var episodesList = new List(); + var extrasList = new List(); + var altEpisodesList = new List(); + + // Iterate over the episodes once and store some values for later use. + int index = 0; + int lastNormalEpisode = 0; + foreach (var episode in episodes) { + if (episode.Shoko.IsHidden) + continue; + switch (episode.AniDB.Type) { + case EpisodeType.Normal: + episodesList.Add(episode); + lastNormalEpisode = index; + break; + case EpisodeType.Other: + if (episode.ExtraType != null) + extrasList.Add(episode); + else + altEpisodesList.Add(episode); + break; + default: + if (episode.ExtraType != null) { + extrasList.Add(episode); + } + else if (episode.AniDB.Type == EpisodeType.Special) { + specialsList.Add(episode); + var previousEpisode = episodes + .GetRange(lastNormalEpisode, index - lastNormalEpisode) + .FirstOrDefault(e => e.AniDB.Type == EpisodeType.Normal); + if (previousEpisode != null) + specialsAnchorDictionary[episode] = previousEpisode; + } + break; + } + index++; + } + + // We order the lists after sorting them into buckets because the bucket + // sort we're doing above have the episodes ordered by air date to get + // the previous episode anchors right. + var seriesIdOrder = new string[] { seriesId }.Concat(extraIds).ToList(); + episodesList = episodesList + .OrderBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.Series.ToString())) + .ThenBy(e => e.AniDB.Type) + .ThenBy(e => e.AniDB.EpisodeNumber) + .ToList(); + specialsList = specialsList + .OrderBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.Series.ToString())) + .ThenBy(e => e.AniDB.Type) + .ThenBy(e => e.AniDB.EpisodeNumber) + .ToList(); + altEpisodesList = altEpisodesList + .OrderBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.Series.ToString())) + .ThenBy(e => e.AniDB.Type) + .ThenBy(e => e.AniDB.EpisodeNumber) + .ToList(); + + // Replace the normal episodes if we've hidden all the normal episodes and we have at least one + // alternate episode locally. + var type = customType ?? series.AniDBEntity.Type; + if (episodesList.Count == 0 && altEpisodesList.Count > 0) { + // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. + if (!customType.HasValue && type == SeriesType.Movie) + type = SeriesType.Web; + + episodesList = altEpisodesList; + altEpisodesList = new(); + + // Re-create the special anchors because the episode list changed. + index = 0; + lastNormalEpisode = 0; + specialsAnchorDictionary.Clear(); + foreach (var episode in episodes) { + if (episodesList.Contains(episode)) { + lastNormalEpisode = index; + } + else if (specialsList.Contains(episode)) { + var previousEpisode = episodes + .GetRange(lastNormalEpisode, index - lastNormalEpisode) + .FirstOrDefault(e => e.AniDB.Type == EpisodeType.Normal); + if (previousEpisode != null) + specialsAnchorDictionary[episode] = previousEpisode; + } + index++; + } + } + // Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes. + else if (!customType.HasValue && type == SeriesType.Movie && episodes.Any(episodeInfo => episodeInfo.AniDB.Titles.Any(title => title.LanguageCode == "en" && string.Equals(title.Value, "Complete Movie", StringComparison.InvariantCultureIgnoreCase)) && episodeInfo.Shoko.IsHidden)) { + type = SeriesType.Web; + } + + if (Plugin.Instance.Configuration.MovieSpecialsAsExtraFeaturettes && type == SeriesType.Movie) { + if (specialsList.Count > 0) { + extrasList.AddRange(specialsList); + specialsAnchorDictionary.Clear(); + specialsList = new(); + } + if (altEpisodesList.Count > 0) { + extrasList.AddRange(altEpisodesList); + altEpisodesList = new(); + } + } + + Id = seriesId; + ExtraIds = extraIds.ToArray(); + Shoko = series; + AniDB = series.AniDBEntity; + TvDB = series.TvDBEntityList.FirstOrDefault(); + Type = type; + EarliestImportedAt = earliestImportedAt; + LastImportedAt = lastImportedAt; + AssumedContentRating = contentRating; + Tags = tags; + Genres = genres; + ProductionLocations = productionLocations; + Studios = studios; + Staff = staff; + RawEpisodeList = episodes; + EpisodeList = episodesList; + AlternateEpisodesList = altEpisodesList; + ExtrasList = extrasList; + SpecialsAnchors = specialsAnchorDictionary; + SpecialsList = specialsList; + Relations = relations; + RelationMap = relationMap; + } + + public bool IsExtraEpisode(EpisodeInfo? episodeInfo) + => episodeInfo != null && ExtrasList.Any(eI => eI.Id == episodeInfo.Id); + + public bool IsEmpty(int offset = 0) + { + // The extra "season" for this season info. + if (offset == 1) + return EpisodeList.Count == 0 || !AlternateEpisodesList.Any(eI => eI.Shoko.Size > 0); + + // The default "season" for this season info. + var episodeList = EpisodeList.Count == 0 ? AlternateEpisodesList : EpisodeList; + if (!episodeList.Any(eI => eI.Shoko.Size > 0)) + return false; + + return true; + } + + private static string? GetImagePath(Image image) + => image != null && image.IsAvailable ? image.ToURLString() : null; + + private static PersonInfo? RoleToPersonInfo(Role role) + => role.Type switch + { + CreatorRoleType.Director => new PersonInfo + { + Type = PersonKind.Director, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.Producer => new PersonInfo + { + Type = PersonKind.Producer, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.Music => new PersonInfo + { + Type = PersonKind.Lyricist, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.SourceWork => new PersonInfo + { + Type = PersonKind.Writer, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.SeriesComposer => new PersonInfo + { + Type = PersonKind.Composer, + Name = role.Staff.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.Seiyuu => new PersonInfo + { + Type = PersonKind.Actor, + Name = role.Staff.Name, + // The character will always be present if the role is a VA. + // We make it a conditional check since otherwise will the compiler complain. + Role = role.Character?.Name ?? string.Empty, + ImageUrl = GetImagePath(role.Staff.Image), + }, + _ => null, + }; +} diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs new file mode 100644 index 00000000..4806f3dd --- /dev/null +++ b/Shokofin/API/Info/ShowInfo.cs @@ -0,0 +1,284 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.Entities; +using Microsoft.Extensions.Logging; +using Shokofin.API.Models; +using Shokofin.Utils; + +namespace Shokofin.API.Info; + +public class ShowInfo +{ + /// + /// Main Shoko Series Id. + /// + public readonly string Id; + + /// + /// Main Shoko Group Id. + /// + public readonly string? GroupId; + + /// + /// Shoko Group Id used for Collection Support. + /// + public readonly string? CollectionId; + + /// + /// The main name of the show. + /// + public readonly string Name; + + /// + /// Indicates this is a standalone show without a group attached to it. + /// + public bool IsStandalone => + Shoko == null; + + /// + /// Indicates that this show is consistent of only movies. + /// + public bool IsMovieCollection => + IsStandalone && DefaultSeason.Type == SeriesType.Movie; + + /// + /// The Shoko Group, if this is not a standalone show entry. + /// + public readonly Group? Shoko; + + /// + /// First premiere date of the show. + /// + public DateTime? PremiereDate => + SeasonList + .Select(s => s.AniDB.AirDate) + .Where(s => s != null) + .OrderBy(s => s) + .FirstOrDefault(); + + /// + /// Ended date of the show. + /// + public DateTime? EndDate => + SeasonList.Any(s => s.AniDB.AirDate.HasValue && s.AniDB.AirDate.Value < DateTime.Now && s.AniDB.EndDate == null) ? null : SeasonList + .Where(s => s.AniDB.EndDate.HasValue) + .Select(s => s.AniDB.EndDate!.Value) + .OrderBy(s => s) + .LastOrDefault(); + + /// + /// Custom rating of the show. + /// + public string? CustomRating => + DefaultSeason.AniDB.Restricted ? "XXX" : null; + + /// + /// Overall community rating of the show. + /// + public float CommunityRating => + (float)(SeasonList.Aggregate(0f, (total, seasonInfo) => total + seasonInfo.AniDB.Rating.ToFloat(10)) / SeasonList.Count); + + /// + /// The date of the earliest imported file, or when the series was created + /// in shoko if no files are imported yet. + /// + public readonly DateTime? EarliestImportedAt; + + /// + /// The date of the last imported file, or when the series was created + /// in shoko if no files are imported yet. + /// + public readonly DateTime? LastImportedAt; + + /// + /// All tags from across all seasons. + /// + public readonly IReadOnlyList Tags; + + /// + /// All genres from across all seasons. + /// + public readonly IReadOnlyList Genres; + + /// + /// All production locations from across all seasons. + /// + public readonly IReadOnlyList ProductionLocations; + + /// + /// All studios from across all seasons. + /// + public readonly IReadOnlyList Studios; + + /// + /// All staff from across all seasons. + /// + public readonly IReadOnlyList Staff; + + /// + /// All seasons. + /// + public readonly List SeasonList; + + /// + /// The season order dictionary. + /// + public readonly IReadOnlyDictionary SeasonOrderDictionary; + + /// + /// The season number base-number dictionary. + /// + private readonly IReadOnlyDictionary SeasonNumberBaseDictionary; + + /// + /// A pre-filtered set of special episode ids without an ExtraType + /// attached. + /// + public readonly IReadOnlyDictionary SpecialsDict; + + /// + /// Indicates that the show has specials. + /// + public bool HasSpecials => + SpecialsDict.Count > 0; + + /// + /// Indicates that the show has specials with files. + /// + public bool HasSpecialsWithFiles => + SpecialsDict.Values.Contains(true); + + /// + /// The default season for the show. + /// + public readonly SeasonInfo DefaultSeason; + + /// + /// Episode number padding for file name generation. + /// + public readonly int EpisodePadding; + + public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) + { + var seasonNumberBaseDictionary = new Dictionary(); + var seasonOrderDictionary = new Dictionary(); + var seasonNumberOffset = 1; + if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0) + seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset); + if (seasonInfo.EpisodeList.Count > 0) + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); + if (seasonInfo.AlternateEpisodesList.Count > 0) + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); + Id = seasonInfo.Id; + GroupId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); + CollectionId = collectionId ?? seasonInfo.Shoko.IDs.ParentGroup.ToString(); + Name = seasonInfo.Shoko.Name; + EarliestImportedAt = seasonInfo.EarliestImportedAt; + LastImportedAt = seasonInfo.LastImportedAt; + Tags = seasonInfo.Tags; + Genres = seasonInfo.Genres; + ProductionLocations = seasonInfo.ProductionLocations; + Studios = seasonInfo.Studios; + Staff = seasonInfo.Staff; + SeasonList = new List() { seasonInfo }; + SeasonNumberBaseDictionary = seasonNumberBaseDictionary; + SeasonOrderDictionary = seasonOrderDictionary; + SpecialsDict = seasonInfo.SpecialsList.ToDictionary(episodeInfo => episodeInfo.Id, episodeInfo => episodeInfo.Shoko.Size > 0); + DefaultSeason = seasonInfo; + EpisodePadding = Math.Max(2, (new int[] { seasonInfo.EpisodeList.Count, seasonInfo.AlternateEpisodesList.Count, seasonInfo.SpecialsList.Count }).Max().ToString().Length); + } + + public ShowInfo(Group group, List seasonList, ILogger logger, bool useGroupIdForCollection) + { + var groupId = group.IDs.Shoko.ToString(); + + // Order series list + var orderingType = Plugin.Instance.Configuration.SeasonOrdering; + switch (orderingType) { + case Ordering.OrderType.Default: + break; + case Ordering.OrderType.ReleaseDate: + seasonList = seasonList.OrderBy(s => s?.AniDB?.AirDate ?? DateTime.MaxValue).ToList(); + break; + case Ordering.OrderType.Chronological: + case Ordering.OrderType.ChronologicalIgnoreIndirect: + seasonList.Sort(new SeriesInfoRelationComparer()); + break; + } + + // Select the targeted id if a group specify a default series. + int foundIndex = -1; + switch (orderingType) { + case Ordering.OrderType.ReleaseDate: + foundIndex = 0; + break; + case Ordering.OrderType.Default: + case Ordering.OrderType.Chronological: + case Ordering.OrderType.ChronologicalIgnoreIndirect: { + int targetId = group.IDs.MainSeries; + foundIndex = seasonList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); + break; + } + } + + // Fallback to the first series if we can't get a base point for seasons. + if (foundIndex == -1) { + logger.LogWarning("Unable to get a base-point for seasons within the group for the filter, so falling back to the first series in the group. This is most likely due to library separation being enabled. (Group={GroupID})", groupId); + foundIndex = 0; + } + + var defaultSeason = seasonList[foundIndex]; + var specialsSet = new Dictionary(); + var seasonOrderDictionary = new Dictionary(); + var seasonNumberBaseDictionary = new Dictionary(); + var seasonNumberOffset = 1; + foreach (var seasonInfo in seasonList) { + if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0) + seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset); + if (seasonInfo.EpisodeList.Count > 0) + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); + if (seasonInfo.AlternateEpisodesList.Count > 0) + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); + foreach (var episodeInfo in seasonInfo.SpecialsList) + specialsSet.Add(episodeInfo.Id, episodeInfo.Shoko.Size > 0); + } + + Id = defaultSeason.Id; + GroupId = groupId; + Name = group.Name; + Shoko = group; + CollectionId = useGroupIdForCollection ? groupId : group.IDs.ParentGroup?.ToString(); + EarliestImportedAt = seasonList.Select(seasonInfo => seasonInfo.EarliestImportedAt).Min(); + LastImportedAt = seasonList.Select(seasonInfo => seasonInfo.LastImportedAt).Max(); + Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); + Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); + ProductionLocations = seasonList.SelectMany(s => s.ProductionLocations).Distinct().ToArray(); + Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); + Staff = seasonList.SelectMany(s => s.Staff).DistinctBy(p => new { p.Type, p.Name, p.Role }).ToArray(); + SeasonList = seasonList; + SeasonNumberBaseDictionary = seasonNumberBaseDictionary; + SeasonOrderDictionary = seasonOrderDictionary; + SpecialsDict = specialsSet; + DefaultSeason = defaultSeason; + EpisodePadding = Math.Max(2, seasonList.SelectMany(s => new int[] { s.EpisodeList.Count, s.AlternateEpisodesList.Count }).Append(specialsSet.Count).Max().ToString().Length); + } + + public bool IsSpecial(EpisodeInfo episodeInfo) + => SpecialsDict.ContainsKey(episodeInfo.Id); + + public bool TryGetBaseSeasonNumberForSeasonInfo(SeasonInfo season, out int baseSeasonNumber) + => SeasonNumberBaseDictionary.TryGetValue(season.Id, out baseSeasonNumber); + + public int GetBaseSeasonNumberForSeasonInfo(SeasonInfo season) + => SeasonNumberBaseDictionary.TryGetValue(season.Id, out var baseSeasonNumber) ? baseSeasonNumber : 0; + + public SeasonInfo? GetSeasonInfoBySeasonNumber(int seasonNumber) + { + if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seasonInfo) && seasonInfo != null)) + return null; + + return seasonInfo; + } +} diff --git a/Shokofin/API/Models/ApiException.cs b/Shokofin/API/Models/ApiException.cs new file mode 100644 index 00000000..34e229fe --- /dev/null +++ b/Shokofin/API/Models/ApiException.cs @@ -0,0 +1,99 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; + +namespace Shokofin.API.Models; + +[Serializable] +public class ApiException : Exception +{ + + private record ValidationResponse + { + public Dictionary errors = new(); + + public string title = string.Empty; + + public HttpStatusCode status = HttpStatusCode.BadRequest; + } + + public readonly HttpStatusCode StatusCode; + + public readonly ApiExceptionType Type; + + public readonly RemoteApiException? Inner; + + public readonly Dictionary ValidationErrors; + + public ApiException(HttpStatusCode statusCode, string source, string? message) : base(string.IsNullOrEmpty(message) ? source : $"{source}: {message}") + { + StatusCode = statusCode; + Type = ApiExceptionType.Simple; + ValidationErrors = new(); + } + + protected ApiException(HttpStatusCode statusCode, RemoteApiException inner) : base(inner.Message, inner) + { + StatusCode = statusCode; + Type = ApiExceptionType.RemoteException; + Inner = inner; + ValidationErrors = new(); + } + + protected ApiException(HttpStatusCode statusCode, string source, string? message, Dictionary? validationErrors = null): base(string.IsNullOrEmpty(message) ? source : $"{source}: {message}") + { + StatusCode = statusCode; + Type = ApiExceptionType.ValidationErrors; + ValidationErrors = validationErrors ?? new(); + } + + public static ApiException FromResponse(HttpResponseMessage response) + { + var text = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + if (text.Length > 0 && text[0] == '{') { + var full = JsonSerializer.Deserialize(text); + var title = full?.title; + var validationErrors = full?.errors; + return new ApiException(response.StatusCode, "ValidationError", title, validationErrors); + } + var index = text.IndexOf("HEADERS"); + if (index != -1) { + var (firstLine, lines) = text[..index].TrimEnd().Split('\n'); + var (name, splitMessage) = firstLine?.Split(':') ?? Array.Empty(); + var message = string.Join(':', splitMessage).Trim(); + var stackTrace = string.Join('\n', lines); + return new ApiException(response.StatusCode, new RemoteApiException(name ?? "InternalServerException", message, stackTrace)); + } + return new ApiException(response.StatusCode, response.StatusCode.ToString() + "Exception", text.Split('\n').FirstOrDefault() ?? string.Empty); + } + + public class RemoteApiException : Exception + { + public RemoteApiException(string source, string message, string stack) : base($"{source}: {message}") + { + Source = source; + StackTrace = stack; + } + + /// + public override string StackTrace { get; } + } + + public enum ApiExceptionType + { + Simple = 0, + ValidationErrors = 1, + RemoteException = 2, + } +} + +public static class IListExtension { + public static void Deconstruct(this IList list, out T? first, out IList rest) { + first = list.Count > 0 ? list[0] : default; // or throw + rest = list.Skip(1).ToList(); + } +} diff --git a/Shokofin/API/Models/ApiKey.cs b/Shokofin/API/Models/ApiKey.cs new file mode 100644 index 00000000..4e2e1c0f --- /dev/null +++ b/Shokofin/API/Models/ApiKey.cs @@ -0,0 +1,13 @@ + +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class ApiKey +{ + /// + /// The Api Key Token. + /// + [JsonPropertyName("apikey")] + public string Token { get; set; } = string.Empty; +} diff --git a/Shokofin/API/Models/ComponentVersion.cs b/Shokofin/API/Models/ComponentVersion.cs new file mode 100644 index 00000000..9080dd52 --- /dev/null +++ b/Shokofin/API/Models/ComponentVersion.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class ComponentVersionSet +{ + /// + /// Shoko.Server version. + /// + public ComponentVersion Server { get; set; } = new(); +} + +public class ComponentVersion +{ + /// + /// Version number. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Commit SHA. + /// + public string? Commit { get; set; } + + /// + /// Release channel. + /// + public ReleaseChannel? ReleaseChannel { get; set; } + + /// + /// Release date. + /// + public DateTime? ReleaseDate { get; set; } = null; + + public override string ToString() + { + var extraDetails = new string?[3] { + ReleaseChannel?.ToString(), + Commit?[0..7], + ReleaseDate?.ToUniversalTime().ToString("yyyy-MM-ddThh:mm:ssZ"), + }.Where(s => !string.IsNullOrEmpty(s)).OfType().Join(", "); + if (extraDetails.Length == 0) + return $"Version {Version}"; + + return $"Version {Version} ({extraDetails})"; + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReleaseChannel +{ + Stable = 1, + Dev = 2, + Debug = 3, +} diff --git a/Shokofin/API/Models/CrossReference.cs b/Shokofin/API/Models/CrossReference.cs new file mode 100644 index 00000000..babc6bf4 --- /dev/null +++ b/Shokofin/API/Models/CrossReference.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class CrossReference +{ + /// + /// The Series IDs + /// + [JsonPropertyName("SeriesID")] + public SeriesCrossReferenceIDs Series { get; set; } = new(); + + /// + /// The Episode IDs + /// + [JsonPropertyName("EpisodeIDs")] + public List Episodes { get; set; } = new(); + + /// + /// File episode cross-reference for a series. + /// + public class EpisodeCrossReferenceIDs + { + /// + /// The Shoko ID, if the local metadata has been created yet. + /// + [JsonPropertyName("ID")] + public int? Shoko { get; set; } + + /// + /// The AniDB ID. + /// + public int AniDB { get; set; } + + public int? ReleaseGroup { get; set; } + + /// + /// Percentage file is matched to the episode. + /// + public CrossReferencePercentage? Percentage { get; set; } + } + + public class CrossReferencePercentage + { + /// + /// File/episode cross-reference percentage range end. + /// + public int Start { get; set; } + + /// + /// File/episode cross-reference percentage range end. + /// + public int End { get; set; } + + /// + /// The raw percentage to "group" the cross-references by. + /// + public int Size { get; set; } + + /// + /// The assumed number of groups in the release, to group the + /// cross-references by. + /// + public int? Group { get; set; } + } + + /// + /// File series cross-reference. + /// + public class SeriesCrossReferenceIDs + { + /// + /// The Shoko ID, if the local metadata has been created yet. + /// /// + [JsonPropertyName("ID")] + + public int? Shoko { get; set; } + + /// + /// The AniDB ID. + /// + public int AniDB { get; set; } + } +} \ No newline at end of file diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs new file mode 100644 index 00000000..2964655a --- /dev/null +++ b/Shokofin/API/Models/Episode.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class Episode +{ + /// + /// All identifiers related to the episode entry, e.g. the Shoko, AniDB, + /// TvDB, etc. + /// + public EpisodeIDs IDs { get; set; } = new(); + + public string Name { get; set; } = string.Empty; + + /// + /// The duration of the episode. + /// + public TimeSpan Duration { get; set; } + + /// + /// Indicates the episode is hidden. + /// + public bool IsHidden { get; set; } + + /// + /// Number of files + /// + /// + public int Size { get; set; } + + /// + /// The , if is + /// included in the data to add. + /// + [JsonPropertyName("AniDB")] + public AniDB AniDBEntity { get; set; } = new(); + + /// + /// The entries, if + /// is included in the data to add. + /// + [JsonPropertyName("TvDB")] + public List TvDBEntityList { get; set; } = new(); + + /// + /// File cross-references for the episode. + /// + public List CrossReferences { get; set; } = new(); + + public class AniDB + { + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// + /// The duration of the episode. + /// + public TimeSpan Duration { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public EpisodeType Type { get; set; } + + public int EpisodeNumber { get; set; } + + public DateTime? AirDate { get; set; } + + public List Titles { get; set; } = new(); + + public string Description { get; set; } = string.Empty; + + public Rating Rating { get; set; } = new(); + } + + public class TvDB + { + [JsonPropertyName("ID")] + public int Id { get; set; } + + [JsonPropertyName("Season")] + public int SeasonNumber { get; set; } + + [JsonPropertyName("Number")] + public int EpisodeNumber { get; set; } + + [JsonPropertyName("AbsoluteNumber")] + public int AbsoluteEpisodeNumber { get; set; } + + public string Title { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public DateTime? AirDate { get; set; } + + public int? AirsAfterSeason { get; set; } + + public int? AirsBeforeSeason { get; set; } + + public int? AirsBeforeEpisode { get; set; } + + public Rating? Rating { get; set; } + + public Image Thumbnail { get; set; } = new(); + } + + public class EpisodeIDs : IDs + { + public int Series { get; set; } + + public int AniDB { get; set; } + + public List<int> TvDB { get; set; } = new(); + } +} + + +public enum EpisodeType +{ + /// <summary> + /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. + /// </summary> + Other = 1, + + /// <summary> + /// The episode type is unknown. + /// </summary> + Unknown = Other, + + /// <summary> + /// A normal episode. + /// </summary> + Normal = 2, + + /// <summary> + /// A special episode. + /// </summary> + Special = 3, + + /// <summary> + /// A trailer. + /// </summary> + Trailer = 4, + + /// <summary> + /// Either an opening-song, or an ending-song. + /// </summary> + ThemeSong = 5, + + /// <summary> + /// Intro, and/or opening-song. + /// </summary> + OpeningSong = 6, + + /// <summary> + /// Outro, end-roll, credits, and/or ending-song. + /// </summary> + EndingSong = 7, + + /// <summary> + /// AniDB parody type. Where else would this be useful? + /// </summary> + Parody = 8, + + /// <summary> + /// A interview tied to the series. + /// </summary> + Interview = 9, + + /// <summary> + /// A DVD or BD extra, e.g. BD-menu or deleted scenes. + /// </summary> + Extra = 10, +} + diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs new file mode 100644 index 00000000..ac90f9fd --- /dev/null +++ b/Shokofin/API/Models/File.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class File +{ + /// <summary> + /// The id of the <see cref="File"/>. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// The Cross Reference Models for every episode this file belongs to, created in a reverse tree and + /// transformed back into a tree. Series -> Episode such that only episodes that this file is linked to are + /// shown. In many cases, this will have arrays of 1 item + /// </summary> + [JsonPropertyName("SeriesIDs")] + public List<CrossReference> CrossReferences { get; set; } = new(); + + /// <summary> + /// The calculated hashes from the <see cref="File"/>. + /// + /// Either will all hashes be filled or none. + /// </summary> + public HashMap Hashes { get; set; } = new(); + + /// <summary> + /// All the <see cref="Location"/>s this <see cref="File"/> is present at. + /// </summary> + public List<Location> Locations { get; set; } = new(); + + /// <summary> + /// Try to fit this file's resolution to something like 1080p, 480p, etc. + /// </summary> + public string Resolution { get; set; } = string.Empty; + + /// <summary> + /// The duration of the file. + /// </summary> + public TimeSpan Duration { get; set; } + + /// <summary> + /// The file creation date of this file. + /// </summary> + [JsonPropertyName("Created")] + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the file was last imported. Usually is a file only imported once, + /// but there may be exceptions. + /// </summary> + [JsonPropertyName("Imported")] + public DateTime? ImportedAt { get; set; } + + [JsonPropertyName("AniDB")] + public AniDB? AniDBData { get; set; } + + /// <summary> + /// The size of the file in bytes. + /// </summary> + public long Size { get; set; } + + /// <summary> + /// Metadata about the location where a file lies, including the import + /// folder it belongs to and the relative path from the base of the import + /// folder to where it lies. + /// </summary> + public class Location + { + /// <summary> + /// File location ID. + /// </summary> + [JsonPropertyName("ID")] + public int? Id { get; set; } + + /// <summary> + /// The id of the <see cref="ImportFolder"/> this <see cref="File"/> + /// resides in. + /// </summary> + [JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } + + /// <summary> + /// The relative path from the base of the <see cref="ImportFolder"/> to + /// where the <see cref="File"/> lies. + /// </summary> + [JsonPropertyName("RelativePath")] + public string InternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } + + /// <summary> + /// The relative path from the base of the <see cref="ImportFolder"/> to + /// where the <see cref="File"/> lies, with a leading slash applied at + /// the start. + /// </summary> + [JsonIgnore] + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } + + /// <summary> + /// True if the server can access the the <see cref="Location.RelativePath"/> at + /// the moment of requesting the data. + /// </summary> + [JsonPropertyName("Accessible")] + public bool IsAccessible { get; set; } = false; + } + + /// <summary> + /// AniDB_File info + /// </summary> + public class AniDB + { + /// <summary> + /// The AniDB File ID. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// Blu-ray, DVD, LD, TV, etc.. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public FileSource Source { get; set; } + + /// <summary> + /// The Release Group. This is usually set, but sometimes is set as "raw/unknown" + /// </summary> + public AniDBReleaseGroup ReleaseGroup { get; set; } = new(); + + /// <summary> + /// The file's version, Usually 1, sometimes more when there are edits released later + /// </summary> + public int Version { get; set; } + + /// <summary> + /// The original FileName. Useful for when you obtained from a shady source or when you renamed it without thinking. + /// </summary> + public string OriginalFileName { get; set; } = string.Empty; + + /// <summary> + /// Is the file marked as deprecated. Generally, yes if there's a V2, and this isn't it + /// </summary> + public bool IsDeprecated { get; set; } + + /// <summary> + /// Mostly applicable to hentai, but on occasion a TV release is censored enough to earn this. + /// </summary> + public bool? IsCensored { get; set; } + + /// <summary> + /// Does the file have chapters. This may be wrong, since it was only added in AVDump2 (a more recent version at that) + /// </summary> + [JsonPropertyName("Chaptered")] + public bool IsChaptered { get; set; } + + /// <summary> + /// The file's release date. This is probably not filled in + /// </summary> + [JsonPropertyName("ReleaseDate")] + public DateTime? ReleasedAt { get; set; } + + /// <summary> + /// When we last got data on this file + /// </summary> + [JsonPropertyName("Updated")] + public DateTime LastUpdatedAt { get; set; } + } + + public class AniDBReleaseGroup + { + /// <summary> + /// The AniDB Release Group ID. + /// /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// The release group's Name (Unlimited Translation Works) + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// The release group's Name (UTW) + /// </summary> + public string? ShortName { get; set; } + } + + /// <summary> + /// The calculated hashes of the file. Either will all hashes be filled or + /// none. + /// </summary> + public class HashMap + { + public string ED2K { get; set; } = string.Empty; + + public string SHA1 { get; set; } = string.Empty; + + public string CRC32 { get; set; } = string.Empty; + + public string MD5 { get; set; } = string.Empty; + } + + + /// <summary> + /// User stats for the file. + /// </summary> + public class UserStats + { + /// <summary> + /// Where to resume the next playback. + /// </summary> + public TimeSpan? ResumePosition { get; set; } + + /// <summary> + /// Total number of times the file have been watched. + /// </summary> + public int WatchedCount { get; set; } + + /// <summary> + /// When the file was last watched. Will be null if the full is + /// currently marked as unwatched. + /// </summary> + public DateTime? LastWatchedAt { get; set; } + + /// <summary> + /// When the entry was last updated. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + /// <summary> + /// True if the <see cref="UserStats"/> object is considered empty. + /// </summary> + public virtual bool IsEmpty + { + get => ResumePosition == null && LastWatchedAt == null && WatchedCount == 0; + } + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FileSource +{ + Unknown = 0, + Other = 1, + TV = 2, + DVD = 3, + BluRay = 4, + Web = 5, + VHS = 6, + VCD = 7, + LaserDisc = 8, + Camera = 9 +} diff --git a/Shokofin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs new file mode 100644 index 00000000..7acdcf35 --- /dev/null +++ b/Shokofin/API/Models/Group.cs @@ -0,0 +1,58 @@ +namespace Shokofin.API.Models; + +public class Group +{ + public string Name { get; set; } = string.Empty; + + public int Size { get; set; } + + public GroupIDs IDs { get; set; } = new(); + + public string SortName { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public bool HasCustomName { get; set; } + + /// <summary> + /// Sizes object, has totals + /// </summary> + public GroupSizes Sizes { get; set; } = new(); + + public class GroupIDs : IDs + { + public int MainSeries { get; set; } + + public int? ParentGroup { get; set; } + + public int TopLevelGroup { get; set; } + } + + /// <summary> + /// Downloaded, Watched, Total, etc + /// </summary> + public class GroupSizes : Series.SeriesSizes + { + /// <summary> + /// Number of direct sub-groups within the group. + /// /// </summary> + /// <value></value> + public int SubGroups { get; set; } + + /// <summary> + /// Count of the different series types within the group. + /// </summary> + public SeriesTypeCounts SeriesTypes { get; set; } = new(); + + public class SeriesTypeCounts + { + public int Unknown; + public int Other; + public int TV; + public int TVSpecial; + public int Web; + public int Movie; + public int OVA; + } + } +} diff --git a/Shokofin/API/Models/IDs.cs b/Shokofin/API/Models/IDs.cs new file mode 100644 index 00000000..a3c77060 --- /dev/null +++ b/Shokofin/API/Models/IDs.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class IDs +{ + [JsonPropertyName("ID")] + public int Shoko { get; set; } +} diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs new file mode 100644 index 00000000..5a9f9e2c --- /dev/null +++ b/Shokofin/API/Models/Image.cs @@ -0,0 +1,142 @@ +using System; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class Image +{ + /// <summary> + /// AniDB, TvDB, TMDB, etc. + /// </summary> + public ImageSource Source { get; set; } = ImageSource.AniDB; + + /// <summary> + /// Poster, Banner, etc. + /// </summary> + public ImageType Type { get; set; } = ImageType.Poster; + + /// <summary> + /// The image's id. Usually an int, but in the case of <see cref="ImageType.Static"/> resources + /// then it is the resource name. + /// </summary> + public string ID { get; set; } = string.Empty; + + + /// <summary> + /// True if the image is marked as the default for the given <see cref="ImageType"/>. + /// Only one default is possible for a given <see cref="ImageType"/>. + /// </summary> + [JsonPropertyName("Preferred")] + public bool IsDefault { get; set; } = false; + + /// <summary> + /// True if the image has been disabled. You must explicitly ask for these, for obvious reasons. + /// </summary> + [JsonPropertyName("Disabled")] + public bool IsDisabled { get; set; } = false; + + /// <summary> + /// Width of the image, if available. + /// </summary> + public int? Width { get; set; } + + /// <summary> + /// Height of the image, if available. + /// </summary> + public int? Height { get; set; } + + /// <summary> + /// The relative path from the image base directory if the image is present + /// on the server. + /// </summary> + [JsonPropertyName("RelativeFilepath")] + public string? LocalPath { get; set; } + + /// <summary> + /// True if the image is available. + /// </summary> + [JsonIgnore] + public virtual bool IsAvailable + => !string.IsNullOrEmpty(LocalPath); + + /// <summary> + /// Get an URL to both download the image on the backend and preview it for + /// the clients. + /// </summary> + /// <remarks> + /// May or may not work 100% depending on how the servers and clients are + /// set up, but better than nothing. + /// </remarks> + /// <returns>The image URL</returns> + public string ToURLString() + => new Uri(new Uri(Web.ImageHostUrl.Value), $"/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); +} + +/// <summary> +/// Image source. +/// </summary> +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ImageSource +{ + /// <summary> + /// + /// </summary> + AniDB = 1, + + /// <summary> + /// + /// </summary> + TvDB = 2, + + /// <summary> + /// + /// </summary> + TMDB = 3, + + /// <summary> + /// + /// </summary> + Shoko = 100 +} + +/// <summary> +/// Image type. +/// </summary> +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ImageType +{ + /// <summary> + /// + /// </summary> + Poster = 1, + + /// <summary> + /// + /// </summary> + Banner = 2, + + /// <summary> + /// + /// </summary> + Thumb = 3, + + /// <summary> + /// + /// </summary> + Fanart = 4, + + /// <summary> + /// + /// </summary> + Character = 5, + + /// <summary> + /// + /// </summary> + Staff = 6, + + /// <summary> + /// Static resources are only valid if the <see cref="Image.Source"/> is set to <see cref="ImageSource.Shoko"/>. + /// </summary> + Static = 100 +} \ No newline at end of file diff --git a/Shokofin/API/Models/Images.cs b/Shokofin/API/Models/Images.cs new file mode 100644 index 00000000..eeda6d2b --- /dev/null +++ b/Shokofin/API/Models/Images.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Shokofin.API.Models; + +public class Images +{ + public List<Image> Posters { get; set; } = new List<Image>(); + + public List<Image> Fanarts { get; set; } = new List<Image>(); + + public List<Image> Banners { get; set; } = new List<Image>(); +} diff --git a/Shokofin/API/Models/ImportFolder.cs b/Shokofin/API/Models/ImportFolder.cs new file mode 100644 index 00000000..dce9850e --- /dev/null +++ b/Shokofin/API/Models/ImportFolder.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class ImportFolder +{ + /// <summary> + /// The ID of the import folder. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// The friendly name of the import folder, if any. + /// </summary> + public string? Name { get; set; } +} \ No newline at end of file diff --git a/Shokofin/API/Models/ListResult.cs b/Shokofin/API/Models/ListResult.cs new file mode 100644 index 00000000..64e44940 --- /dev/null +++ b/Shokofin/API/Models/ListResult.cs @@ -0,0 +1,23 @@ + +using System.Collections.Generic; + +namespace Shokofin.API.Models; + +/// <summary> +/// A list with the total count of <typeparamref name="T"/> entries that +/// match the filter and a sliced or the full list of <typeparamref name="T"/> +/// entries. +/// </summary> +public class ListResult<T> +{ + /// <summary> + /// Total number of <typeparamref name="T"/> entries that matched the + /// applied filter. + /// </summary> + public int Total { get; set; } = 0; + + /// <summary> + /// A sliced page or the whole list of <typeparamref name="T"/> entries. + /// </summary> + public IReadOnlyList<T> List { get; set; } = new T[] {}; +} diff --git a/Shokofin/API/Models/Rating.cs b/Shokofin/API/Models/Rating.cs new file mode 100644 index 00000000..9d8e852b --- /dev/null +++ b/Shokofin/API/Models/Rating.cs @@ -0,0 +1,34 @@ +namespace Shokofin.API.Models; + +public class Rating +{ + /// <summary> + /// The rating value relative to the <see cref="Rating.MaxValue"/>. + /// </summary> + public decimal Value { get; set; } = 0; + + /// <summary> + /// Max value for the rating. + /// </summary> + public int MaxValue { get; set; } = 0; + + /// <summary> + /// AniDB, etc. + /// </summary> + public string Source { get; set; } = string.Empty; + + /// <summary> + /// number of votes + /// </summary> + public int? Votes { get; set; } + + /// <summary> + /// for temporary vs permanent, or any other situations that may arise later + /// </summary> + public string? Type { get; set; } + + public float ToFloat(uint scale = 1) + { + return (float)((Value * scale) / MaxValue); + } +} diff --git a/Shokofin/API/Models/Relation.cs b/Shokofin/API/Models/Relation.cs new file mode 100644 index 00000000..d7e1f29c --- /dev/null +++ b/Shokofin/API/Models/Relation.cs @@ -0,0 +1,132 @@ +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +/// <summary> +/// Describes relations between two series entries. +/// </summary> +public class Relation +{ + /// <summary> + /// The IDs of the series. + /// </summary> + public RelationIDs IDs { get; set; } = new(); + + /// <summary> + /// The IDs of the related series. + /// </summary> + public RelationIDs RelatedIDs { get; set; } = new(); + + /// <summary> + /// The relation between <see cref="Relation.IDs"/> and <see cref="Relation.RelatedIDs"/>. + /// </summary> + public RelationType Type { get; set; } + + /// <summary> + /// AniDB, etc. + /// </summary> + public string Source { get; set; } = "Unknown"; + + /// <summary> + /// Relation IDs. + /// </summary> + public class RelationIDs + { + /// <summary> + /// The ID of the <see cref="Series"/> entry. + /// </summary> + public int? Shoko { get; set; } + + /// <summary> + /// The ID of the <see cref="Series.AniDB"/> entry. + /// </summary> + public int? AniDB { get; set; } + } +} + +/// <summary> +/// Explains how the main entry relates to the related entry. +/// </summary> +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RelationType +{ + /// <summary> + /// The relation between the entries cannot be explained in simple terms. + /// </summary> + Other = 0, + + /// <summary> + /// The entries use the same setting, but follow different stories. + /// </summary> + SameSetting = 1, + + /// <summary> + /// The entries use the same base story, but is set in alternate settings. + /// </summary> + AlternativeSetting = 2, + + /// <summary> + /// The entries tell the same story in the same settings but are made at different times. + /// </summary> + AlternativeVersion = 3, + + /// <summary> + /// The entries tell different stories in different settings but otherwise shares some character(s). + /// </summary> + SharedCharacters = 4, + + /// <summary> + /// The first story either continues, or expands upon the story of the related entry. + /// </summary> + Prequel = 20, + + /// <summary> + /// The related entry is the main-story for the main entry, which is a side-story. + /// </summary> + MainStory = 21, + + /// <summary> + /// The related entry is a longer version of the summarized events in the main entry. + /// </summary> + FullStory = 22, + + /// <summary> + /// The related entry either continues, or expands upon the story of the main entry. + /// </summary> + Sequel = 40, + + /// <summary> + /// The related entry is a side-story for the main entry, which is the main-story. + /// </summary> + SideStory = 41, + + /// <summary> + /// The related entry summarizes the events of the story in the main entry. + /// </summary> + Summary = 42, +} + +/// <summary> +/// Extensions related to relations +/// </summary> +public static class RelationExtensions +{ + /// <summary> + /// Reverse the relation. + /// </summary> + /// <param name="type">The relation to reverse.</param> + /// <returns>The reversed relation.</returns> + public static RelationType Reverse(this RelationType type) + { + return type switch + { + RelationType.Prequel => RelationType.Sequel, + RelationType.Sequel => RelationType.Prequel, + RelationType.MainStory => RelationType.SideStory, + RelationType.SideStory => RelationType.MainStory, + RelationType.FullStory => RelationType.Summary, + RelationType.Summary => RelationType.FullStory, + _ => type + }; + } +} diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs new file mode 100644 index 00000000..764ed7b7 --- /dev/null +++ b/Shokofin/API/Models/Role.cs @@ -0,0 +1,161 @@ +using System; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class Role : IEquatable<Role> +{ + /// <summary> + /// Extra info about the role. For example, role can be voice actor, while role_details is Main Character + /// </summary> + [JsonPropertyName("RoleDetails")] + public string Name { get; set; } = string.Empty; + + /// <summary> + /// The role that the staff plays, cv, writer, director, etc + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("RoleName")] + public CreatorRoleType Type { get; set; } + + /// <summary> + /// Most will be Japanese. Once AniList is in, it will have multiple options + /// </summary> + public string? Language { get; set; } + + public Person Staff { get; set; } = new(); + + /// <summary> + /// The character played, the <see cref="Role.Type"/> is of type + /// <see cref="CreatorRoleType.Seiyuu"/>. + /// </summary> + public Person? Character { get; set; } + + public override bool Equals(object? obj) + => Equals(obj as Role); + + public bool Equals(Role? other) + { + if (other is null) + return false; + + return string.Equals(Name, other.Name, StringComparison.Ordinal) && + Type == other.Type && + string.Equals(Language, other.Language, StringComparison.Ordinal) && + Staff.Equals(other.Staff) && + (Character is null ? other.Character is null : Character.Equals(other.Character)); + } + + public override int GetHashCode() + { + var hash = 17; + + hash = hash * 31 + (Name?.GetHashCode() ?? 0); + hash = hash * 31 + Type.GetHashCode(); + hash = hash * 31 + (Language?.GetHashCode() ?? 0); + hash = hash * 31 + Staff.GetHashCode(); + hash = hash * 31 + (Character?.GetHashCode() ?? 0); + + return hash; + } + + public class Person : IEquatable<Person> + { + /// <summary> + /// Main Name, romanized if needed + /// ex. John Smith + /// </summary> + 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; } + + /// <summary> + /// A description, bio, etc + /// ex. John Smith was born September 12, 1980 in Tokyo, Japan. He is a composer and arranger. + /// </summary> + public string Description { get; set; } = string.Empty; + + /// <summary> + /// Visual representation of the character or staff. Usually a profile + /// picture. + /// </summary> + public Image Image { get; set; } = new(); + + public override bool Equals(object? obj) + => Equals(obj as Person); + + public bool Equals(Person? other) + { + if (other is null) + return false; + + return string.Equals(Name, other.Name, StringComparison.Ordinal) && + string.Equals(Description, other.Description, StringComparison.Ordinal) && + string.Equals(AlternateName, other.AlternateName, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + var hash = 17; + + hash = hash * 31 + (Name?.GetHashCode() ?? 0); + hash = hash * 31 + (AlternateName?.GetHashCode() ?? 0); + hash = hash * 31 + (Description?.GetHashCode() ?? 0); + + return hash; + } + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CreatorRoleType +{ + /// <summary> + /// Voice actor or voice actress. + /// </summary> + Seiyuu, + + /// <summary> + /// This can be anything involved in writing the show. + /// </summary> + Staff, + + /// <summary> + /// The studio responsible for publishing the show. + /// </summary> + Studio, + + /// <summary> + /// The main producer(s) for the show. + /// </summary> + Producer, + + /// <summary> + /// Direction. + /// </summary> + Director, + + /// <summary> + /// Series Composition. + /// </summary> + SeriesComposer, + + /// <summary> + /// Character Design. + /// </summary> + CharacterDesign, + + /// <summary> + /// Music composer. + /// </summary> + Music, + + /// <summary> + /// Responsible for the creation of the source work this show is derived from. + /// </summary> + SourceWork, +} diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs new file mode 100644 index 00000000..179799dd --- /dev/null +++ b/Shokofin/API/Models/Series.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class Series +{ + public string Name { get; set; } = string.Empty; + + public int Size { get; set; } + + /// <summary> + /// All identifiers related to the series entry, e.g. the Shoko, AniDB, + /// TvDB, etc. + /// </summary> + public SeriesIDs IDs { get; set; } = new(); + + /// <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. + /// </summary> + public Images Images { get; set; } = new(); + + /// <summary> + /// The user's rating, if any. + /// </summary> + public Rating? UserRating { get; set; } = new(); + + /// <summary> + /// The AniDB entry. + /// </summary> + [JsonPropertyName("AniDB")] + public AniDBWithDate AniDBEntity { get; set; } = new(); + + /// <summary> + /// The TvDB entries, if any. + /// </summary> + [JsonPropertyName("TvDB")] + public List<TvDB> TvDBEntityList { get; set; }= new(); + + public SeriesSizes Sizes { get; set; } = new(); + + /// <summary> + /// When the series entry was created during the process of the first file + /// being added to Shoko. + /// </summary> + [JsonPropertyName("Created")] + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the series entry was last updated. + /// </summary> + [JsonPropertyName("Updated")] + public DateTime LastUpdatedAt { get; set; } + + public class AniDB + { + /// <summary> + /// AniDB Id + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// <see cref="Series"/> Id if the series is available locally. + /// </summary> + [JsonPropertyName("ShokoID")] + public int? ShokoId { get; set; } + + /// <summary> + /// Series type. Series, OVA, Movie, etc + /// </summary> + public SeriesType Type { get; set; } + + /// <summary> + /// Main Title, usually matches x-jat + /// </summary> + public string Title { get; set; } = string.Empty; + + /// <summary> + /// There should always be at least one of these, the <see cref="Title"/>. May be omitted if needed. + /// </summary> + public List<Title>? Titles { get; set; } + + /// <summary> + /// Description. + /// </summary> + public string Description { get; set; } = string.Empty; + + /// <summary> + /// Restricted content. Mainly porn. + /// </summary> + public bool Restricted { get; set; } + + /// <summary> + /// The main or default poster. + /// </summary> + public Image Poster { get; set; } = new(); + + /// <summary> + /// Number of <see cref="EpisodeType.Normal"/> episodes contained within the series if it's known. + /// </summary> + public int? EpisodeCount { get; set; } + + /// <summary> + /// The average rating for the anime. Only available on + /// </summary> + public Rating? Rating { get; set; } + + /// <summary> + /// User approval rate for the similar submission. Only available for similar. + /// </summary> + public Rating? UserApproval { get; set; } + + /// <summary> + /// Relation type. Only available for relations. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public RelationType? Relation { get; set; } + } + + public class AniDBWithDate : AniDB + { + /// <summary> + /// Description. + /// </summary> + public new string Description { get; set; } = string.Empty; + + /// <summary> + /// There should always be at least one of these, the <see cref="Title"/>. May be omitted if needed. + /// </summary> + public new List<Title> Titles { get; set; } = new(); + + /// <summary> + /// The average rating for the anime. Only available on + /// </summary> + public new Rating Rating { get; set; } = new(); + + /// <summary> + /// Number of <see cref="EpisodeType.Normal"/> episodes contained within the series if it's known. + /// </summary> + public new int EpisodeCount { get; set; } + + [JsonIgnore] + private DateTime? InternalAirDate { get; set; } = null; + + /// <summary> + /// Air date (2013-02-27). Anything without an air date is going to be missing a lot of info. + /// </summary> + public DateTime? AirDate + { + get + { + return InternalAirDate; + } + set + { + InternalAirDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; + } + } + + [JsonIgnore] + private DateTime? InternalEndDate { get; set; } = null; + + /// <summary> + /// End date, can be omitted. Omitted means that it's still airing (2013-02-27) + /// </summary> + public DateTime? EndDate + { + get + { + return InternalEndDate; + } + set + { + InternalEndDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; + } + } + } + + public class TvDB + { + /// <summary> + /// TvDB Id. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + public DateTime? AirDate { get; set; } + + public DateTime? EndDate { get; set; } + + public string Title { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public Rating Rating { get; set; } = new(); + } + + public class SeriesIDs : IDs + { + public int ParentGroup { get; set; } = 0; + + public int TopLevelGroup { get; set; } = 0; + + public int AniDB { get; set; } = 0; + + public List<int> TvDB { get; set; } = new List<int>(); + + public List<int> TMDB { get; set; } = new List<int>(); + + public List<int> MAL { get; set; } = new List<int>(); + + public List<string> TraktTv { get; set; } = new List<string>(); + + public List<int> AniList { get; set; } = new List<int>(); + } + + /// <summary> + /// Downloaded, Watched, Total, etc + /// </summary> + public class SeriesSizes + { + /// <summary> + /// Combined count of all files across all file sources within the series or group. + /// </summary> + public int Files => + FileSources.Unknown + + FileSources.Other + + FileSources.TV + + FileSources.DVD + + FileSources.BluRay + + FileSources.Web + + FileSources.VHS + + FileSources.VCD + + FileSources.LaserDisc + + FileSources.Camera; + + /// <summary> + /// Counts of each file source type available within the local collection + /// </summary> + public FileSourceCounts FileSources { get; set; } = new(); + + /// <summary> + /// What is downloaded and available + /// </summary> + public EpisodeTypeCounts Local { get; set; } = new(); + + /// <summary> + /// What is local and watched. + /// </summary> + public EpisodeTypeCounts Watched { get; set; } = new(); + + /// <summary> + /// Total count of each type + /// </summary> + public EpisodeTypeCounts Total { get; set; } = new(); + + /// <summary> + /// Lists the count of each type of episode. + /// </summary> + 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; + } + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SeriesType +{ + /// <summary> + /// The series type is unknown. + /// </summary> + Unknown, + /// <summary> + /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. + /// </summary> + Other, + /// <summary> + /// Standard TV series. + /// </summary> + TV, + /// <summary> + /// TV special. + /// </summary> + TVSpecial, + /// <summary> + /// Web series. + /// </summary> + Web, + /// <summary> + /// All movies, regardless of source (e.g. web or theater) + /// </summary> + Movie, + /// <summary> + /// Original Video Animations, AKA standalone releases that don't air on TV or the web. + /// </summary> + OVA, +} + diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs new file mode 100644 index 00000000..a4b61fef --- /dev/null +++ b/Shokofin/API/Models/Tag.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; + +namespace Shokofin.API.Models; + +public class Tag +{ + /// <summary> + /// Tag id. Relative to it's source for now. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// Parent id relative to the source, if any. + /// </summary> + [JsonPropertyName("ParentID")] + public int? ParentId { get; set; } + + /// <summary> + /// The tag itself + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// What does the tag mean/what's it for + /// </summary> + public string? Description { get; set; } + + /// <summary> + /// True if the tag has been verified. + /// </summary> + /// <remarks> + /// For anidb does this mean the tag has been verified for use, and is not + /// an unsorted tag. Also, anidb hides unverified tags from appearing in + /// their UI except when the tags are edited. + /// </remarks> + public bool? IsVerified { get; set; } + + /// <summary> + /// True if the tag is considered a spoiler for all series it appears on. + /// </summary> + [JsonPropertyName("IsSpoiler")] + public bool IsGlobalSpoiler { get; set; } + + /// <summary> + /// True if the tag is considered a spoiler for that particular series it is + /// set on. + /// </summary> + public bool? IsLocalSpoiler { get; set; } + + /// <summary> + /// How relevant is it to the series + /// </summary> + public TagWeight? Weight { get; set; } + + /// <summary> + /// When the tag info was last updated. + /// </summary> + public DateTime? LastUpdated { get; set; } + + /// <summary> + /// Source. AniDB, User, etc. + /// </summary> + public string Source { get; set; } = string.Empty; +} + +public class ResolvedTag : Tag +{ + // All the abstract tags I know about. + private static readonly HashSet<string> AbstractTags = new() { + "/content indicators", + "/dynamic", + "/dynamic/cast", + "/dynamic/ending", + "/dynamic/storytelling", + "/elements", + "/elements/motifs", + "/elements/pornography", + "/elements/pornography/group sex", + "/elements/pornography/oral", + "/elements/sexual abuse", + "/elements/speculative fiction", + "/elements/tropes", + "/fetishes", + "/fetishes/breasts", + "/maintenance tags", + "/maintenance tags/TO BE MOVED TO CHARACTER", + "/maintenance tags/TO BE MOVED TO EPISODE", + "/origin", + "/original work", + "/setting", + "/setting/place", + "/setting/time", + "/setting/time/season", + "/target audience", + "/technical aspects", + "/technical aspects/adapted into other media", + "/technical aspects/awards", + "/technical aspects/multi-anime projects", + "/themes", + "/themes/body and host", + "/themes/death", + "/themes/family life", + "/themes/money", + "/themes/tales", + "/ungrouped", + "/unsorted", + "/unsorted/character related tags which need deleting or merging", + "/unsorted/ending tags that need merging", + "/unsorted/old animetags", + }; + + private static readonly Dictionary<string, string> TagNameOverrides = new() { + { "/fetishes/housewives", "MILF" }, + { "/setting/past", "Historical Past" }, + { "/setting/past/alternative past", "Alternative Past" }, + { "/setting/past/historical", "Historical Past" }, + { "/ungrouped/3dd cg", "3D CG animation" }, + { "/ungrouped/condom", "uses condom" }, + { "/ungrouped/dilf", "DILF" }, + { "/unsorted/old animetags/preview in ed", "preview in ED" }, + { "/unsorted/old animetags/recap in opening", "recap in OP" }, + }; + + private static readonly Dictionary<string, string> TagNamespaceOverride = new() { + { "/ungrouped/1950s", "/setting/time/past" }, + { "/ungrouped/1990s", "/setting/time/past" }, + { "/ungrouped/3dd cg", "/technical aspects/CGI" }, + { "/ungrouped/afterlife world", "/setting/place" }, + { "/ungrouped/airhead", "/maintenance tags/TO BE MOVED TO CHARACTER" }, + { "/ungrouped/airport", "/setting/place" }, + { "/ungrouped/anal prolapse", "/elements/pornography" }, + { "/ungrouped/child protagonist", "/dynamic/cast" }, + { "/ungrouped/condom", "/elements/pornography" }, + { "/ungrouped/dilf", "/fetishes" }, + { "/ungrouped/Italian-Japanese co-production", "/target audience" }, + { "/ungrouped/Middle-Aged Protagonist", "/dynamic/cast" }, + { "/ungrouped/creation magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/destruction magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/overpowered magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/paper talisman magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/space magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/very bloody wound in low-pg series", "/technical aspects" }, + { "/unsorted/ending tags that need merging/anti-climactic end", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/cliffhanger ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/complete manga adaptation", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/downer ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/incomplete story", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/only the beginning", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/series end", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/tragic ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/twisted ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/unresolved romance", "/dynamic/ending" }, + { "/unsorted/old animetags/preview in ed", "/technical aspects" }, + { "/unsorted/old animetags/recap in opening", "/technical aspects" }, + }; + + private string? _displayName = null; + + public string DisplayName => _displayName ??= TagNameOverrides.TryGetValue(FullName, out var altName) ? altName : Name; + + private string? _fullName = null; + + public string FullName => _fullName ??= Namespace + Name; + + public bool IsParent => Children.Count is > 0; + + public bool IsAbstract => AbstractTags.Contains(FullName); + + public bool IsWeightless => !IsAbstract && Weight is 0; + + /// <summary> + /// True if the tag is considered a spoiler for that particular series it is + /// set on. + /// </summary> + public new bool IsLocalSpoiler; + + /// <summary> + /// How relevant is it to the series + /// </summary> + public new TagWeight Weight; + + public string Namespace; + + public IReadOnlyDictionary<string, ResolvedTag> Children; + + public IReadOnlyDictionary<string, ResolvedTag> RecursiveNamespacedChildren; + + public ResolvedTag(Tag tag, ResolvedTag? parent, Func<string, int, IEnumerable<Tag>?> getChildren, string ns = "/") + { + Id = tag.Id; + ParentId = parent?.Id; + Name = tag.Name; + Description = tag.Description; + IsVerified = tag.IsVerified; + IsGlobalSpoiler = tag.IsGlobalSpoiler || (parent?.IsGlobalSpoiler ?? false); + IsLocalSpoiler = tag.IsLocalSpoiler ?? parent?.IsLocalSpoiler ?? false; + Weight = tag.Weight ?? TagWeight.Weightless; + LastUpdated = tag.LastUpdated; + Source = tag.Source; + Namespace = TagNamespaceOverride.TryGetValue(ns + "/" + tag.Name, out var newNs) ? newNs : ns; + Children = (getChildren(Source, Id) ?? Array.Empty<Tag>()) + .DistinctBy(childTag => childTag.Name) + .Select(childTag => new ResolvedTag(childTag, this, getChildren, FullName + "/")) + .ToDictionary(childTag => childTag.Name); + RecursiveNamespacedChildren = Children.Values + .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) + .ToDictionary(childTag => childTag.FullName[FullName.Length..]); + } +} \ No newline at end of file diff --git a/Shokofin/API/Models/Title.cs b/Shokofin/API/Models/Title.cs new file mode 100644 index 00000000..676bca5e --- /dev/null +++ b/Shokofin/API/Models/Title.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class Title +{ + /// <summary> + /// The title. + /// </summary> + [JsonPropertyName("Name")] + public string Value { get; set; } = string.Empty; + + /// <summary> + /// 3-digit language code (x-jat, etc. are exceptions) + /// </summary> + [JsonPropertyName("Language")] + public string LanguageCode { get; set; } = "unk"; + /// <summary> + /// AniDB series type. Only available on series titles. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TitleType? Type { get; set; } + + /// <summary> + /// True if this is the default title for the entry. + /// </summary> + [JsonPropertyName("Default")] + public bool IsDefault { get; set; } + + /// <summary> + /// AniDB, TvDB, AniList, etc. + /// </summary> + public string Source { get; set; } = "Unknown"; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TitleType +{ + None = 0, + Main = 1, + Official = 2, + Short = 3, + Synonym = 4, + TitleCard = 5, + KanjiReading = 6, +} \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs new file mode 100644 index 00000000..cebe3456 --- /dev/null +++ b/Shokofin/API/ShokoAPIClient.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Shokofin.API.Models; +using Shokofin.Utils; + +namespace Shokofin.API; + +/// <summary> +/// All API calls to Shoko needs to go through this gateway. +/// </summary> +public class ShokoAPIClient : IDisposable +{ + private readonly HttpClient _httpClient; + + private readonly ILogger<ShokoAPIClient> Logger; + + private static ComponentVersion? ServerVersion => + Plugin.Instance.Configuration.ServerVersion; + + private static readonly DateTime StableCutOffDate = DateTime.Parse("2023-12-16T00:00:00.000Z"); + + private static bool UseOlderSeriesAndFileEndpoints => + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < StableCutOffDate)); + + private static readonly DateTime ImportFolderCutOffDate = DateTime.Parse("2024-03-28T00:00:00.000Z"); + + private static bool UseOlderImportFolderFileEndpoints => + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < ImportFolderCutOffDate)); + + private readonly GuardedMemoryCache _cache; + + public ShokoAPIClient(ILogger<ShokoAPIClient> logger) + { + _httpClient = new HttpClient { + Timeout = TimeSpan.FromMinutes(10), + }; + Logger = logger; + _cache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { SlidingExpiration = new(2, 30, 0) }); + Plugin.Instance.Tracker.Stalled += OnTrackerStalled; + } + + ~ShokoAPIClient() + { + Plugin.Instance.Tracker.Stalled -= OnTrackerStalled; + } + + private void OnTrackerStalled(object? sender, EventArgs eventArgs) + => Clear(); + + #region Base Implementation + + public void Clear() + { + Logger.LogDebug("Clearing data…"); + _cache.Clear(); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + _httpClient.Dispose(); + _cache.Dispose(); + } + + private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = false) + => Get<ReturnType>(url, HttpMethod.Get, apiKey, skipCache); + + private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = false) + { + if (skipCache) { + Logger.LogTrace("Creating object for {Method} {URL}", method, url); + var response = await Get(url, method, apiKey).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + responseStream.Seek(0, System.IO.SeekOrigin.Begin); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + return value; + } + + return await _cache.GetOrCreateAsync( + $"apiKey={apiKey ?? "default"},method={method},url={url},object", + (_) => Logger.LogTrace("Reusing object for {Method} {URL}", method, url), + async () => { + Logger.LogTrace("Creating object for {Method} {URL}", method, url); + var response = await Get(url, method, apiKey).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + responseStream.Seek(0, System.IO.SeekOrigin.Begin); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + return value; + } + ); + } + + private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null, bool skipApiKey = false) + { + // Use the default key if no key was provided. + apiKey ??= Plugin.Instance.Configuration.ApiKey; + + // Check if we have a key to use. + if (string.IsNullOrEmpty(apiKey) && !skipApiKey) + throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + var version = Plugin.Instance.Configuration.ServerVersion; + if (version == null) { + version = await GetVersion().ConfigureAwait(false) + ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + Plugin.Instance.Configuration.ServerVersion = version; + Plugin.Instance.UpdateConfiguration(); + } + + try { + Logger.LogTrace("Trying to {Method} {URL}", method, url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); + + using var requestMessage = new HttpRequestMessage(method, remoteUrl); + requestMessage.Content = new StringContent(string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; + } + catch (HttpRequestException ex) { + Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); + throw; + } + } + + private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? apiKey = null) + => Post<Type, ReturnType>(url, HttpMethod.Post, body, apiKey); + + private async Task<ReturnType> Post<Type, ReturnType>(string url, HttpMethod method, Type body, string? apiKey = null) + { + var response = await Post(url, method, body, apiKey).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + return value; + } + + private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method, Type body, string? apiKey = null) + { + // Use the default key if no key was provided. + apiKey ??= Plugin.Instance.Configuration.ApiKey; + + // Check if we have a key to use. + if (string.IsNullOrEmpty(apiKey)) + throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + var version = Plugin.Instance.Configuration.ServerVersion; + if (version == null) { + version = await GetVersion().ConfigureAwait(false) + ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + Plugin.Instance.Configuration.ServerVersion = version; + Plugin.Instance.UpdateConfiguration(); + } + + try { + Logger.LogTrace("Trying to get {URL}", url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); + + if (method == HttpMethod.Get) + throw new HttpRequestException("Get requests cannot contain a body."); + + if (method == HttpMethod.Head) + throw new HttpRequestException("Head requests cannot contain a body."); + + using var requestMessage = new HttpRequestMessage(method, remoteUrl); + requestMessage.Content = new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json"); + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; + } + catch (HttpRequestException ex) { + Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); + throw; + } + } + + #endregion Base Implementation + + public async Task<ApiKey?> GetApiKey(string username, string password, bool forUser = false) + { + var version = Plugin.Instance.Configuration.ServerVersion; + if (version == null) { + version = await GetVersion().ConfigureAwait(false) + ?? throw new HttpRequestException("Unable to connect to Shoko Server to read the version.", null, HttpStatusCode.BadGateway); + + Plugin.Instance.Configuration.ServerVersion = version; + Plugin.Instance.UpdateConfiguration(); + } + + var postData = JsonSerializer.Serialize(new Dictionary<string, string> + { + {"user", username}, + {"pass", password}, + {"device", forUser ? "Shoko Jellyfin Plugin (Shokofin) - User Key" : "Shoko Jellyfin Plugin (Shokofin)"}, + }); + var apiBaseUrl = Plugin.Instance.Configuration.Url; + var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + return null; + + return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result).ConfigureAwait(false); + } + + public async Task<ComponentVersion?> GetVersion() + { + try { + var apiBaseUrl = Plugin.Instance.Configuration.Url; + var source = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var response = await _httpClient.GetAsync($"{apiBaseUrl}/api/v3/Init/Version", source.Token); + if (response.StatusCode == HttpStatusCode.OK) { + var componentVersionSet = await JsonSerializer.DeserializeAsync<ComponentVersionSet>(response.Content.ReadAsStreamAsync().Result); + return componentVersionSet?.Server; + } + } + catch (Exception e) { + Logger.LogTrace("Unable to connect to Shoko Server to read the version. Exception; {e}", e.Message); + return null; + } + + return null; + } + + public Task<HttpResponseMessage> GetImageAsync(ImageSource imageSource, ImageType imageType, int imageId) + => Get($"/api/v3/Image/{imageSource}/{imageType}/{imageId}", HttpMethod.Get, null, true); + + public async Task<ImportFolder?> GetImportFolder(int id) + { + try { + return await Get<ImportFolder>($"/api/v3/ImportFolder/{id}"); + } + catch (ApiException e) { + if (e.StatusCode == HttpStatusCode.NotFound) + return null; + throw; + } + } + + public Task<File> GetFile(string id) + { + if (UseOlderSeriesAndFileEndpoints) + return Get<File>($"/api/v3/File/{id}?includeXRefs=true&includeDataFrom=AniDB"); + + return Get<File>($"/api/v3/File/{id}?include=XRefs&includeDataFrom=AniDB"); + } + + public Task<List<File>> GetFileByPath(string path) + { + return Get<List<File>>($"/api/v3/File/PathEndsWith?path={Uri.EscapeDataString(path)}&includeDataFrom=AniDB&limit=1"); + } + + public async Task<IReadOnlyList<File>> GetFilesForSeries(string seriesId) + { + if (UseOlderSeriesAndFileEndpoints) + return await Get<List<File>>($"/api/v3/Series/{seriesId}/File?&includeXRefs=true&includeDataFrom=AniDB").ConfigureAwait(false); + + var listResult = await Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB").ConfigureAwait(false); + return listResult.List; + } + + public async Task<ListResult<File>> GetFilesForImportFolder(int importFolderId, string subPath, int page = 1) + { + if (UseOlderImportFolderFileEndpoints) { + return await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?page={page}&pageSize=100&includeXRefs=true", skipCache: true).ConfigureAwait(false); + } + + return await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?page={page}&folderPath={Uri.EscapeDataString(subPath)}&pageSize=1000&include=XRefs", skipCache: true).ConfigureAwait(false); + } + + public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) + { + try { + return await Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey, true).ConfigureAwait(false); + } + catch (ApiException e) { + // File user stats were not found. + if (e.StatusCode == HttpStatusCode.NotFound) { + if (!e.Message.Contains("FileUserStats")) + Logger.LogWarning("Unable to find user stats for a file that doesn't exist. (File={FileID})", fileId); + return null; + } + throw; + } + } + + public Task<File.UserStats> PutFileUserStats(string fileId, File.UserStats userStats, string? apiKey = null) + { + return Post<File.UserStats, File.UserStats>($"/api/v3/File/{fileId}/UserStats", HttpMethod.Put, userStats, apiKey); + } + + public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, bool watched, string apiKey) + { + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); + return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); + } + + public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) + { + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey).ConfigureAwait(false); + return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); + } + + public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long? progress, bool watched, string apiKey) + { + if (!progress.HasValue) + return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); + return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); + } + + public Task<Episode> GetEpisode(string id) + { + return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB,TvDB&includeXRefs=true"); + } + + public Task<ListResult<Episode>> GetEpisodesFromSeries(string seriesId) + { + return Get<ListResult<Episode>>($"/api/v3/Series/{seriesId}/Episode?pageSize=0&includeHidden=true&includeMissing=true&includeDataFrom=AniDB,TvDB&includeXRefs=true"); + } + + public Task<Series> GetSeries(string id) + { + return Get<Series>($"/api/v3/Series/{id}?includeDataFrom=AniDB,TvDB"); + } + + public Task<Series> GetSeriesFromEpisode(string id) + { + return Get<Series>($"/api/v3/Episode/{id}/Series?includeDataFrom=AniDB,TvDB"); + } + + public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0, bool recursive = false) + { + return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?recursive={recursive}&includeMissing=true&includeIgnored=false&includeDataFrom=AniDB,TvDB"); + } + + public Task<List<Role>> GetSeriesCast(string id) + { + return Get<List<Role>>($"/api/v3/Series/{id}/Cast"); + } + + public Task<List<Relation>> GetSeriesRelations(string id) + { + return Get<List<Relation>>($"/api/v3/Series/{id}/Relations"); + } + + public Task<Images> GetSeriesImages(string id) + { + return Get<Images>($"/api/v3/Series/{id}/Images"); + } + + public Task<List<Series>> GetSeriesPathEndsWith(string dirname) + { + return Get<List<Series>>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); + } + + public Task<List<Tag>> GetSeriesTags(string id, ulong filter = 0) + { + return Get<List<Tag>>($"/api/v3/Series/{id}/Tags?filter={filter}&excludeDescriptions=true"); + } + + public Task<Group> GetGroup(string id) + { + return Get<Group>($"/api/v3/Group/{id}"); + } + + public Task<List<Group>> GetGroupsInGroup(string id) + { + return Get<List<Group>>($"/api/v3/Group/{id}/Group?includeEmpty=true"); + } + + public Task<Group> GetGroupFromSeries(string id) + { + return Get<Group>($"/api/v3/Series/{id}/Group"); + } +} diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs new file mode 100644 index 00000000..e76fb083 --- /dev/null +++ b/Shokofin/API/ShokoAPIManager.cs @@ -0,0 +1,1185 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +using Path = System.IO.Path; +using Regex = System.Text.RegularExpressions.Regex; +using RegexOptions = System.Text.RegularExpressions.RegexOptions; + +namespace Shokofin.API; + +public class ShokoAPIManager : IDisposable +{ + private static readonly Regex YearRegex = new(@"\s+\((?<year>\d{4})\)\s*$", RegexOptions.Compiled); + + private readonly ILogger<ShokoAPIManager> Logger; + + private readonly ShokoAPIClient APIClient; + + private readonly ILibraryManager LibraryManager; + + private readonly object MediaFolderListLock = new(); + + private readonly List<Folder> MediaFolderList = new(); + + private readonly ConcurrentDictionary<string, string> PathToSeriesIdDictionary = new(); + + private readonly ConcurrentDictionary<string, string> NameToSeriesIdDictionary = new(); + + private readonly ConcurrentDictionary<string, List<string>> PathToEpisodeIdsDictionary = new(); + + private readonly ConcurrentDictionary<string, (string FileId, string SeriesId)> PathToFileIdAndSeriesIdDictionary = new(); + + private readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new(); + + private readonly ConcurrentDictionary<string, string> SeriesIdToDefaultSeriesIdDictionary = new(); + + private readonly ConcurrentDictionary<string, string?> SeriesIdToCollectionIdDictionary = new(); + + private readonly ConcurrentDictionary<string, string> EpisodeIdToEpisodePathDictionary = new(); + + private readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new(); + + private readonly ConcurrentDictionary<string, List<string>> FileAndSeriesIdToEpisodeIdDictionary = new(); + + private readonly GuardedMemoryCache DataCache; + + public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient, ILibraryManager libraryManager) + { + Logger = logger; + APIClient = apiClient; + LibraryManager = libraryManager; + DataCache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = new(2, 30, 0) }); + Plugin.Instance.Tracker.Stalled += OnTrackerStalled; + } + + ~ShokoAPIManager() + { + Plugin.Instance.Tracker.Stalled -= OnTrackerStalled; + } + + private void OnTrackerStalled(object? sender, EventArgs eventArgs) + => Clear(); + + #region Ignore rule + + /// <summary> + /// We'll let the ignore rule "scan" for the media folder, and populate our + /// dictionary for later use, then we'll use said dictionary to lookup the + /// media folder by path later in the ignore rule and when stripping the + /// media folder from the path to get the relative path in + /// <see cref="StripMediaFolder"/>. + /// </summary> + /// <param name="path">The path to find the media folder for.</param> + /// <param name="parent">The parent folder of <paramref name="path"/>. + /// </param> + /// <returns>The media folder and partial string within said folder for + /// <paramref name="path"/>.</returns> + public (Folder mediaFolder, string partialPath) FindMediaFolder(string path, Folder parent) + { + Folder? mediaFolder = null; + lock (MediaFolderListLock) + mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + if (mediaFolder is not null) + return (mediaFolder, path[mediaFolder.Path.Length..]); + if (parent.GetTopParent() is not Folder topParent) + throw new Exception($"Unable to find media folder for path \"{path}\""); + lock (MediaFolderListLock) + MediaFolderList.Add(topParent); + return (topParent, path[topParent.Path.Length..]); + } + + /// <summary> + /// Strip the media folder from the full path, leaving only the partial + /// path to use when searching Shoko for a match. + /// </summary> + /// <param name="fullPath">The full path to strip.</param> + /// <returns>The partial path, void of the media folder.</returns> + public string StripMediaFolder(string fullPath) + { + Folder? mediaFolder = null; + lock (MediaFolderListLock) + mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + if (mediaFolder is not null) + return fullPath[mediaFolder.Path.Length..]; + if (Path.GetDirectoryName(fullPath) is not string directoryPath || LibraryManager.FindByPath(directoryPath, true)?.GetTopParent() is not Folder topParent) + return fullPath; + lock (MediaFolderListLock) + MediaFolderList.Add(topParent); + return fullPath[topParent.Path.Length..]; + } + + #endregion + #region Clear + + public void Dispose() + { + GC.SuppressFinalize(this); + Clear(); + } + + public void Clear() + { + Logger.LogDebug("Clearing data…"); + EpisodeIdToEpisodePathDictionary.Clear(); + EpisodeIdToSeriesIdDictionary.Clear(); + FileAndSeriesIdToEpisodeIdDictionary.Clear(); + lock (MediaFolderListLock) + MediaFolderList.Clear(); + PathToEpisodeIdsDictionary.Clear(); + PathToFileIdAndSeriesIdDictionary.Clear(); + PathToSeriesIdDictionary.Clear(); + NameToSeriesIdDictionary.Clear(); + SeriesIdToDefaultSeriesIdDictionary.Clear(); + SeriesIdToCollectionIdDictionary.Clear(); + SeriesIdToPathDictionary.Clear(); + DataCache.Clear(); + Logger.LogDebug("Cleanup complete."); + } + + #endregion + #region Tags, Genres, And Content Ratings + + public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries(string seriesId) + => DataCache.GetOrCreateAsync( + $"series-linked-tags:{seriesId}", + async () => { + var nextUserTagId = 1; + var hasCustomTags = false; + var rootTags = new List<Tag>(); + var tagMap = new Dictionary<string, List<Tag>>(); + var tags = (await APIClient.GetSeriesTags(seriesId).ConfigureAwait(false)) + .OrderBy(tag => tag.Source) + .ThenBy(tag => tag.Source == "User" ? tag.Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length : 0) + .ToList(); + foreach (var tag in tags) { + if (Plugin.Instance.Configuration.HideUnverifiedTags && tag.IsVerified.HasValue && !tag.IsVerified.Value) + continue; + + switch (tag.Source) { + case "AniDB": { + var parentKey = $"{tag.Source}:{tag.ParentId ?? 0}"; + if (!tag.ParentId.HasValue) { + rootTags.Add(tag); + continue; + } + if (!tagMap.TryGetValue(parentKey, out var list)) + tagMap[parentKey] = list = new(); + // Remove comment on tag name itself. + if (tag.Name.Contains(" - ")) + tag.Name = tag.Name.Split(" - ").First().Trim(); + else if (tag.Name.Contains("--")) + tag.Name = tag.Name.Split("--").First().Trim(); + list.Add(tag); + break; + } + case "User": { + if (!hasCustomTags) { + rootTags.Add(new() { + Id = 0, + Name = "custom user tags", + Description = string.Empty, + IsVerified = true, + IsGlobalSpoiler = false, + IsLocalSpoiler = false, + LastUpdated = DateTime.UnixEpoch, + Source = "Shokofin", + }); + hasCustomTags = true; + } + var parentNames = tag.Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + tag.Name = parentNames.Last(); + parentNames.RemoveAt(parentNames.Count - 1); + var customTagsRoot = rootTags.First(tag => tag.Source == "Shokofin" && tag.Id == 0); + var lastParentTag = customTagsRoot; + while (parentNames.Count > 0) { + // Take the first element from the list. + if (!parentNames.TryRemoveAt(0, out var name)) + break; + + // Make sure the parent's children exists in our map. + var parentKey = $"Shokofin:{lastParentTag.Id}"; + if (!tagMap!.TryGetValue(parentKey, out var children)) + tagMap[parentKey] = children = new(); + + // Add the child tag to the parent's children if needed. + var childTag = children.Find(t => string.Equals(name, t.Name, StringComparison.InvariantCultureIgnoreCase)); + if (childTag is null) + children.Add(childTag = new() { + Id = nextUserTagId++, + ParentId = lastParentTag.Id, + Name = name.ToLowerInvariant(), + IsVerified = true, + Description = string.Empty, + IsGlobalSpoiler = false, + IsLocalSpoiler = false, + LastUpdated = customTagsRoot.LastUpdated, + Source = "Shokofin", + }); + + // Switch to the child tag for the next parent name. + lastParentTag = childTag; + }; + + // Same as above, but for the last parent, be it the root or any other layer. + var lastParentKey = $"Shokofin:{lastParentTag.Id}"; + if (!tagMap!.TryGetValue(lastParentKey, out var lastChildren)) + tagMap[lastParentKey] = lastChildren = new(); + + if (!lastChildren.Any(childTag => string.Equals(childTag.Name, tag.Name, StringComparison.InvariantCultureIgnoreCase))) + lastChildren.Add(new() { + Id = nextUserTagId++, + ParentId = lastParentTag.Id, + Name = tag.Name, + Description = tag.Description, + IsVerified = tag.IsVerified, + IsGlobalSpoiler = tag.IsGlobalSpoiler, + IsLocalSpoiler = tag.IsLocalSpoiler, + Weight = tag.Weight, + LastUpdated = tag.LastUpdated, + Source = "Shokofin", + }); + break; + } + } + } + List<Tag>? getChildren(string source, int id) => tagMap.TryGetValue($"{source}:{id}", out var list) ? list : null; + var allResolvedTags = rootTags + .Select(tag => new ResolvedTag(tag, null, getChildren)) + .SelectMany(tag => tag.RecursiveNamespacedChildren.Values.Prepend(tag)) + .ToDictionary(tag => tag.FullName); + // We reassign the children because they may have been moved to a different namespace. + foreach (var groupBy in allResolvedTags.Values.GroupBy(tag => tag.Namespace).OrderByDescending(pair => pair.Key)) { + if (!allResolvedTags.TryGetValue(groupBy.Key[..^1], out var nsTag)) + continue; + nsTag.Children = groupBy.ToDictionary(childTag => childTag.Name); + nsTag.RecursiveNamespacedChildren = nsTag.Children.Values + .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) + .ToDictionary(childTag => childTag.FullName[nsTag.FullName.Length..]); + } + return allResolvedTags as IReadOnlyDictionary<string, ResolvedTag>; + } + ); + + private async Task<string[]> GetTagsForSeries(string seriesId) + { + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.FilterTags(tags); + } + + private async Task<string[]> GetGenresForSeries(string seriesId) + { + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.FilterGenres(tags); + } + + private async Task<string[]> GetProductionLocations(string seriesId) + { + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.GetProductionCountriesFromTags(tags); + } + + private async Task<string?> GetAssumedContentRating(string seriesId) + { + var tags = await GetNamespacedTagsForSeries(seriesId); + return ContentRating.GetTagBasedContentRating(tags); + } + + private async Task<SeriesType?> GetCustomSeriesType(string seriesId) + { + var tags = await GetNamespacedTagsForSeries(seriesId); + if (tags.TryGetValue("/custom user tags/series type", out var seriesTypeTag) && + seriesTypeTag.Children.Count is > 1 && + Enum.TryParse<SeriesType>(NormalizeCustomSeriesType(seriesTypeTag.Children.Keys.First()), out var seriesType) && + seriesType is not SeriesType.Unknown + ) + return seriesType; + return null; + } + + private static string NormalizeCustomSeriesType(string seriesType) + { + seriesType = seriesType.ToLowerInvariant().Replace(" ", ""); + if (seriesType[^1] == 's') + seriesType = seriesType[..^1]; + return seriesType; + } + + #endregion + #region Path Set And Local Episode IDs + + public async Task<List<(File file, string seriesId)>> GetFilesForSeason(SeasonInfo seasonInfo) + { + // TODO: Optimise/cache this better now that we do it per season. + var list = (await APIClient.GetFilesForSeries(seasonInfo.Id)).Select(file => (file, seriesId: seasonInfo.Id)).ToList(); + foreach (var extraId in seasonInfo.ExtraIds) + list.AddRange((await APIClient.GetFilesForSeries(extraId)).Select(file => (file, seriesId: extraId))); + return list; + } + + /// <summary> + /// Get a set of paths that are unique to the series and don't belong to + /// any other series. + /// </summary> + /// <param name="seriesId">Shoko series id.</param> + /// <returns>Unique path set for the series</returns> + public async Task<HashSet<string>> GetPathSetForSeries(string seriesId, IEnumerable<string> extraIds) + { + // TODO: Optimise/cache this better now that we do it per season. + var (pathSet, _) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId).ConfigureAwait(false); + foreach (var extraId in extraIds) + foreach (var path in await GetPathSetAndLocalEpisodeIdsForSeries(extraId).ContinueWith(task => task.Result.Item1).ConfigureAwait(false)) + pathSet.Add(path); + return pathSet; + } + + /// <summary> + /// Get a set of local episode ids for the series. + /// </summary> + /// <param name="seriesId">Shoko series id.</param> + /// <returns>Local episode ids for the series</returns> + public async Task<HashSet<string>> GetLocalEpisodeIdsForSeason(SeasonInfo seasonInfo) + { + // TODO: Optimise/cache this better now that we do it per season. + var (_, episodeIds) = await GetPathSetAndLocalEpisodeIdsForSeries(seasonInfo.Id).ConfigureAwait(false); + foreach (var extraId in seasonInfo.ExtraIds) + foreach (var episodeId in await GetPathSetAndLocalEpisodeIdsForSeries(extraId).ContinueWith(task => task.Result.Item2).ConfigureAwait(false)) + episodeIds.Add(episodeId); + return episodeIds; + } + + // Set up both at the same time. + private Task<(HashSet<string>, HashSet<string>)> GetPathSetAndLocalEpisodeIdsForSeries(string seriesId) + => DataCache.GetOrCreateAsync( + $"series-path-set-and-episode-ids:${seriesId}", + async () => { + var pathSet = new HashSet<string>(); + var episodeIds = new HashSet<string>(); + foreach (var file in await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false)) { + if (file.CrossReferences.Count == 1) + foreach (var fileLocation in file.Locations) + pathSet.Add((Path.GetDirectoryName(fileLocation.RelativePath) ?? string.Empty) + Path.DirectorySeparatorChar); + var xref = file.CrossReferences.First(xref => xref.Series.Shoko.HasValue && xref.Series.Shoko.ToString() == seriesId); + foreach (var episodeXRef in xref.Episodes.Where(e => e.Shoko.HasValue)) + episodeIds.Add(episodeXRef.Shoko!.Value.ToString()); + } + + return (pathSet, episodeIds); + }, + new() + ); + + #endregion + #region File Info + + internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnumerable<string> episodeIds) + { + PathToFileIdAndSeriesIdDictionary.TryAdd(path, (fileId, seriesId)); + PathToEpisodeIdsDictionary.TryAdd(path, episodeIds.ToList()); + } + + public async Task<(FileInfo?, SeasonInfo?, ShowInfo?)> GetFileInfoByPath(string path) + { + // Use pointer for fast lookup. + if (PathToFileIdAndSeriesIdDictionary.ContainsKey(path)) { + var (fI, sI) = PathToFileIdAndSeriesIdDictionary[path]; + var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); + if (fileInfo == null) + return (null, null, null); + + var seasonInfo = await GetSeasonInfoForSeries(sI).ConfigureAwait(false); + if (seasonInfo == null) + return (null, null, null); + + var showInfo = await GetShowInfoForSeries(sI).ConfigureAwait(false); + if (showInfo == null) + return (null, null, null); + + return new(fileInfo, seasonInfo, showInfo); + } + + // Fast-path for VFS. + if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + var fileName = Path.GetFileNameWithoutExtension(path); + if (!fileName.TryGetAttributeValue(ShokoSeriesId.Name, out var sI) || !int.TryParse(sI, out _)) + return (null, null, null); + if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out var fI) || !int.TryParse(fI, out _)) + return (null, null, null); + + var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); + if (fileInfo == null) + return (null, null, null); + + var seasonInfo = await GetSeasonInfoForSeries(sI).ConfigureAwait(false); + if (seasonInfo == null) + return (null, null, null); + + var showInfo = await GetShowInfoForSeries(sI).ConfigureAwait(false); + if (showInfo == null) + return (null, null, null); + + AddFileLookupIds(path, fI, sI, fileInfo.EpisodeList.Select(episode => episode.Id)); + return (fileInfo, seasonInfo, showInfo); + } + + // Strip the path and search for a match. + var partialPath = StripMediaFolder(path); + var result = await APIClient.GetFileByPath(partialPath).ConfigureAwait(false); + Logger.LogDebug("Looking for a match for {Path}", partialPath); + + // Check if we found a match. + var file = result?.FirstOrDefault(); + if (file == null || file.CrossReferences.Count == 0) { + Logger.LogTrace("Found no match for {Path}", partialPath); + return (null, null, null); + } + + // Find the file locations matching the given path. + var fileId = file.Id.ToString(); + var fileLocations = file.Locations + .Where(location => location.RelativePath.EndsWith(partialPath)) + .ToList(); + Logger.LogTrace("Found a file match for {Path} (File={FileId})", partialPath, file.Id.ToString()); + if (fileLocations.Count != 1) { + if (fileLocations.Count == 0) + throw new Exception($"I have no idea how this happened, but the path gave a file that doesn't have a matching file location. See you in #support. (File={fileId})"); + + Logger.LogWarning("Multiple locations matched the path, picking the first location. (File={FileId})", fileId); + } + + // Find the correct series based on the path. + var selectedPath = (Path.GetDirectoryName(fileLocations.First().RelativePath) ?? string.Empty) + Path.DirectorySeparatorChar; + foreach (var seriesXRef in file.CrossReferences.Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue))) { + var seriesId = seriesXRef.Series.Shoko!.Value.ToString(); + + // Check if the file is in the series folder. + var (primaryId, extraIds) = await GetSeriesIdsForSeason(seriesId); + var pathSet = await GetPathSetForSeries(primaryId, extraIds).ConfigureAwait(false); + if (!pathSet.Contains(selectedPath)) + continue; + + // Find the season info. + var seasonInfo = await GetSeasonInfoForSeries(primaryId).ConfigureAwait(false); + if (seasonInfo == null) + return (null, null, null); + + // Find the show info. + var showInfo = await GetShowInfoForSeries(primaryId).ConfigureAwait(false); + if (showInfo == null || showInfo.SeasonList.Count == 0) + return (null, null, null); + + // Find the file info for the series. + var fileInfo = await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); + + // Add pointers for faster lookup. + foreach (var episodeInfo in fileInfo.EpisodeList) + EpisodeIdToEpisodePathDictionary.TryAdd(episodeInfo.Id, path); + + // Add pointers for faster lookup. + AddFileLookupIds(path, fileId, seriesId, fileInfo.EpisodeList.Select(episode => episode.Id)); + + // Return the result. + return new(fileInfo, seasonInfo, showInfo); + } + + throw new Exception($"Unable to determine the series to use for the file based on it's location because the file resides within a mixed folder with multiple AniDB anime in it. You will either have to fix your file structure or use the VFS to avoid this issue. (File={fileId})\nFile location; {path}"); + } + + public async Task<FileInfo?> GetFileInfo(string fileId, string seriesId) + { + if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId)) + return null; + + var cacheKey = $"file:{fileId}:{seriesId}"; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out var fileInfo)) + return fileInfo; + + // Gracefully return if we can't find the file. + File file; + try { + file = await APIClient.GetFile(fileId).ConfigureAwait(false); + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return null; + } + + return await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); + } + + private static readonly EpisodeType[] EpisodePickOrder = { EpisodeType.Special, EpisodeType.Normal, EpisodeType.Other }; + + private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) + => DataCache.GetOrCreateAsync( + $"file:{fileId}:{seriesId}", + async () => { + Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); + + // Find the cross-references for the selected series. + var seriesXRef = file.CrossReferences.Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .FirstOrDefault(xref => xref.Series.Shoko!.Value.ToString() == seriesId) ?? + throw new Exception($"Unable to find any cross-references for the specified series for the file. (File={fileId},Series={seriesId})"); + + // Find a list of the episode info for each episode linked to the file for the series. + var episodeList = new List<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)>(); + foreach (var episodeXRef in seriesXRef.Episodes) { + var episodeId = episodeXRef.Shoko!.Value.ToString(); + var episodeInfo = await GetEpisodeInfo(episodeId).ConfigureAwait(false) ?? + throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); + if (episodeInfo.Shoko.IsHidden) { + Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); + continue; + } + episodeList.Add((episodeInfo, episodeXRef, episodeId)); + } + + // Group and order the episodes. + var groupedEpisodeLists = episodeList + .GroupBy(tuple => (type: tuple.Episode.AniDB.Type, group: tuple.CrossReference.Percentage?.Group ?? 1)) + .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key.type)) + .ThenBy(a => a.Key.group) + .Select(epList => epList.OrderBy(tuple => tuple.Episode.AniDB.EpisodeNumber).ToList()) + .ToList(); + + var fileInfo = new FileInfo(file, groupedEpisodeLists, seriesId); + + FileAndSeriesIdToEpisodeIdDictionary[$"{fileId}:{seriesId}"] = episodeList.Select(episode => episode.Id).ToList(); + return fileInfo; + } + ); + + public bool TryGetFileIdForPath(string path, out string? fileId) + { + if (!string.IsNullOrEmpty(path) && PathToFileIdAndSeriesIdDictionary.TryGetValue(path, out var pair)) { + fileId = pair.FileId; + return true; + } + + fileId = null; + return false; + } + + #endregion + #region Episode Info + + public async Task<EpisodeInfo?> GetEpisodeInfo(string episodeId) + { + if (string.IsNullOrEmpty(episodeId)) + return null; + + var key = $"episode:{episodeId}"; + if (DataCache.TryGetValue<EpisodeInfo>(key, out var episodeInfo)) + return episodeInfo; + + var episode = await APIClient.GetEpisode(episodeId).ConfigureAwait(false); + return CreateEpisodeInfo(episode, episodeId); + } + + private EpisodeInfo CreateEpisodeInfo(Episode episode, string episodeId) + => DataCache.GetOrCreate( + $"episode:{episodeId}", + () => { + Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); + + return new EpisodeInfo(episode); + } + ); + + public bool TryGetEpisodeIdForPath(string path, out string? episodeId) + { + if (string.IsNullOrEmpty(path)) { + episodeId = null; + return false; + } + var result = PathToEpisodeIdsDictionary.TryGetValue(path, out var episodeIds); + episodeId = episodeIds?.FirstOrDefault(); + return result; + } + + public bool TryGetEpisodeIdsForPath(string path, out List<string>? episodeIds) + { + if (string.IsNullOrEmpty(path)) { + episodeIds = null; + return false; + } + return PathToEpisodeIdsDictionary.TryGetValue(path, out episodeIds); + } + + public bool TryGetEpisodeIdsForFileId(string fileId, string seriesId, out List<string>? episodeIds) + { + if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId)) { + episodeIds = null; + return false; + } + return FileAndSeriesIdToEpisodeIdDictionary.TryGetValue($"{fileId}:{seriesId}", out episodeIds); + } + + public bool TryGetEpisodePathForId(string episodeId, out string? path) + { + if (string.IsNullOrEmpty(episodeId)) { + path = null; + return false; + } + return EpisodeIdToEpisodePathDictionary.TryGetValue(episodeId, out path); + } + + public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) + { + if (string.IsNullOrEmpty(episodeId)) { + seriesId = null; + return false; + } + return EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out seriesId); + } + + #endregion + #region Season Info + + public async Task<SeasonInfo?> GetSeasonInfoByPath(string path) + { + var seriesId = await GetSeriesIdForPath(path).ConfigureAwait(false); + if (string.IsNullOrEmpty(seriesId)) + return null; + + var series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); + return await CreateSeasonInfo(series).ConfigureAwait(false); + } + + public async Task<SeasonInfo?> GetSeasonInfoForSeries(string seriesId) + { + if (string.IsNullOrEmpty(seriesId)) + return null; + + Series series; + try { + series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return null; + } + return await CreateSeasonInfo(series).ConfigureAwait(false); + } + + public async Task<SeasonInfo?> GetSeasonInfoForEpisode(string episodeId) + { + if (!EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) { + var series = await APIClient.GetSeriesFromEpisode(episodeId).ConfigureAwait(false); + if (series == null) + return null; + seriesId = series.IDs.Shoko.ToString(); + return await CreateSeasonInfo(series).ConfigureAwait(false); + } + + return await GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); + } + + private async Task<SeasonInfo> CreateSeasonInfo(Series series) + { + var (seriesId, extraIds) = await GetSeriesIdsForSeason(series); + return await DataCache.GetOrCreateAsync( + $"season:{seriesId}", + (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId), + async () => { + // We updated the "primary" series id for the merge group, so fetch the new series details from the client cache. + if (!string.Equals(series.IDs.Shoko.ToString(), seriesId, StringComparison.Ordinal)) + series = await APIClient.GetSeries(seriesId); + + Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId},ExtraSeries={ExtraIds})", series.Name, seriesId, extraIds); + + var customSeriesType = await GetCustomSeriesType(seriesId).ConfigureAwait(false); + var contentRating = await GetAssumedContentRating(seriesId).ConfigureAwait(false); + var (earliestImportedAt, lastImportedAt) = await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); + var episodes = (await Task.WhenAll( + extraIds.Prepend(seriesId) + .Select(id => APIClient.GetEpisodesFromSeries(id).ContinueWith(task => task.Result.List.Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())))) + ).ConfigureAwait(false)) + .SelectMany(list => list) + .OrderBy(episode => episode.AniDB.AirDate) + .ToList(); + + SeasonInfo seasonInfo; + if (extraIds.Count > 0) { + var detailsIds = extraIds.Prepend(seriesId).ToList(); + + // Create the tasks. + var castTasks = detailsIds.Select(id => APIClient.GetSeriesCast(id)); + var relationsTasks = detailsIds.Select(id => APIClient.GetSeriesRelations(id)); + var genresTasks = detailsIds.Select(id => GetGenresForSeries(id)); + var tagsTasks = detailsIds.Select(id => GetTagsForSeries(id)); + var productionLocationsTasks = detailsIds.Select(id => GetProductionLocations(id)); + + // Await the tasks in order. + var cast = (await Task.WhenAll(castTasks)) + .SelectMany(c => c) + .Distinct() + .ToList(); + var relations = (await Task.WhenAll(relationsTasks)) + .SelectMany(r => r) + .Where(r => r.RelatedIDs.Shoko.HasValue && !detailsIds.Contains(r.RelatedIDs.Shoko.Value.ToString())) + .ToList(); + var genres = (await Task.WhenAll(genresTasks)) + .SelectMany(g => g) + .OrderBy(g => g) + .Distinct() + .ToArray(); + var tags = (await Task.WhenAll(tagsTasks)) + .SelectMany(t => t) + .OrderBy(t => t) + .Distinct() + .ToArray(); + var productionLocations = (await Task.WhenAll(genresTasks)) + .SelectMany(g => g) + .OrderBy(g => g) + .Distinct() + .ToArray(); + + // Create the season info using the merged details. + seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); + } else { + var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); + var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); + var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); + var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); + var productionLocations = await GetProductionLocations(seriesId).ConfigureAwait(false); + seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); + } + + foreach (var episode in episodes) + EpisodeIdToSeriesIdDictionary.TryAdd(episode.Id, seriesId); + return seasonInfo; + } + ); + } + + private Task<(DateTime?, DateTime?)> GetEarliestImportedAtForSeries(string seriesId) + => DataCache.GetOrCreateAsync<(DateTime?, DateTime?)>( + $"series-earliest-imported-at:${seriesId}", + async () => { + var files = await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false); + if (!files.Any(f => f.ImportedAt.HasValue)) + return (null, null); + return ( + files.Any(f => f.ImportedAt.HasValue) + ? files.Where(f => f.ImportedAt.HasValue).Select(f => f.ImportedAt!.Value).Min() + : files.Select(f => f.CreatedAt).Min(), + files.Any(f => f.ImportedAt.HasValue) + ? files.Where(f => f.ImportedAt.HasValue).Select(f => f.ImportedAt!.Value).Max() + : files.Select(f => f.CreatedAt).Max() + ); + }, + new() + ); + + public async Task<(string primaryId, List<string> extraIds)> GetSeriesIdsForSeason(string seriesId) + => await GetSeriesIdsForSeason(await APIClient.GetSeries(seriesId)); + + private Task<(string primaryId, List<string> extraIds)> GetSeriesIdsForSeason(Series series) + => DataCache.GetOrCreateAsync( + $"season-series-ids:{series.IDs.Shoko}", + (tuple) => { + var config = Plugin.Instance.Configuration; + if (!config.EXPERIMENTAL_MergeSeasons) + return; + + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(GetCustomSeriesType(series.IDs.Shoko.ToString()).ConfigureAwait(false).GetAwaiter().GetResult() ?? series.AniDBEntity.Type)) + return; + + if (series.AniDBEntity.AirDate is null) + return; + + Logger.LogTrace("Reusing existing series-to-season mapping for series. (Series={SeriesId},ExtraSeries={ExtraIds})", tuple.primaryId, tuple.extraIds); + }, + async () => { + var primaryId = series.IDs.Shoko.ToString(); + var extraIds = new List<string>(); + var config = Plugin.Instance.Configuration; + if (!config.EXPERIMENTAL_MergeSeasons) + return (primaryId, extraIds); + + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(await GetCustomSeriesType(series.IDs.Shoko.ToString()) ?? series.AniDBEntity.Type)) + return (primaryId, extraIds); + + if (series.AniDBEntity.AirDate is null) + return (primaryId, extraIds); + + Logger.LogTrace("Creating new series-to-season mapping for series. (Series={SeriesId})", primaryId); + + // We potentially have a "follow-up" season candidate, so look for the "primary" season candidate, then jump into that. + var relations = await APIClient.GetSeriesRelations(primaryId).ConfigureAwait(false); + var mainTitle = series.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; + var result = YearRegex.Match(mainTitle); + var maxDaysThreshold = config.EXPERIMENTAL_MergeSeasonsMergeWindowInDays; + if (result.Success) + { + var adjustedMainTitle = mainTitle[..^result.Length]; + var currentDate = series.AniDBEntity.AirDate.Value; + var currentRelations = relations; + while (currentRelations.Count > 0) { + foreach (var prequelRelation in currentRelations.Where(relation => relation.Type == RelationType.Prequel && relation.RelatedIDs.Shoko.HasValue)) { + var prequelSeries = await APIClient.GetSeries(prequelRelation.RelatedIDs.Shoko!.Value.ToString()); + if (prequelSeries.IDs.ParentGroup != series.IDs.ParentGroup) + continue; + + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(await GetCustomSeriesType(prequelSeries.IDs.Shoko.ToString()) ?? prequelSeries.AniDBEntity.Type)) + continue; + + if (prequelSeries.AniDBEntity.AirDate is null) + continue; + + var prequelDate = prequelSeries.AniDBEntity.AirDate.Value; + if (prequelDate > currentDate) + continue; + + if (maxDaysThreshold > 0) { + var deltaDays = (int)Math.Floor((currentDate - prequelDate).TotalDays); + if (deltaDays > maxDaysThreshold) + continue; + } + + var prequelMainTitle = prequelSeries.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; + var prequelResult = YearRegex.Match(prequelMainTitle); + if (!prequelResult.Success) { + if (string.Equals(adjustedMainTitle, prequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { + (primaryId, extraIds) = await GetSeriesIdsForSeason(prequelSeries); + goto breakPrequelWhileLoop; + } + continue; + } + + var adjustedPrequelMainTitle = prequelMainTitle[..^prequelResult.Length]; + if (string.Equals(adjustedMainTitle, adjustedPrequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { + currentDate = prequelDate; + currentRelations = await APIClient.GetSeriesRelations(prequelSeries.IDs.Shoko.ToString()).ConfigureAwait(false); + goto continuePrequelWhileLoop; + } + } + breakPrequelWhileLoop: break; + continuePrequelWhileLoop: continue; + } + } + // We potentially have a "primary" season candidate, so look for any "follow-up" season candidates. + else { + var currentDate = series.AniDBEntity.AirDate.Value; + var adjustedMainTitle = mainTitle; + var currentRelations = relations; + while (currentRelations.Count > 0) { + foreach (var sequelRelation in currentRelations.Where(relation => relation.Type == RelationType.Sequel && relation.RelatedIDs.Shoko.HasValue)) { + var sequelSeries = await APIClient.GetSeries(sequelRelation.RelatedIDs.Shoko!.Value.ToString()); + if (sequelSeries.IDs.ParentGroup != series.IDs.ParentGroup) + continue; + + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(await GetCustomSeriesType(sequelSeries.IDs.Shoko.ToString()) ?? sequelSeries.AniDBEntity.Type)) + continue; + + if (sequelSeries.AniDBEntity.AirDate is null) + continue; + + var sequelDate = sequelSeries.AniDBEntity.AirDate.Value; + if (sequelDate < currentDate) + continue; + + if (maxDaysThreshold > 0) { + var deltaDays = (int)Math.Floor((sequelDate - currentDate).TotalDays); + if (deltaDays > maxDaysThreshold) + continue; + } + + var sequelMainTitle = sequelSeries.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; + var sequelResult = YearRegex.Match(sequelMainTitle); + if (!sequelResult.Success) + continue; + + var adjustedSequelMainTitle = sequelMainTitle[..^sequelResult.Length]; + if (string.Equals(adjustedMainTitle, adjustedSequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { + extraIds.Add(sequelSeries.IDs.Shoko.ToString()); + currentDate = sequelDate; + currentRelations = await APIClient.GetSeriesRelations(sequelSeries.IDs.Shoko.ToString()).ConfigureAwait(false); + goto continueSequelWhileLoop; + } + } + break; + continueSequelWhileLoop: continue; + } + } + + Logger.LogTrace("Created new series-to-season mapping for series. (Series={SeriesId},ExtraSeries={ExtraIds})", primaryId, extraIds); + + return (primaryId, extraIds); + } + ); + + #endregion + #region Series Helpers + + public bool TryGetSeriesIdForPath(string path, [NotNullWhen(true)] out string? seriesId) + { + if (string.IsNullOrEmpty(path)) { + seriesId = null; + return false; + } + return PathToSeriesIdDictionary.TryGetValue(path, out seriesId); + } + + public bool TryGetSeriesPathForId(string seriesId, [NotNullWhen(true)] out string? path) + { + if (string.IsNullOrEmpty(seriesId)) { + path = null; + return false; + } + return SeriesIdToPathDictionary.TryGetValue(seriesId, out path); + } + + public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true)] out string? defaultSeriesId) + { + if (string.IsNullOrEmpty(seriesId)) { + defaultSeriesId = null; + return false; + } + return SeriesIdToDefaultSeriesIdDictionary.TryGetValue(seriesId, out defaultSeriesId); + } + + private async Task<string?> GetSeriesIdForPath(string path) + { + // Reuse cached value. + if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) + return seriesId; + + // Fast-path for VFS. + if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + if (!Path.GetFileName(path).TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + return null; + + PathToSeriesIdDictionary[path] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, path); + + return seriesId; + } + + var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for shoko series matching path {Path}", partialPath); + var result = await APIClient.GetSeriesPathEndsWith(partialPath).ConfigureAwait(false); + Logger.LogTrace("Found {Count} matches for path {Path}", result.Count, partialPath); + + // Return the first match where the series unique paths partially match + // the input path. + foreach (var series in result) { + seriesId = series.IDs.Shoko.ToString(); + var (primaryId, extraIds) = await GetSeriesIdsForSeason(seriesId); + var pathSet = await GetPathSetForSeries(primaryId, extraIds).ConfigureAwait(false); + foreach (var uniquePath in pathSet) { + // Remove the trailing slash before matching. + if (!uniquePath[..^1].EndsWith(partialPath)) + continue; + + PathToSeriesIdDictionary[path] = primaryId; + SeriesIdToPathDictionary.TryAdd(primaryId, path); + + return primaryId; + } + } + + // In the edge case for series with only files with multiple + // cross-references we just return the first match. + return result.FirstOrDefault()?.IDs.Shoko.ToString(); + } + + #endregion + #region Show Info + + public async Task<ShowInfo?> GetShowInfoByPath(string path) + { + if (!PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { + seriesId = await GetSeriesIdForPath(path).ConfigureAwait(false); + if (string.IsNullOrEmpty(seriesId)) + return null; + } + + return await GetShowInfoForSeries(seriesId).ConfigureAwait(false); + } + + public async Task<ShowInfo?> GetShowInfoForEpisode(string episodeId) + { + if (string.IsNullOrEmpty(episodeId)) + return null; + + if (EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) + return await GetShowInfoForSeries(seriesId).ConfigureAwait(false); + + var series = await APIClient.GetSeriesFromEpisode(episodeId).ConfigureAwait(false); + if (series == null) + return null; + + seriesId = series.IDs.Shoko.ToString(); + return await GetShowInfoForSeries(seriesId).ConfigureAwait(false); + } + + public async Task<ShowInfo?> GetShowInfoForSeries(string seriesId) + { + if (string.IsNullOrEmpty(seriesId)) + return null; + + var group = await APIClient.GetGroupFromSeries(seriesId).ConfigureAwait(false); + if (group == null) + return null; + + var seasonInfo = await GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); + if (seasonInfo == null) + return null; + + // Create a standalone group if grouping is disabled and/or for each series in a group with sub-groups. + if (!Plugin.Instance.Configuration.UseGroupsForShows || group.Sizes.SubGroups > 0) + return GetOrCreateShowInfoForSeasonInfo(seasonInfo); + + // If we found a movie, and we're assigning movies as stand-alone shows, and we didn't create a stand-alone show + // above, then attach the stand-alone show to the parent group of the group that might other + if (seasonInfo.Type == SeriesType.Movie && Plugin.Instance.Configuration.SeparateMovies) + return GetOrCreateShowInfoForSeasonInfo(seasonInfo, group.Size > 0 ? group.IDs.ParentGroup?.ToString() : null); + + return await CreateShowInfoForGroup(group, group.IDs.Shoko.ToString()).ConfigureAwait(false); + } + + private Task<ShowInfo?> CreateShowInfoForGroup(Group group, string groupId) + => DataCache.GetOrCreateAsync( + $"show:by-group-id:{groupId}", + (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo?.Name, groupId), + async () => { + Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); + + var seriesInGroup = await APIClient.GetSeriesInGroup(groupId).ConfigureAwait(false); + var seasonList = (await Task.WhenAll(seriesInGroup.Select(CreateSeasonInfo)).ConfigureAwait(false)) + .DistinctBy(seasonInfo => seasonInfo.Id) + .ToList(); + + var length = seasonList.Count; + if (Plugin.Instance.Configuration.SeparateMovies) { + seasonList = seasonList.Where(s => s.Type != SeriesType.Movie).ToList(); + + // Return early if no series matched the filter or if the list was empty. + if (seasonList.Count == 0) { + Logger.LogWarning("Creating an empty show info for filter! (Group={GroupId})", groupId); + + return null; + } + } + + var showInfo = new ShowInfo(group, seasonList, Logger, length != seasonList.Count); + + foreach (var seasonInfo in seasonList) { + SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; + if (!string.IsNullOrEmpty(showInfo.CollectionId)) + SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; + } + + return showInfo; + } + ); + + + private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? collectionId = null) + => DataCache.GetOrCreate( + $"show:by-series-id:{seasonInfo.Id}", + (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Series={SeriesId})", showInfo.Name, seasonInfo.Id), + () => { + Logger.LogTrace("Creating info object for show {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seasonInfo.Id); + + var showInfo = new ShowInfo(seasonInfo, collectionId); + SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; + if (!string.IsNullOrEmpty(showInfo.CollectionId)) + SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; + return showInfo; + } + ); + + #endregion + #region Collection Info + + public async Task<CollectionInfo?> GetCollectionInfoForGroup(string groupId) + { + if (string.IsNullOrEmpty(groupId)) + return null; + + if (DataCache.TryGetValue<CollectionInfo>($"collection:by-group-id:{groupId}", out var collectionInfo)) { + Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId); + return collectionInfo; + } + + var group = await APIClient.GetGroup(groupId).ConfigureAwait(false); + return await CreateCollectionInfo(group, groupId).ConfigureAwait(false); + } + + public async Task<CollectionInfo?> GetCollectionInfoForSeries(string seriesId) + { + if (string.IsNullOrEmpty(seriesId)) + return null; + + if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { + if (string.IsNullOrEmpty(groupId)) + return null; + + return await GetCollectionInfoForGroup(groupId).ConfigureAwait(false); + } + + var group = await APIClient.GetGroupFromSeries(seriesId).ConfigureAwait(false); + if (group == null) + return null; + + return await CreateCollectionInfo(group, group.IDs.Shoko.ToString()).ConfigureAwait(false); + } + + private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) + => DataCache.GetOrCreateAsync( + $"collection:by-group-id:{groupId}", + (collectionInfo) => Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId), + async () => { + Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + Logger.LogTrace("Fetching show info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + + var showGroupIds = new HashSet<string>(); + var collectionIds = new HashSet<string>(); + var showDict = new Dictionary<string, ShowInfo>(); + foreach (var series in await APIClient.GetSeriesInGroup(groupId, recursive: true).ConfigureAwait(false)) { + var showInfo = await GetShowInfoForSeries(series.IDs.Shoko.ToString()).ConfigureAwait(false); + if (showInfo == null) + continue; + + if (!string.IsNullOrEmpty(showInfo.GroupId)) + showGroupIds.Add(showInfo.GroupId); + + if (string.IsNullOrEmpty(showInfo.CollectionId)) + continue; + + collectionIds.Add(showInfo.CollectionId); + if (showInfo.CollectionId == groupId) + showDict.TryAdd(showInfo.Id, showInfo); + } + + var groupList = new List<CollectionInfo>(); + if (group.Sizes.SubGroups > 0) { + Logger.LogTrace("Fetching sub-collection info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + foreach (var subGroup in await APIClient.GetGroupsInGroup(groupId).ConfigureAwait(false)) { + if (showGroupIds.Contains(subGroup.IDs.Shoko.ToString()) && !collectionIds.Contains(subGroup.IDs.Shoko.ToString())) + continue; + var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.IDs.Shoko.ToString()).ConfigureAwait(false); + if (subCollectionInfo.Shoko.Sizes.Files > 0) + groupList.Add(subCollectionInfo); + } + } + + Logger.LogTrace("Finalizing info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + var showList = showDict.Values.ToList(); + var collectionInfo = new CollectionInfo(group, showList, groupList); + return collectionInfo; + } + ); + + #endregion +} diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs new file mode 100644 index 00000000..018d3d54 --- /dev/null +++ b/Shokofin/Collections/CollectionManager.cs @@ -0,0 +1,693 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Info; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +namespace Shokofin.Collections; + +public class CollectionManager +{ + private readonly ILibraryManager LibraryManager; + + private readonly ICollectionManager Collection; + + private readonly ILogger<CollectionManager> Logger; + + private readonly IIdLookup Lookup; + + private readonly ShokoAPIManager ApiManager; + + private static int MinCollectionSize => Plugin.Instance.Configuration.CollectionMinSizeOfTwo ? 1 : 0; + + public CollectionManager( + ILibraryManager libraryManager, + ICollectionManager collectionManager, + ILogger<CollectionManager> logger, + IIdLookup lookup, + ShokoAPIManager apiManager + ) + { + LibraryManager = libraryManager; + Collection = collectionManager; + Logger = logger; + Lookup = lookup; + ApiManager = apiManager; + } + + public Task<Folder?> GetCollectionsFolder(bool createIfNeeded) + => Collection.GetCollectionsFolder(createIfNeeded); + + public async Task ReconstructCollections(IProgress<double> progress, CancellationToken cancellationToken) + { + try { + // This check is to prevent creating the collections root if we don't have any libraries yet. + if (LibraryManager.GetVirtualFolders().Count is 0) return; + switch (Plugin.Instance.Configuration.CollectionGrouping) + { + default: + await CleanupAll(progress, cancellationToken); + break; + case Ordering.CollectionCreationType.Movies: + await ReconstructMovieSeriesCollections(progress, cancellationToken); + break; + case Ordering.CollectionCreationType.Shared: + await ReconstructSharedCollections(progress, cancellationToken); + break; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + } + } + + #region Movie Collections + + private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, CancellationToken cancellationToken) + { + Logger.LogTrace("Ensuring collection root exists…"); + var collectionRoot = (await GetCollectionsFolder(true).ConfigureAwait(false))!; + + var timeStarted = DateTime.Now; + + Logger.LogTrace("Cleaning up movies and invalid collections…"); + + // Clean up movies and unneeded group collections. + await CleanupMovies().ConfigureAwait(false); + CleanupGroupCollections(); + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(10); + + // Get all movies to include in the collection. + var movies = GetMovies(); + Logger.LogInformation("Reconstructing collections for {MovieCount} movies using Shoko Series.", movies.Count); + + // Create a tree-map of how it's supposed to be. + var movieDict = new Dictionary<Movie, (FileInfo fileInfo, SeasonInfo seasonInfo, ShowInfo showInfo)>(); + foreach (var movie in movies) { + if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) + continue; + + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path).ConfigureAwait(false); + if (fileInfo == null || seasonInfo == null || showInfo == null) + continue; + + movieDict.Add(movie, (fileInfo, seasonInfo, showInfo)); + } + // Filter to only "seasons" with at least (`MinCollectionSize` + 1) movies in them. + var seasonDict = movieDict.Values + .Select(tuple => tuple.seasonInfo) + .GroupBy(seasonInfo => seasonInfo.Id) + .Where(groupBy => groupBy.Count() > MinCollectionSize) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.First()); + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(30); + + // Find out what to add, what to remove and what to check. + var addedChildren = 0; + var removedChildren = 0; + var totalChildren = 0; + var existingCollections = GetSeriesCollections(); + var childDict = existingCollections + .Values + .SelectMany(collectionList => collectionList) + .ToDictionary(collection => collection.Id, collection => collection.Children.Concat(collection.GetLinkedChildren()).ToList()); + var parentDict = childDict + .SelectMany(pair => pair.Value.Select(child => (childId: child.Id, parent: pair.Key))) + .GroupBy(tuple => tuple.childId) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.Select(tuple => tuple.parent).ToList()); + var toCheck = new Dictionary<string, BoxSet>(); + var toRemove = new Dictionary<Guid, BoxSet>(); + var toAdd = seasonDict.Keys + .Where(groupId => !existingCollections.ContainsKey(groupId)) + .ToHashSet(); + foreach (var (seriesId, collectionList) in existingCollections) { + if (seasonDict.ContainsKey(seriesId)) { + toCheck.Add(seriesId, collectionList[0]); + foreach (var collection in collectionList.Skip(1)) + toRemove.Add(collection.Id, collection); + } + else { + foreach (var collection in collectionList) + toRemove.Add(collection.Id, collection); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(50); + + // Remove unknown collections. + foreach (var (id, collection) in toRemove) { + // Remove the item from all parents. + if (parentDict.TryGetValue(collection.Id, out var parents)) { + foreach (var parentId in parents) { + if (!toRemove.ContainsKey(parentId) && collection.ParentId != parentId) + await Collection.RemoveFromCollectionAsync(parentId, new[] { id }).ConfigureAwait(false); + } + } + + // Log how many children we will be removing. + removedChildren += childDict[collection.Id].Count; + + // Remove the item. + LibraryManager.DeleteItem(collection, new() { DeleteFileLocation = true, DeleteFromExternalProvider = false }); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(70); + + // Add the missing collections. + foreach (var missingId in toAdd) { + var seasonInfo = seasonDict[missingId]; + var collection = await Collection.CreateCollectionAsync(new() { + Name = $"{seasonInfo.Shoko.Name.ForceASCII()} [{ShokoCollectionSeriesId.Name}={missingId}]", + ProviderIds = new() { { ShokoCollectionSeriesId.Name, missingId } }, + }).ConfigureAwait(false); + + childDict.Add(collection.Id, new()); + toCheck.Add(missingId, collection); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(80); + + // Check if the collection have the correct children, and add any + // missing and remove any extras. + var fixedCollections = 0; + foreach (var (seriesId, collection) in toCheck) + { + // Edit the metadata to if needed. + var updated = false; + var seasonInfo = seasonDict[seriesId]; + var metadataLanguage = LibraryManager.GetLibraryOptions(collection)?.PreferredMetadataLanguage; + var (displayName, alternateTitle) = Text.GetSeasonTitles(seasonInfo, metadataLanguage); + if (!string.Equals(collection.Name, displayName)) { + collection.Name = displayName; + updated = true; + } + if (!string.Equals(collection.OriginalTitle, alternateTitle)) { + collection.OriginalTitle = alternateTitle; + updated = true; + } + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + fixedCollections++; + } + + var actualChildren = childDict[collection.Id]; + var actualChildMovies = new List<Movie>(); + foreach (var child in actualChildren) switch (child) { + case Movie movie: + actualChildMovies.Add(movie); + break; + } + + var expectedMovies = seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList) + .Select(episodeInfo => (episodeInfo, seasonInfo)) + .SelectMany(tuple => movieDict.Where(pair => pair.Value.seasonInfo.Id == tuple.seasonInfo.Id && pair.Value.fileInfo.EpisodeList.Any(episodeInfo => episodeInfo.Id == tuple.episodeInfo.Id))) + .Select(pair => pair.Key) + .ToList(); + var missingMovies = expectedMovies + .Select(movie => movie.Id) + .Except(actualChildMovies.Select(a => a.Id).ToHashSet()) + .ToList(); + var unwantedMovies = actualChildren + .Except(actualChildMovies) + .Select(movie => movie.Id) + .ToList(); + if (missingMovies.Count > 0) + await Collection.AddToCollectionAsync(collection.Id, missingMovies).ConfigureAwait(false); + if (unwantedMovies.Count > 0) + await Collection.RemoveFromCollectionAsync(collection.Id, unwantedMovies).ConfigureAwait(false); + + totalChildren += expectedMovies.Count; + addedChildren += missingMovies.Count; + removedChildren += unwantedMovies.Count; + } + + progress.Report(100); + + Logger.LogInformation( + "Created {AddedCount} ({AddedCollectionCount},{AddedChildCount}), fixed {FixedCount}, skipped {SkippedCount} ({SkippedCollectionCount},{SkippedChildCount}), and removed {RemovedCount} ({RemovedCollectionCount},{RemovedChildCount}) collections for {MovieCount} movies and using Shoko Series in {TimeSpent}. (Total={TotalCount})", + toAdd.Count + addedChildren, + toAdd.Count, + addedChildren, + fixedCollections - toAdd.Count, + toCheck.Count + totalChildren - toAdd.Count - addedChildren - (fixedCollections - toAdd.Count), + toCheck.Count - toAdd.Count - (fixedCollections - toAdd.Count), + totalChildren - addedChildren, + toRemove.Count + removedChildren, + toRemove.Count, + removedChildren, + movies.Count, + DateTime.Now - timeStarted, + toCheck.Count + totalChildren + ); + } + + #endregion + + #region Shared Collections + + private async Task ReconstructSharedCollections(IProgress<double> progress, CancellationToken cancellationToken) + { + Logger.LogTrace("Ensuring collection root exists…"); + var collectionRoot = (await GetCollectionsFolder(true).ConfigureAwait(false))!; + + var timeStarted = DateTime.Now; + + Logger.LogTrace("Cleaning up movies and invalid collections…"); + + // Clean up movies and unneeded series collections. + await CleanupMovies().ConfigureAwait(false); + CleanupSeriesCollections(); + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(10); + + // Get all shows/movies to include in the collection. + var movies = GetMovies(); + var shows = GetShows(); + Logger.LogInformation("Checking collections for {MovieCount} movies and {ShowCount} shows using Shoko Groups.", movies.Count, shows.Count); + + // Create a tree-map of how it's supposed to be. + var movieDict = new Dictionary<Movie, (FileInfo fileInfo, SeasonInfo seasonInfo, ShowInfo showInfo)>(); + foreach (var movie in movies) { + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path).ConfigureAwait(false); + if (fileInfo == null || seasonInfo == null || showInfo == null) + continue; + + movieDict.Add(movie, (fileInfo, seasonInfo, showInfo)); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(20); + + var showDict = new Dictionary<Series, ShowInfo>(); + foreach (var show in shows) { + if (!Lookup.TryGetSeriesIdFor(show, out var seriesId)) + continue; + + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); + if (showInfo == null) + continue; + + showDict.Add(show, showInfo); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(30); + + // Filter to only collections with at least (`MinCollectionSize` + 1) entries in them. + var movieCollections = movieDict.Values + .Select(tuple => tuple.showInfo.CollectionId) + .Where(collectionId => !string.IsNullOrEmpty(collectionId)) + .ToList(); + var showCollections = showDict.Values + .Select(showInfo => showInfo.CollectionId) + .Where(collectionId => !string.IsNullOrEmpty(collectionId)) + .ToList(); + var groupsDict = await Task + .WhenAll( + movieCollections.Concat(showCollections) + .GroupBy(collectionId => collectionId) + .Select(groupBy => + ApiManager.GetCollectionInfoForGroup(groupBy.Key!) + .ContinueWith(task => (collectionInfo: task.Result, count: groupBy.Count())) + ) + ) + .ContinueWith(task => + task.Result + .Where(tuple => tuple.collectionInfo != null) + .GroupBy(tuple => tuple.collectionInfo!.TopLevelId) + .Where(groupBy => groupBy.Sum(tuple => tuple.count) > MinCollectionSize) + .SelectMany(groupBy => groupBy) + .ToDictionary(c => c.collectionInfo!.Id, c => c.collectionInfo!) + ) + .ConfigureAwait(false); + var finalGroups = new Dictionary<string, CollectionInfo>(); + foreach (var initialGroup in groupsDict.Values) { + var currentGroup = initialGroup; + if (finalGroups.ContainsKey(currentGroup.Id)) + continue; + + finalGroups.Add(currentGroup.Id, currentGroup); + if (currentGroup.IsTopLevel) + continue; + + while (!currentGroup.IsTopLevel && !finalGroups.ContainsKey(currentGroup.ParentId!)) + { + currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!).ConfigureAwait(false); + if (currentGroup == null) + break; + finalGroups.Add(currentGroup.Id, currentGroup); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(40); + + // Find out what to add, what to remove and what to check. + var addedChildren = 0; + var removedChildren = 0; + var totalChildren = 0; + var existingCollections = GetGroupCollections(); + var childDict = existingCollections + .Values + .SelectMany(collectionList => collectionList) + .ToDictionary(collection => collection.Id, collection => collection.Children.Concat(collection.GetLinkedChildren()).ToList()); + var parentDict = childDict + .SelectMany(pair => pair.Value.Select(child => (childId: child.Id, parent: pair.Key))) + .GroupBy(tuple => tuple.childId) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.Select(tuple => tuple.parent).ToList()); + var toCheck = new Dictionary<string, BoxSet>(); + var toRemove = new Dictionary<Guid, BoxSet>(); + var toAdd = finalGroups.Keys + .Where(groupId => !existingCollections.ContainsKey(groupId)) + .ToList(); + foreach (var (groupId, collectionList) in existingCollections) { + if (finalGroups.ContainsKey(groupId)) { + toCheck.Add(groupId, collectionList[0]); + foreach (var collection in collectionList.Skip(1)) + toRemove.Add(collection.Id, collection); + } + else { + foreach (var collection in collectionList) + toRemove.Add(collection.Id, collection); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(50); + + // Remove unknown collections. + foreach (var (id, collection) in toRemove) { + // Remove the item from all parents. + if (parentDict.TryGetValue(collection.Id, out var parents)) { + foreach (var parentId in parents) { + if (!toRemove.ContainsKey(parentId) && collection.ParentId != parentId) + await Collection.RemoveFromCollectionAsync(parentId, new[] { id }).ConfigureAwait(false); + } + } + + // Log how many children we will be removing. + removedChildren += childDict[collection.Id].Count; + + // Remove the item. + LibraryManager.DeleteItem(collection, new() { DeleteFileLocation = true, DeleteFromExternalProvider = false }); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(70); + + // Add the missing collections. + var addedCollections = toAdd.Count; + while (toAdd.Count > 0) { + // First add any top level ids, then gradually move down until all groups are added. + var index = toAdd.FindIndex(id => finalGroups[id].IsTopLevel); + if (index == -1) + index = toAdd.FindIndex(id => toCheck.ContainsKey(finalGroups[id].ParentId!)); + if (index == -1) + throw new IndexOutOfRangeException("Unable to find the parent to add."); + + var missingId = toAdd[index]; + var collectionInfo = finalGroups[missingId]; + var collection = await Collection.CreateCollectionAsync(new() { + Name = $"{collectionInfo.Name.ForceASCII()} [{ShokoCollectionGroupId.Name}={missingId}]", + ProviderIds = new() { { ShokoCollectionGroupId.Name, missingId } }, + }).ConfigureAwait(false); + + childDict.Add(collection.Id, new()); + toCheck.Add(missingId, collection); + toAdd.RemoveAt(index); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(80); + + // Check if the collection have the correct children, and add any + // missing and remove any extras. + var fixedCollections = 0; + foreach (var (groupId, collection) in toCheck) + { + // Edit the metadata to place the collection under the right parent and with the correct name. + var collectionInfo = finalGroups[groupId]; + var updated = false; + var parent = collectionInfo.IsTopLevel ? collectionRoot : toCheck[collectionInfo.ParentId!]; + if (collection.ParentId != parent.Id) { + collection.SetParent(parent); + updated = true; + } + if (!string.Equals(collection.Name, collectionInfo.Name)) { + collection.Name = collectionInfo.Name; + updated = true; + } + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + fixedCollections++; + } + + var actualChildren = childDict[collection.Id]; + var actualChildCollections = new List<BoxSet>(); + var actualChildSeries = new List<Series>(); + var actualChildMovies = new List<Movie>(); + foreach (var child in actualChildren) switch (child) { + case BoxSet subCollection: + actualChildCollections.Add(subCollection); + break; + case Series series: + actualChildSeries.Add(series); + break; + case Movie movie: + actualChildMovies.Add(movie); + break; + } + + var expectedCollections = collectionInfo.SubCollections + .Select(subCollectionInfo => toCheck.TryGetValue(subCollectionInfo.Id, out var boxSet) ? boxSet : null) + .OfType<BoxSet>() + .ToList(); + var expectedShows = collectionInfo.Shows + .Where(showInfo => !showInfo.IsMovieCollection) + .SelectMany(showInfo => showDict.Where(pair => pair.Value.Id == showInfo.Id)) + .Select(pair => pair.Key) + .ToList(); + var expectedMovies = collectionInfo.Shows + .Where(showInfo => showInfo.IsMovieCollection) + .SelectMany(showInfo => showInfo.DefaultSeason.EpisodeList.Concat(showInfo.DefaultSeason.AlternateEpisodesList).Select(episodeInfo => (episodeInfo, seasonInfo: showInfo.DefaultSeason))) + .SelectMany(tuple => movieDict.Where(pair => pair.Value.seasonInfo.Id == tuple.seasonInfo.Id && pair.Value.fileInfo.EpisodeList.Any(episodeInfo => episodeInfo.Id == tuple.episodeInfo.Id))) + .Select(pair => pair.Key) + .ToList(); + var missingCollections = expectedCollections + .Select(show => show.Id) + .Except(actualChildCollections.Select(a => a.Id).ToHashSet()) + .ToList(); + var missingShows = expectedShows + .Select(show => show.Id) + .Except(actualChildSeries.Select(a => a.Id).ToHashSet()) + .ToList(); + var missingMovies = expectedMovies + .Select(movie => movie.Id) + .Except(actualChildMovies.Select(a => a.Id).ToHashSet()) + .ToList(); + var missingChildren = missingCollections + .Concat(missingShows) + .Concat(missingMovies) + .ToList(); + var unwantedChildren = actualChildren + .Except(actualChildCollections) + .Except(actualChildSeries) + .Except(actualChildMovies) + .Select(movie => movie.Id) + .ToList(); + if (missingChildren.Count > 0) + await Collection.AddToCollectionAsync(collection.Id, missingChildren).ConfigureAwait(false); + if (unwantedChildren.Count > 0) + await Collection.RemoveFromCollectionAsync(collection.Id, unwantedChildren).ConfigureAwait(false); + + totalChildren += expectedCollections.Count + expectedShows.Count + expectedMovies.Count; + addedChildren += missingChildren.Count; + removedChildren += unwantedChildren.Count; + } + + progress.Report(100); + + Logger.LogInformation( + "Created {AddedCount} ({AddedCollectionCount},{AddedChildCount}), fixed {FixedCount}, skipped {SkippedCount} ({SkippedCollectionCount},{SkippedChildCount}), and removed {RemovedCount} ({RemovedCollectionCount},{RemovedChildCount}) entities for {MovieCount} movies and {ShowCount} shows using Shoko Groups in {TimeSpent}. (Total={TotalCount})", + addedCollections + addedChildren, + addedCollections, + addedChildren, + fixedCollections - addedCollections, + toCheck.Count + totalChildren - addedCollections - addedChildren - (fixedCollections - addedCollections), + toCheck.Count - addedCollections - (fixedCollections - addedCollections), + totalChildren - addedChildren, + toRemove.Count + removedChildren, + toRemove.Count, + removedChildren, + movies.Count, + shows.Count, + DateTime.Now - timeStarted, + toCheck.Count + totalChildren + ); + } + + #endregion + + #region Cleanup Helpers + + private async Task CleanupAll(IProgress<double> progress, CancellationToken cancellationToken) + { + await CleanupMovies(); + cancellationToken.ThrowIfCancellationRequested(); + + CleanupSeriesCollections(); + cancellationToken.ThrowIfCancellationRequested(); + + CleanupGroupCollections(); + progress.Report(100d); + } + + /// <summary> + /// Check the movies with a shoko series id set, and remove the collection name from them. + /// </summary> + /// <returns>A task to await when it's done.</returns> + private async Task CleanupMovies() + { + var movies = GetMovies(); + foreach (var movie in movies) { + if (string.IsNullOrEmpty(movie.CollectionName)) + continue; + + if (!Lookup.TryGetEpisodeIdFor(movie, out var episodeId) || + !Lookup.TryGetSeriesIdFor(movie, out var seriesId)) + continue; + + Logger.LogTrace("Removing movie {MovieName} from collection {CollectionName}. (Episode={EpisodeId},Series={SeriesId})", movie.Name, movie.CollectionName, episodeId, seriesId); + movie.CollectionName = string.Empty; + await LibraryManager.UpdateItemAsync(movie, movie.GetParent(), ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); + } + } + + private void CleanupSeriesCollections() + { + var collectionDict = GetSeriesCollections(); + if (collectionDict.Count == 0) + return; + + var collectionSet = collectionDict.Values + .SelectMany(x => x.Select(y => y.Id)) + .Distinct() + .Count(); + Logger.LogInformation("Going to remove {CollectionCount} collection items for {SeriesCount} Shoko Series", collectionSet, collectionDict.Count); + + foreach (var (seriesId, collectionList) in collectionDict) + foreach (var collection in collectionList) + RemoveCollection(collection, seriesId: seriesId); + } + + private void CleanupGroupCollections() + { + var collectionDict = GetGroupCollections(); + if (collectionDict.Count == 0) + return; + + var collectionSet = collectionDict.Values + .SelectMany(x => x.Select(y => y.Id)) + .Distinct() + .Count(); + Logger.LogInformation("Going to remove {CollectionCount} collection items for {GroupCount} Shoko Groups", collectionSet, collectionDict.Count); + + foreach (var (groupId, collectionList) in collectionDict) + foreach (var collection in collectionList) + RemoveCollection(collection, groupId: groupId); + } + + private void RemoveCollection(BoxSet collection, string? seriesId = null, string? groupId = null) + { + var children = collection.Children.Concat(collection.GetLinkedChildren()).Select(x => x.Id).Distinct().Count(); + Logger.LogTrace("Removing collection {CollectionName} with {ChildCount} children. (Collection={CollectionId},Series={SeriesId},Group={GroupId})", collection.Name, children, collection.Id, seriesId, groupId); + + // Remove the item. + LibraryManager.DeleteItem(collection, new() { DeleteFileLocation = true, DeleteFromExternalProvider = false }); + } + + #endregion + + #region Getter Helpers + + private List<Movie> GetMovies() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, string.Empty } }, + IsVirtualItem = false, + Recursive = true, + }) + .Where(Lookup.IsEnabledForItem) + .Cast<Movie>() + .ToList(); + } + + private List<Series> GetShows() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, string.Empty } }, + IsVirtualItem = false, + Recursive = true, + }) + .Where(Lookup.IsEnabledForItem) + .Cast<Series>() + .ToList(); + } + + private Dictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoCollectionSeriesId.Name, string.Empty } }, + IsVirtualItem = false, + Recursive = true, + }) + .Cast<BoxSet>() + .Select(x => x.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) && !string.IsNullOrEmpty(seriesId) ? new { SeriesId = seriesId, BoxSet = x } : null) + .Where(x => x != null) + .GroupBy(x => x!.SeriesId, x => x!.BoxSet) + .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); + } + + private Dictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + + HasAnyProviderId = new Dictionary<string, string> { { ShokoCollectionGroupId.Name, string.Empty } }, + IsVirtualItem = false, + Recursive = true, + }) + .Cast<BoxSet>() + .Select(x => x.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var groupId) && !string.IsNullOrEmpty(groupId) ? new { GroupId = groupId, BoxSet = x } : null) + .Where(x => x != null) + .GroupBy(x => x!.GroupId, x => x!.BoxSet) + .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); + } + + #endregion +} \ No newline at end of file diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs new file mode 100644 index 00000000..a99b3f80 --- /dev/null +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Text.Json.Serialization; +using System.Xml.Serialization; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using LibraryFilteringMode = Shokofin.Utils.Ordering.LibraryFilteringMode; + +namespace Shokofin.Configuration; + +public class MediaFolderConfiguration +{ + /// <summary> + /// The jellyfin library id. + /// </summary> + public Guid LibraryId { get; set; } + + /// <summary> + /// The Jellyfin library's name. Only for displaying on the plugin + /// configuration page. + /// </summary> + [XmlIgnore] + [JsonInclude] + public string? LibraryName => LibraryId == Guid.Empty ? null : BaseItem.LibraryManager.GetItemById(LibraryId)?.Name; + + /// <summary> + /// The jellyfin media folder id. + /// </summary> + public Guid MediaFolderId { get; set; } + + /// <summary> + /// The jellyfin media folder path. Stored only for showing in the settings + /// page of the plugin… since it's very hard to get in there otherwise. + /// </summary> + public string MediaFolderPath { get; set; } = string.Empty; + + /// <summary> + /// The shoko import folder id the jellyfin media folder is linked to. + /// </summary> + public int ImportFolderId { get; set; } + + /// <summary> + /// The friendly name of the import folder, if any. Stored only for showing + /// in the settings page of the plugin… since it's very hard to get in + /// there otherwise. + /// </summary> + public string? ImportFolderName { get; set; } + + /// <summary> + /// The relative path from the root of the import folder the media folder is located at. + /// </summary> + public string ImportFolderRelativePath { get; set; } = string.Empty; + + /// <summary> + /// Indicates the Jellyfin Media Folder is mapped to a Shoko Import Folder. + /// </summary> + [XmlIgnore] + [JsonInclude] + public bool IsMapped => ImportFolderId != 0; + + /// <summary> + /// Indicates that SignalR file events is enabled for the folder. + /// </summary> + public bool IsFileEventsEnabled { get; set; } = true; + + /// <summary> + /// Indicates that SignalR refresh events is enabled for the folder. + /// </summary> + public bool IsRefreshEventsEnabled { get; set; } = true; + + /// <summary> + /// Enable or disable the virtual file system on a per-media-folder basis. + /// </summary> + public bool IsVirtualFileSystemEnabled { get; set; } = true; + + /// <summary> + /// Enable or disable the library filtering on a per-media-folder basis. Do + /// note that this will only take effect if the VFS is not used. + /// </summary> + public LibraryFilteringMode LibraryFilteringMode { get; set; } = LibraryFilteringMode.Auto; + + /// <summary> + /// Check if a relative path within the import folder is potentially available in this media folder. + /// </summary> + /// <param name="relativePath"></param> + /// <returns></returns> + public bool IsEnabledForPath(string relativePath) + => string.IsNullOrEmpty(ImportFolderRelativePath) || relativePath.StartsWith(ImportFolderRelativePath + Path.DirectorySeparatorChar); +} \ No newline at end of file diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs new file mode 100644 index 00000000..af4da12f --- /dev/null +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Naming.Common; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Configuration.Models; + +namespace Shokofin.Configuration; + +public static class MediaFolderConfigurationExtensions +{ + public static Folder GetFolderForPath(this string mediaFolderPath) + => BaseItem.LibraryManager.FindByPath(mediaFolderPath, true) as Folder ?? + throw new Exception($"Unable to find folder by path \"{mediaFolderPath}\"."); + + public static IReadOnlyList<(int importFolderId, string importFolderSubPath, IReadOnlyList<string> mediaFolderPaths)> ToImportFolderList(this IEnumerable<MediaFolderConfiguration> mediaConfigs) + => mediaConfigs + .GroupBy(a => (a.ImportFolderId, a.ImportFolderRelativePath)) + .Select(g => (g.Key.ImportFolderId, g.Key.ImportFolderRelativePath, g.Select(a => a.MediaFolderPath).ToList() as IReadOnlyList<string>)) + .ToList(); + + public static IReadOnlyList<(string importFolderSubPath, bool vfsEnabled, IReadOnlyList<string> mediaFolderPaths)> ToImportFolderList(this IEnumerable<MediaFolderConfiguration> mediaConfigs, int importFolderId, string relativePath) + => mediaConfigs + .Where(a => a.ImportFolderId == importFolderId && a.IsEnabledForPath(relativePath)) + .GroupBy(a => (a.ImportFolderId, a.ImportFolderRelativePath, a.IsVirtualFileSystemEnabled)) + .Select(g => (g.Key.ImportFolderRelativePath, g.Key.IsVirtualFileSystemEnabled, g.Select(a => a.MediaFolderPath).ToList() as IReadOnlyList<string>)) + .ToList(); +} + +public class MediaFolderConfigurationService +{ + private readonly ILogger<MediaFolderConfigurationService> Logger; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ShokoAPIClient ApiClient; + + private readonly NamingOptions NamingOptions; + + private readonly Dictionary<Guid, string> MediaFolderChangeKeys = new(); + + private readonly object LockObj = new(); + + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationAdded; + + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationUpdated; + + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationRemoved; + + public MediaFolderConfigurationService( + ILogger<MediaFolderConfigurationService> logger, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ShokoAPIClient apiClient, + NamingOptions namingOptions + ) + { + Logger = logger; + LibraryManager = libraryManager; + FileSystem = fileSystem; + ApiClient = apiClient; + NamingOptions = namingOptions; + + foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders) + MediaFolderChangeKeys[mediaConfig.MediaFolderId] = ConstructKey(mediaConfig); + LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; + Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; + } + + ~MediaFolderConfigurationService() + { + LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + MediaFolderChangeKeys.Clear(); + } + + #region Changes Tracking + + private static string ConstructKey(MediaFolderConfiguration config) + => $"IsMapped={config.IsMapped},IsFileEventsEnabled={config.IsFileEventsEnabled},IsRefreshEventsEnabled={config.IsRefreshEventsEnabled},IsVirtualFileSystemEnabled={config.IsVirtualFileSystemEnabled},LibraryFilteringMode={config.LibraryFilteringMode}"; + + private void OnConfigurationChanged(object? sender, PluginConfiguration config) + { + foreach (var mediaConfig in config.MediaFolders) { + var currentKey = ConstructKey(mediaConfig); + if (MediaFolderChangeKeys.TryGetValue(mediaConfig.MediaFolderId, out var previousKey) && previousKey != currentKey) { + MediaFolderChangeKeys[mediaConfig.MediaFolderId] = currentKey; + if (LibraryManager.GetItemById(mediaConfig.MediaFolderId) is not Folder mediaFolder) + continue; + ConfigurationUpdated?.Invoke(sender, new(mediaConfig, mediaFolder)); + } + } + } + + private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) + { + var root = LibraryManager.RootFolder; + if (e.Item != null && root != null && e.Item != root && e.Item is Folder folder && folder.ParentId == Guid.Empty && !string.IsNullOrEmpty(folder.Path) && !folder.Path.StartsWith(root.Path)) { + lock (LockObj) { + var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); + if (mediaFolderConfig != null) { + Logger.LogDebug( + "Removing stored configuration for folder at {Path} (ImportFolder={ImportFolderId},RelativePath={RelativePath})", + folder.Path, + mediaFolderConfig.ImportFolderId, + mediaFolderConfig.ImportFolderRelativePath + ); + Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); + Plugin.Instance.UpdateConfiguration(); + + MediaFolderChangeKeys.Remove(folder.Id); + ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); + } + } + } + } + + #endregion + + #region Media Folder Mapping + + public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) + { + + lock (LockObj) { + var virtualFolders = LibraryManager.GetVirtualFolders(); + return Plugin.Instance.Configuration.MediaFolders + .Where(config => config.IsMapped && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) + .GroupBy(config => config.LibraryId) + .Select(groupBy => ( + libraryFolder: LibraryManager.GetItemById(groupBy.Key) as Folder, + virtualFolder: virtualFolders.FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == groupBy.Key), + mediaList: groupBy + .Where(config => LibraryManager.GetItemById(config.MediaFolderId) is Folder) + .ToList() as IReadOnlyList<MediaFolderConfiguration> + )) + .Where(tuple => tuple.libraryFolder is not null && tuple.virtualFolder is not null && tuple.virtualFolder.Locations.Length is > 0 && tuple.mediaList.Count is > 0) + .Select(tuple => (tuple.libraryFolder!.GetVirtualRoot(), tuple.virtualFolder!.Locations[0], LibraryManager.GetConfiguredContentType(tuple.libraryFolder!), tuple.mediaList)) + .ToList(); + } + } + + public (string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, Func<MediaFolderConfiguration, bool>? filter = null) + { + var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder); + lock (LockObj) { + if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) + return (string.Empty, string.Empty, null, new List<MediaFolderConfiguration>()); + var virtualFolder = LibraryManager.GetVirtualFolders() + .FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == mediaFolderConfig.LibraryId); + if (virtualFolder is null || virtualFolder.Locations.Length is 0) + return (string.Empty, string.Empty, null, new List<MediaFolderConfiguration>()); + return ( + libraryFolder.GetVirtualRoot(), + virtualFolder.Locations[0], + LibraryManager.GetConfiguredContentType(libraryFolder), + Plugin.Instance.Configuration.MediaFolders + .Where(config => config.IsMapped && config.LibraryId == mediaFolderConfig.LibraryId && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) + .ToList() + ); + } + } + + public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) + { + if (LibraryManager.GetVirtualFolders().FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path)) is not { } library || !Guid.TryParse(library.ItemId, out var libraryId)) + throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); + + lock (LockObj) { + var config = Plugin.Instance.Configuration; + var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); + var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id) ?? + CreateConfigurationForPath(libraryId, mediaFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Map all the other media folders now… since we need them to exist when generating the VFS. + foreach (var mediaFolderPath in library.Locations) { + if (string.Equals(mediaFolderPath, mediaFolder.Path)) + continue; + + if (config.MediaFolders.Find(c => string.Equals(mediaFolderPath, c.MediaFolderPath)) is {} mfc) + continue; + + if (LibraryManager.FindByPath(mediaFolderPath, true) is not Folder secondFolder) + { + Logger.LogTrace("Unable to find database entry for {Path}", mediaFolderPath); + continue; + } + + CreateConfigurationForPath(libraryId, secondFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); + } + + return mediaFolderConfig; + } + + } + + private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid libraryId, Folder mediaFolder, MediaFolderConfiguration? libraryConfig) + { + // Check if we should introduce the VFS for the media folder. + var config = Plugin.Instance.Configuration; + var mediaFolderConfig = new MediaFolderConfiguration() { + LibraryId = libraryId, + MediaFolderId = mediaFolder.Id, + MediaFolderPath = mediaFolder.Path, + IsFileEventsEnabled = libraryConfig?.IsFileEventsEnabled ?? config.SignalR_FileEvents, + IsRefreshEventsEnabled = libraryConfig?.IsRefreshEventsEnabled ?? config.SignalR_RefreshEnabled, + IsVirtualFileSystemEnabled = libraryConfig?.IsVirtualFileSystemEnabled ?? config.VFS_Enabled, + LibraryFilteringMode = libraryConfig?.LibraryFilteringMode ?? config.LibraryFilteringMode, + }; + + var start = DateTime.UtcNow; + var attempts = 0; + var samplePaths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .Take(100) + .ToList(); + + Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}.", samplePaths.Count > 100 ? 100 : samplePaths.Count, mediaFolder.Path); + foreach (var path in samplePaths) { + attempts++; + var partialPath = path[mediaFolder.Path.Length..]; + var files = await ApiClient.GetFileByPath(partialPath).ConfigureAwait(false); + var file = files.FirstOrDefault(); + if (file is null) + continue; + + var fileId = file.Id.ToString(); + var fileLocations = file.Locations + .Where(location => location.RelativePath.EndsWith(partialPath)) + .ToList(); + if (fileLocations.Count is 0) + continue; + + var fileLocation = fileLocations[0]; + mediaFolderConfig.ImportFolderId = fileLocation.ImportFolderId; + mediaFolderConfig.ImportFolderRelativePath = fileLocation.RelativePath[..^partialPath.Length]; + break; + } + + try { + var importFolder = await ApiClient.GetImportFolder(mediaFolderConfig.ImportFolderId); + if (importFolder != null) + mediaFolderConfig.ImportFolderName = importFolder.Name; + } + catch { } + + // Store and log the result. + MediaFolderChangeKeys[mediaFolder.Id] = ConstructKey(mediaFolderConfig); + config.MediaFolders.Add(mediaFolderConfig); + Plugin.Instance.UpdateConfiguration(config); + if (mediaFolderConfig.IsMapped) { + Logger.LogInformation( + "Found a match for media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", + mediaFolder.Path, + DateTime.UtcNow - start, + mediaFolderConfig.ImportFolderId, + mediaFolderConfig.ImportFolderRelativePath, + mediaFolder.Path, + attempts + ); + } + else { + Logger.LogWarning( + "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}.", + mediaFolder.Path, + attempts, + DateTime.UtcNow - start + ); + } + + ConfigurationAdded?.Invoke(null, new(mediaFolderConfig, mediaFolder)); + + return mediaFolderConfig; + } + + #endregion +} \ No newline at end of file diff --git a/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs b/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs new file mode 100644 index 00000000..c9d44ef9 --- /dev/null +++ b/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs @@ -0,0 +1,18 @@ +using System; +using MediaBrowser.Controller.Entities; +using Shokofin.Configuration; + +namespace Shokofin.Configuration.Models; + +public class MediaConfigurationChangedEventArgs : EventArgs +{ + public MediaFolderConfiguration Configuration { get; private init; } + + public Folder MediaFolder { get; private init; } + + public MediaConfigurationChangedEventArgs(MediaFolderConfiguration config, Folder folder) + { + Configuration = config; + MediaFolder = folder; + } +} \ No newline at end of file diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs new file mode 100644 index 00000000..9ffc0f5e --- /dev/null +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -0,0 +1,573 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text.Json.Serialization; +using System.Xml.Serialization; +using MediaBrowser.Model.Plugins; +using Shokofin.API.Models; + +using CollectionCreationType = Shokofin.Utils.Ordering.CollectionCreationType; +using DescriptionProvider = Shokofin.Utils.Text.DescriptionProvider; +using LibraryFilteringMode = Shokofin.Utils.Ordering.LibraryFilteringMode; +using OrderType = Shokofin.Utils.Ordering.OrderType; +using ProviderName = Shokofin.Events.Interfaces.ProviderName; +using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; +using TagIncludeFilter = Shokofin.Utils.TagFilter.TagIncludeFilter; +using TagSource = Shokofin.Utils.TagFilter.TagSource; +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; +using TitleProvider = Shokofin.Utils.Text.TitleProvider; + +namespace Shokofin.Configuration; + +public class PluginConfiguration : BasePluginConfiguration +{ + #region Connection + +#pragma warning disable CA1822 + /// <summary> + /// Helper for the web ui to show the windows only warning, and to disable + /// the VFS by default if we cannot create symbolic links. + /// </summary> + [XmlIgnore, JsonInclude] + public bool CanCreateSymbolicLinks => Plugin.Instance.CanCreateSymbolicLinks; +#pragma warning restore CA1822 + + /// <summary> + /// The URL for where to connect to shoko internally. + /// And externally if no <seealso cref="PublicUrl"/> is set. + /// </summary> + [XmlElement("Host")] + public string Url { get; set; } + + /// <summary> + /// The last known server version. This is used for keeping compatibility + /// with multiple versions of the server. + /// </summary> + [XmlElement("HostVersion")] + public ComponentVersion? ServerVersion { get; set; } + + [XmlElement("PublicHost")] + public string PublicUrl { get; set; } + + [JsonIgnore] + public virtual string PrettyUrl + => string.IsNullOrEmpty(PublicUrl) ? Url : PublicUrl; + + /// <summary> + /// The last known user name we used to try and connect to the server. + /// </summary> + public string Username { get; set; } + + /// <summary> + /// The API key used to authenticate our requests to the server. + /// This will be an empty string if we're not authenticated yet. + /// </summary> + public string ApiKey { get; set; } + + #endregion + + #region Plugin Interoperability + + /// <summary> + /// Add AniDB ids to entries that support it. This is best to use when you + /// don't use shoko groups. + /// </summary> + public bool AddAniDBId { get; set; } + + /// <summary> + /// Add TMDb ids to entries that support it. + /// </summary> + public bool AddTMDBId { get; set; } + + #endregion + + #region Metadata + + /// <summary> + /// Determines if we use the overridden settings for how the main title is fetched for entries. + /// </summary> + public bool TitleMainOverride { get; set; } + + /// <summary> + /// Determines how we'll be selecting our main title for entries. + /// </summary> + public TitleProvider[] TitleMainList { get; set; } + + /// <summary> + /// The order of which we will be selecting our main title for entries. + /// </summary> + public TitleProvider[] TitleMainOrder { get; set; } + + /// <summary> + /// Determines if we use the overridden settings for how the alternate title is fetched for entries. + /// </summary> + public bool TitleAlternateOverride { get; set; } + + /// <summary> + /// Determines how we'll be selecting our alternate title for entries. + /// </summary> + public TitleProvider[] TitleAlternateList { get; set; } + + /// <summary> + /// The order of which we will be selecting our alternate title for entries. + /// </summary> + public TitleProvider[] TitleAlternateOrder { get; set; } + + /// <summary> + /// Allow choosing any title in the selected language if no official + /// title is available. + /// </summary> + public bool TitleAllowAny { get; set; } + + /// <summary> + /// This will combine the titles for multi episodes entries into a single + /// title, instead of just showing the title for the first episode. + /// </summary> + public bool TitleAddForMultipleEpisodes { get; set; } + + /// <summary> + /// Mark any episode that is not considered a normal season episode with a + /// prefix and number. + /// </summary> + public bool MarkSpecialsWhenGrouped { get; set; } + + /// <summary> + /// Determines if we use the overridden settings for how descriptions are fetched for entries. + /// </summary> + public bool DescriptionSourceOverride { get; set; } + + /// <summary> + /// The collection of providers for descriptions. Replaces the former `DescriptionSource`. + /// </summary> + public DescriptionProvider[] DescriptionSourceList { get; set; } + + /// <summary> + /// The prioritization order of source providers for description sources. + /// </summary> + public DescriptionProvider[] DescriptionSourceOrder { get; set; } + + /// <summary> + /// Clean up links within the AniDB description for entries. + /// </summary> + public bool SynopsisCleanLinks { get; set; } + + /// <summary> + /// Clean up misc. lines within the AniDB description for entries. + /// </summary> + public bool SynopsisCleanMiscLines { get; set; } + + /// <summary> + /// Remove the "summary" preface text in the AniDB description for entries. + /// </summary> + public bool SynopsisRemoveSummary { get; set; } + + /// <summary> + /// Collapse up multiple empty lines into a single line in the AniDB + /// description for entries. + /// </summary> + public bool SynopsisCleanMultiEmptyLines { get; set; } + + #endregion + + #region Tags + + /// <summary> + /// Determines if we use the overridden settings for how the tags are set for entries. + /// </summary> + public bool TagOverride { get; set; } + + /// <summary> + /// All tag sources to use for tags. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagSource TagSources { get; set; } + + /// <summary> + /// Filter to include tags as tags based on specific criteria. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagIncludeFilter TagIncludeFilters { get; set; } + + /// <summary> + /// Minimum weight of tags to be included, except weightless tags, which has their own filtering through <seealso cref="TagIncludeFilter.Weightless"/>. + /// </summary> + public TagWeight TagMinimumWeight { get; set; } + + /// <summary> + /// The maximum relative depth of the tag from it's source type to use for tags. + /// </summary> + [Range(0, 10)] + public int TagMaximumDepth { get; set; } + + /// <summary> + /// Determines if we use the overridden settings for how the genres are set for entries. + /// </summary> + public bool GenreOverride { get; set; } + + /// <summary> + /// All tag sources to use for genres. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagSource GenreSources { get; set; } + + /// <summary> + /// Filter to include tags as genres based on specific criteria. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagIncludeFilter GenreIncludeFilters { get; set; } + + /// <summary> + /// Minimum weight of tags to be included, except weightless tags, which has their own filtering through <seealso cref="TagIncludeFilter.Weightless"/>. + /// </summary> + public TagWeight GenreMinimumWeight { get; set; } + + /// <summary> + /// The maximum relative depth of the tag from it's source type to use for genres. + /// </summary> + [Range(0, 10)] + public int GenreMaximumDepth { get; set; } + + /// <summary> + /// Hide tags that are not verified by the AniDB moderators yet. + /// </summary> + public bool HideUnverifiedTags { get; set; } + + /// <summary> + /// Determines if we use the overridden settings for how the content/official ratings are set for entries. + /// </summary> + public bool ContentRatingOverride { get; set; } + + /// <summary> + /// Enabled content rating providers. + /// </summary> + public ProviderName[] ContentRatingList { get; set; } + + /// <summary> + /// The order to go through the content rating providers to retrieve a content rating. + /// </summary> + public ProviderName[] ContentRatingOrder { get; set; } + + /// <summary> + /// Determines if we use the overridden settings for how the production locations are set for entries. + /// </summary> + public bool ProductionLocationOverride { get; set; } + + /// <summary> + /// Enabled production location providers. + /// </summary> + public ProviderName[] ProductionLocationList { get; set; } + + /// <summary> + /// The order to go through the production location providers to retrieve a production location. + /// </summary> + public ProviderName[] ProductionLocationOrder { get; set; } + + #endregion + + #region User + + /// <summary> + /// User configuration. + /// </summary> + public List<UserConfiguration> UserList { get; set; } + + #endregion + + #region Library + + /// <summary> + /// Use Shoko Groups to group Shoko Series together to create the show entries. + /// </summary> + public bool UseGroupsForShows { get; set; } + + /// <summary> + /// Separate movies out of show type libraries. + /// </summary> + public bool SeparateMovies { get; set; } + + /// <summary> + /// Append all specials in AniDB movie series as special features for + /// the movies. + /// </summary> + public bool MovieSpecialsAsExtraFeaturettes { get; set; } + + /// <summary> + /// Add trailers to entities within the VFS. Trailers within the trailers + /// directory when not using the VFS are not affected by this option. + /// </summary> + public bool AddTrailers { get; set; } + + /// <summary> + /// Add all credits as theme videos to entities with in the VFS. In a + /// non-VFS library they will just be filtered out since we can't properly + /// support them as Jellyfin native features. + /// </summary> + public bool AddCreditsAsThemeVideos { get; set; } + + /// <summary> + /// Add all credits as special features to entities with in the VFS. In a + /// non-VFS library they will just be filtered out since we can't properly + /// support them as Jellyfin native features. + /// </summary> + public bool AddCreditsAsSpecialFeatures { get; set; } + + /// <summary> + /// Determines how collections are made. + /// </summary> + public CollectionCreationType CollectionGrouping { get; set; } + + /// <summary> + /// Add a minimum requirement of two entries with the same collection id + /// before creating a collection for them. + /// </summary> + public bool CollectionMinSizeOfTwo { get; set; } + + /// <summary> + /// Determines how seasons are ordered within a show. + /// </summary> + public OrderType SeasonOrdering { get; set; } + + /// <summary> + /// Determines how specials are placed within seasons, if at all. + /// </summary> + public SpecialOrderType SpecialsPlacement { get; set; } + + /// <summary> + /// Add missing season and episode entries so the user can see at a glance + /// what is missing, and so the "Upcoming" section of the library works as + /// intended. + /// </summary> + public bool AddMissingMetadata { get; set; } + + public string[] IgnoredFolders { get; set; } + + #endregion + + #region Media Folder + + /// <summary> + /// Enable/disable the VFS for new media-folders/libraries. + /// </summary> + [XmlElement("VirtualFileSystem")] + public bool VFS_Enabled { get; set; } + + /// <summary> + /// Number of threads to concurrently generate links for the VFS. + /// </summary> + [XmlElement("VirtualFileSystemThreads")] + public int VFS_Threads { get; set; } + + /// <summary> + /// Add release group to the file name of VFS entries. + /// </summary> + public bool VFS_AddReleaseGroup { get; set; } + + /// <summary> + /// Add resolution to the file name of VFS entries. + /// </summary> + public bool VFS_AddResolution { get; set; } + + /// <summary> + /// Enable/disable the filtering for new media-folders/libraries. + /// </summary> + [XmlElement("LibraryFiltering")] + public LibraryFilteringMode LibraryFilteringMode { get; set; } + + /// <summary> + /// Reaction time to when a library scan starts/ends, because they don't + /// expose it as an event, so we need to poll instead. + /// </summary> + [Range(1, 10)] + public int LibraryScanReactionTimeInSeconds { get; set; } + + /// <summary> + /// Per media folder configuration. + /// </summary> + public List<MediaFolderConfiguration> MediaFolders { get; set; } + + #endregion + + #region SignalR + + /// <summary> + /// Enable the SignalR events from Shoko. + /// </summary> + public bool SignalR_AutoConnectEnabled { get; set; } + + /// <summary> + /// Reconnect intervals if the the stream gets disconnected. + /// </summary> + public int[] SignalR_AutoReconnectInSeconds { get; set; } + + /// <summary> + /// Will automatically refresh entries if metadata is updated in Shoko. + /// </summary> + public bool SignalR_RefreshEnabled { get; set; } + + /// <summary> + /// Will notify Jellyfin about files that have been added/updated/removed + /// in shoko. + /// </summary> + public bool SignalR_FileEvents { get; set; } + + /// <summary> + /// The different SignalR event sources to 'subscribe' to. + /// </summary> + public ProviderName[] SignalR_EventSources { get; set; } + + #endregion + + #region Usage Tracker + + /// <summary> + /// Amount of seconds that needs to pass before the usage tracker considers the usage as stalled and resets it's tracking and dispatches it's <seealso cref="Utils.UsageTracker.Stalled"/> event. + /// </summary> + /// <remarks> + /// It can be configured between 1 second and 3 hours. + /// </remarks> + [Range(1, 10800)] + public int UsageTracker_StalledTimeInSeconds { get; set; } + + #endregion + + #region Experimental features + + /// <summary> + /// Automagically merge alternate versions after a library scan. + /// </summary> + public bool EXPERIMENTAL_AutoMergeVersions { get; set; } + + /// <summary> + /// Split all movies up before merging them back together. + /// </summary> + public bool EXPERIMENTAL_SplitThenMergeMovies { get; set; } + + /// <summary> + /// Split all episodes up before merging them back together. + /// </summary> + public bool EXPERIMENTAL_SplitThenMergeEpisodes { get; set; } + + /// <summary> + /// Blur the boundaries between AniDB anime further by merging entries which could had just been a single anime entry based on name matching and a configurable merge window. + /// </summary> + public bool EXPERIMENTAL_MergeSeasons { get; set; } + + /// <summary> + /// Series types to attempt to merge. Will respect custom series type overrides. + /// </summary> + public SeriesType[] EXPERIMENTAL_MergeSeasonsTypes { get; set; } + + /// <summary> + /// Number of days to check between the start of each season, inclusive. + /// </summary> + /// <value></value> + public int EXPERIMENTAL_MergeSeasonsMergeWindowInDays { get; set; } + + #endregion + + public PluginConfiguration() + { + Url = "http://127.0.0.1:8111"; + ServerVersion = null; + PublicUrl = string.Empty; + Username = "Default"; + ApiKey = string.Empty; + TagOverride = false; + TagSources = TagSource.ContentIndicators | TagSource.Dynamic | TagSource.DynamicCast | TagSource.DynamicEnding | TagSource.Elements | + TagSource.ElementsPornographyAndSexualAbuse | TagSource.ElementsTropesAndMotifs | TagSource.Fetishes | + TagSource.OriginProduction | TagSource.OriginDevelopment | TagSource.SourceMaterial | TagSource.SettingPlace | + TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.TargetAudience | TagSource.TechnicalAspects | + TagSource.TechnicalAspectsAdaptions | TagSource.TechnicalAspectsAwards | TagSource.TechnicalAspectsMultiAnimeProjects | + TagSource.Themes | TagSource.ThemesDeath | TagSource.ThemesTales | TagSource.CustomTags; + TagIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted; + TagMinimumWeight = TagWeight.Weightless; + TagMaximumDepth = 0; + GenreSources = TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.Elements; + GenreIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted; + GenreMinimumWeight = TagWeight.Four; + GenreMaximumDepth = 1; + HideUnverifiedTags = true; + ContentRatingOverride = false; + ContentRatingList = new[] { + ProviderName.AniDB, + ProviderName.TMDB, + }; + ContentRatingOrder = ContentRatingList.ToArray(); + ProductionLocationOverride = false; + ProductionLocationList = new[] { + ProviderName.AniDB, + ProviderName.TMDB, + }; + ProductionLocationOrder = ProductionLocationList.ToArray(); + TitleAddForMultipleEpisodes = true; + SynopsisCleanLinks = true; + SynopsisCleanMiscLines = true; + SynopsisRemoveSummary = true; + SynopsisCleanMultiEmptyLines = true; + AddAniDBId = true; + AddTMDBId = true; + TitleMainOverride = false; + TitleMainList = new[] { + TitleProvider.Shoko_Default, + }; + TitleMainOrder = new[] { + TitleProvider.Shoko_Default, + TitleProvider.AniDB_Default, + TitleProvider.AniDB_LibraryLanguage, + TitleProvider.AniDB_CountryOfOrigin, + TitleProvider.TMDB_Default, + TitleProvider.TMDB_LibraryLanguage, + TitleProvider.TMDB_CountryOfOrigin, + }; + TitleAlternateOverride = false; + TitleAlternateList = new[] { + TitleProvider.AniDB_CountryOfOrigin + }; + TitleAlternateOrder = TitleMainOrder.ToArray(); + TitleAllowAny = true; + DescriptionSourceOverride = false; + DescriptionSourceList = new[] { + DescriptionProvider.AniDB, + DescriptionProvider.TvDB, + DescriptionProvider.TMDB, + }; + DescriptionSourceOrder = new[] { + DescriptionProvider.AniDB, + DescriptionProvider.TvDB, + DescriptionProvider.TMDB, + }; + VFS_Enabled = CanCreateSymbolicLinks; + VFS_Threads = 4; + VFS_AddReleaseGroup = false; + VFS_AddResolution = false; + UseGroupsForShows = false; + SeparateMovies = false; + MovieSpecialsAsExtraFeaturettes = false; + AddTrailers = true; + AddCreditsAsThemeVideos = true; + AddCreditsAsSpecialFeatures = false; + SeasonOrdering = OrderType.Default; + SpecialsPlacement = SpecialOrderType.AfterSeason; + AddMissingMetadata = true; + MarkSpecialsWhenGrouped = true; + CollectionGrouping = CollectionCreationType.None; + CollectionMinSizeOfTwo = true; + UserList = new(); + MediaFolders = new(); + IgnoredFolders = new[] { ".streams", "@recently-snapshot" }; + LibraryFilteringMode = LibraryFilteringMode.Auto; + LibraryScanReactionTimeInSeconds = 1; + SignalR_AutoConnectEnabled = false; + SignalR_AutoReconnectInSeconds = new[] { 0, 2, 10, 30, 60, 120, 300 }; + SignalR_EventSources = new[] { ProviderName.Shoko, ProviderName.AniDB, ProviderName.TMDB }; + SignalR_RefreshEnabled = false; + SignalR_FileEvents = false; + UsageTracker_StalledTimeInSeconds = 10; + EXPERIMENTAL_AutoMergeVersions = true; + EXPERIMENTAL_SplitThenMergeMovies = true; + EXPERIMENTAL_SplitThenMergeEpisodes = false; + EXPERIMENTAL_MergeSeasons = false; + EXPERIMENTAL_MergeSeasonsTypes = new[] { SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA }; + EXPERIMENTAL_MergeSeasonsMergeWindowInDays = 185; + } +} diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs new file mode 100644 index 00000000..c2df2967 --- /dev/null +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -0,0 +1,79 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Shokofin.Configuration; + +/// <summary> +/// Per user configuration. +/// </summary> +public class UserConfiguration +{ + /// <summary> + /// The Jellyfin user id this configuration is for. + /// </summary> + public Guid UserId { get; set; } = Guid.Empty; + + /// <summary> + /// Enables watch-state synchronization for the user. + /// </summary> + public bool EnableSynchronization { get; set; } + + /// <summary> + /// Enable the stop event for syncing after video playback. + /// </summary> + public bool SyncUserDataAfterPlayback { get; set; } + + /// <summary> + /// Enable the play/pause/resume(/stop) events for syncing under/during + /// video playback. + /// </summary> + public bool SyncUserDataUnderPlayback { get; set; } + + /// <summary> + /// Enable the scrobble event for live syncing under/during video + /// playback. + /// </summary> + public bool SyncUserDataUnderPlaybackLive { get; set; } + + /// <summary> + /// Number of playback events to skip before starting to send the events + /// to Shoko. This is to prevent accidentally updating user watch data + /// when a user miss clicked on a video. + /// </summary> + [Range(0, 200)] + public byte SyncUserDataInitialSkipEventCount { get; set; } = 0; + + /// <summary> + /// Number of ticks to skip (1 tick is 10 seconds) before scrobbling to + /// shoko. + /// </summary> + [Range(1, 250)] + public byte SyncUserDataUnderPlaybackAtEveryXTicks { get; set; } = 6; + + /// <summary> + /// Imminently scrobble if the playtime changes above this threshold + /// given in ticks (ticks in a time-span). + /// </summary> + /// <value></value> + public long SyncUserDataUnderPlaybackLiveThreshold { get; set; } = 125000000; // 12.5s + + /// <summary> + /// Enable syncing user data when an item have been added/updated. + /// </summary> + public bool SyncUserDataOnImport { get; set; } + + /// <summary> + /// Enabling user data sync. for restricted videos (H). + /// </summary> + public bool SyncRestrictedVideos { get; set; } + + /// <summary> + /// The username of the linked user in Shoko. + /// </summary> + public string Username { get; set; } = string.Empty; + + /// <summary> + /// The API Token for authentication/authorization with Shoko Server. + /// </summary> + public string Token { get; set; } = string.Empty; +} diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js new file mode 100644 index 00000000..8156e0e1 --- /dev/null +++ b/Shokofin/Configuration/configController.js @@ -0,0 +1,1199 @@ +const PluginConfig = { + pluginId: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" +}; + +const Messages = { + ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", + InvalidCredentials: "An error occurred while trying to authenticating the user using the provided credentials.", + UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it.", +}; + +/** + * Filter out duplicate values and sanitize list. + * @param {string} value - Stringified list of values to filter. + * @returns {string[]} An array of sanitized and filtered values. + */ +function filterIgnoredFolders(value) { + // We convert to a set to filter out duplicate values. + const filteredSet = new Set( + value + // Split the values at every comma. + .split(",") + // Sanitize inputs. + .map(str => str.trim().toLowerCase()) + .filter(str => str), + ); + + // Convert it back into an array. + return Array.from(filteredSet); +} + +/** + * Filter out duplicate values and sanitize list. + * @param {string} value - Stringified list of values to filter. + * @returns {number[]} An array of sanitized and filtered values. + */ +function filterReconnectIntervals(value) { + // We convert to a set to filter out duplicate values. + const filteredSet = new Set( + value + // Split the values at every comma. + .split(",") + // Sanitize inputs. + .map(str => parseInt(str.trim().toLowerCase(), 10)) + .filter(int => !Number.isNaN(int)), + ); + + // Convert it back into an array. + return Array.from(filteredSet).sort((a, b) => a - b); +} + +/** + * + * @param {HTMLElement} element + * @param {number} index + */ +function adjustSortableListElement(element, index) { + const button = element.querySelector(".btnSortable"); + const icon = button.querySelector(".material-icons"); + if (index > 0) { + button.title = "Up"; + button.classList.add("btnSortableMoveUp"); + button.classList.remove("btnSortableMoveDown"); + icon.classList.add("keyboard_arrow_up"); + icon.classList.remove("keyboard_arrow_down"); + } + else { + button.title = "Down"; + button.classList.add("btnSortableMoveDown"); + button.classList.remove("btnSortableMoveUp"); + icon.classList.add("keyboard_arrow_down"); + icon.classList.remove("keyboard_arrow_up"); + } +} + +/** + * @param {PointerEvent} event + **/ +function onSortableContainerClick(event) { + const parentWithClass = (element, className) => + (element.parentElement.classList.contains(className)) ? element.parentElement : null; + const btnSortable = parentWithClass(event.target, "btnSortable"); + if (btnSortable) { + const listItem = parentWithClass(btnSortable, "sortableOption"); + const list = parentWithClass(listItem, "paperList"); + if (btnSortable.classList.contains("btnSortableMoveDown")) { + const next = listItem.nextElementSibling; + if (next) { + listItem.parentElement.removeChild(listItem); + next.parentElement.insertBefore(listItem, next.nextSibling); + } + } + else { + const prev = listItem.previousElementSibling; + if (prev) { + listItem.parentElement.removeChild(listItem); + prev.parentElement.insertBefore(listItem, prev); + } + } + let i = 0; + for (const option of list.querySelectorAll(".sortableOption")) { + adjustSortableListElement(option, i++); + } + } +} + +async function loadUserConfig(form, userId, config) { + if (!userId) { + form.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("required"); + Dashboard.hideLoadingMsg(); + return; + } + + Dashboard.showLoadingMsg(); + + // Get the configuration to use. + if (!config) config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId) + const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; + + // Configure the elements within the user container + form.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; + form.querySelector("#SyncUserDataOnImport").checked = userConfig.SyncUserDataOnImport || false; + form.querySelector("#SyncUserDataAfterPlayback").checked = userConfig.SyncUserDataAfterPlayback || false; + form.querySelector("#SyncUserDataUnderPlayback").checked = userConfig.SyncUserDataUnderPlayback || false; + form.querySelector("#SyncUserDataUnderPlaybackLive").checked = userConfig.SyncUserDataUnderPlaybackLive || false; + form.querySelector("#SyncUserDataInitialSkipEventCount").checked = userConfig.SyncUserDataInitialSkipEventCount === 2; + form.querySelector("#SyncRestrictedVideos").checked = userConfig.SyncRestrictedVideos || false; + form.querySelector("#UserUsername").value = userConfig.Username || ""; + // Synchronization settings + form.querySelector("#UserPassword").value = ""; + if (userConfig.Token) { + form.querySelector("#UserDeleteContainer").removeAttribute("hidden"); + form.querySelector("#UserUsername").setAttribute("disabled", ""); + form.querySelector("#UserPasswordContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("required"); + } + else { + form.querySelector("#UserDeleteContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("disabled"); + form.querySelector("#UserPasswordContainer").removeAttribute("hidden"); + form.querySelector("#UserUsername").setAttribute("required", ""); + } + + // Show the user settings now if it was previously hidden. + form.querySelector("#UserSettingsContainer").removeAttribute("hidden"); + + Dashboard.hideLoadingMsg(); +} + +async function loadMediaFolderConfig(form, mediaFolderId, config) { + if (!mediaFolderId) { + form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); + form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + Dashboard.hideLoadingMsg(); + return; + } + + Dashboard.showLoadingMsg(); + + // Get the configuration to use. + if (!config) config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId) + const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId); + if (!mediaFolderConfig) { + form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); + form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + Dashboard.hideLoadingMsg(); + return; + } + + form.querySelector("#MediaFolderImportFolderName").value = mediaFolderConfig.IsMapped ? `${mediaFolderConfig.ImportFolderName} (${mediaFolderConfig.ImportFolderId}) ${mediaFolderConfig.ImportFolderRelativePath}` : "Not Mapped"; + + // Configure the elements within the user container + form.querySelector("#MediaFolderVirtualFileSystem").checked = mediaFolderConfig.IsVirtualFileSystemEnabled; + form.querySelector("#MediaFolderLibraryFilteringMode").value = mediaFolderConfig.LibraryFilteringMode; + + // Show the user settings now if it was previously hidden. + form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); + form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); + + if (mediaFolderConfig.IsMapped && mediaFolderConfig.LibraryName) { + form.querySelector("#MediaFolderDeleteContainer").setAttribute("hidden", ""); + } + else { + form.querySelector("#MediaFolderDeleteContainer").removeAttribute("hidden"); + } + + Dashboard.hideLoadingMsg(); +} + +async function loadSignalrMediaFolderConfig(form, mediaFolderId, config) { + if (!mediaFolderId) { + form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").removeAttribute("hidden"); + form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + Dashboard.hideLoadingMsg(); + return; + } + + Dashboard.showLoadingMsg(); + + // Get the configuration to use. + if (!config) config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId) + const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId); + if (!mediaFolderConfig) { + form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").removeAttribute("hidden"); + form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + Dashboard.hideLoadingMsg(); + return; + } + + form.querySelector("#SignalRMediaFolderImportFolderName").value = mediaFolderConfig.IsMapped ? `${mediaFolderConfig.ImportFolderName} (${mediaFolderConfig.ImportFolderId}) ${mediaFolderConfig.ImportFolderRelativePath}` : "Not Mapped"; + + // Configure the elements within the user container + form.querySelector("#SignalRFileEvents").checked = mediaFolderConfig.IsFileEventsEnabled; + form.querySelector("#SignalRRefreshEvents").checked = mediaFolderConfig.IsRefreshEventsEnabled; + + // Show the user settings now if it was previously hidden. + form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); + form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); + + Dashboard.hideLoadingMsg(); +} + +/** + * + * @param {string} username + * @param {string} password + * @param {boolean?} userKey + * @returns {Promise<{ apikey: string; }>} + */ +function getApiKey(username, password, userKey = false) { + return ApiClient.fetch({ + dataType: "json", + data: JSON.stringify({ + username, + password, + userKey, + }), + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + type: "POST", + url: ApiClient.getUrl("Plugin/Shokofin/Host/GetApiKey"), + }); +} + +/** + * + * @returns {Promise<{ IsUsable: boolean; IsActive: boolean; State: "Disconnected" | "Connected" | "Connecting" | "Reconnecting" }>} + */ +function getSignalrStatus() { + return ApiClient.fetch({ + dataType: "json", + type: "GET", + url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Status"), + }); +} + +async function signalrConnect() { + await ApiClient.fetch({ + type: "POST", + url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Connect"), + }); + return getSignalrStatus(); +} + +async function signalrDisconnect() { + await ApiClient.fetch({ + type: "POST", + url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Disconnect"), + }); + return getSignalrStatus(); +} + +async function defaultSubmit(form) { + let config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + + if (config.ApiKey !== "") { + let publicUrl = form.querySelector("#PublicUrl").value; + if (publicUrl.endsWith("/")) { + publicUrl = publicUrl.slice(0, -1); + form.querySelector("#PublicUrl").value = publicUrl; + } + const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); + + // Metadata settings + config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; + ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableList(form, "TitleMainList")); + config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; + ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableList(form, "TitleAlternateList")); + config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; + config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; + config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; + config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; + ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); + config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; + config.TagOverride = form.querySelector("#TagOverride").checked; + config.TagSources = retrieveSimpleList(form, "TagSources").join(", "); + config.TagIncludeFilters = retrieveSimpleList(form, "TagIncludeFilters").join(", "); + config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; + config.TagMaximumDepth = parseInt(form.querySelector("#TagMaximumDepth").value, 10); + config.GenreOverride = form.querySelector("#GenreOverride").checked; + config.GenreSources = retrieveSimpleList(form, "GenreSources").join(", "); + config.GenreIncludeFilters = retrieveSimpleList(form, "GenreIncludeFilters").join(", "); + config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value; + config.GenreMaximumDepth = parseInt(form.querySelector("#GenreMaximumDepth").value, 10); + config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; + ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableList(form, "ContentRatingList")); + config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; + ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); + + // Provider settings + config.AddAniDBId = form.querySelector("#AddAniDBId").checked; + config.AddTMDBId = form.querySelector("#AddTMDBId").checked; + + // Library settings + config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; + config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; + config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; + config.CollectionMinSizeOfTwo = form.querySelector("#CollectionMinSizeOfTwo").checked; + config.SeparateMovies = form.querySelector("#SeparateMovies").checked; + config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; + config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; + config.AddTrailers = form.querySelector("#AddTrailers").checked; + config.AddCreditsAsThemeVideos = form.querySelector("#AddCreditsAsThemeVideos").checked; + config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; + config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; + + // Media Folder settings + let mediaFolderId = form.querySelector("#MediaFolderSelector").value; + let mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + config.IgnoredFolders = ignoredFolders; + form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); + config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; + config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; + if (mediaFolderConfig) { + const libraryId = mediaFolderConfig.LibraryId; + for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { + c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; + c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; + } + } + else { + config.VFS_Enabled = form.querySelector("#VFS_Enabled").checked; + config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; + } + + // SignalR settings + const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); + config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; + config.SignalR_AutoReconnectInSeconds = reconnectIntervals; + form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); + config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); + mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; + mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + if (mediaFolderConfig) { + const libraryId = mediaFolderConfig.LibraryId; + for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { + c.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; + c.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; + } + } + else { + config.SignalR_FileEvents = form.querySelector("#SignalRDefaultFileEvents").checked; + config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; + } + + + // Experimental settings + config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; + config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; + config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; + config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; + + // User settings + const userId = form.querySelector("#UserSelector").value; + if (userId) { + let userConfig = config.UserList.find((c) => userId === c.UserId); + if (!userConfig) { + userConfig = { UserId: userId }; + config.UserList.push(userConfig); + } + + // The user settings goes below here; + userConfig.EnableSynchronization = form.querySelector("#UserEnableSynchronization").checked; + userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; + userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; + userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; + userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; + userConfig.SyncUserDataInitialSkipEventCount = form.querySelector("#SyncUserDataInitialSkipEventCount").checked ? 2 : 0; + userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; + userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s + userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; + + // Only try to save a new token if a token is not already present. + if (!userConfig.Token) try { + const username = form.querySelector("#UserUsername").value; + const password = form.querySelector("#UserPassword").value; + const response = await getApiKey(username, password, true); + userConfig.Username = username; + userConfig.Token = response.apikey; + } + catch (err) { + Dashboard.alert(Messages.InvalidCredentials); + console.error(err, Messages.InvalidCredentials); + userConfig.Username = ""; + userConfig.Token = ""; + } + } + + let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + } + else { + // Connection settings + let url = form.querySelector("#Url").value; + if (!url) { + url = "http://localhost:8111"; + } + else { + try { + let actualUrl = new URL(url); + url = actualUrl.href; + } + catch (err) { + try { + let actualUrl = new URL(`http://${url}:8111`); + url = actualUrl.href; + } + catch (err2) { + throw err; + } + } + } + if (url.endsWith("/")) { + url = url.slice(0, -1); + } + let publicUrl = form.querySelector("#PublicUrl").value; + if (publicUrl.endsWith("/")) { + publicUrl = publicUrl.slice(0, -1); + form.querySelector("#PublicUrl").value = publicUrl; + } + config.PublicUrl = publicUrl; + + // Update the url if needed. + if (config.Url !== url || config.PublicUrl !== publicUrl) { + config.Url = url; + form.querySelector("#Url").value = url; + form.querySelector("#PublicUrl").value = publicUrl; + let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + } + + const username = form.querySelector("#Username").value; + const password = form.querySelector("#Password").value; + try { + const response = await getApiKey(username, password); + config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + config.Username = username; + config.ApiKey = response.apikey; + + let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + + Dashboard.processPluginConfigurationUpdateResult(result); + } + catch (err) { + Dashboard.alert(Messages.InvalidCredentials); + console.error(err, Messages.InvalidCredentials); + } + } + + return config; +} + +async function resetConnectionSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + form.querySelector("#Username").value = config.Username; + form.querySelector("#Password").value = ""; + + // Connection settings + config.ApiKey = ""; + config.ServerVersion = null; + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +async function syncSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + + // Metadata settings + config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; + ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableList(form, "TitleMainList")); + config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; + ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableList(form, "TitleAlternateList")); + config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; + config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; + config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; + config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; + ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); + config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; + config.TagOverride = form.querySelector("#TagOverride").checked; + config.TagSources = retrieveSimpleList(form, "TagSources").join(", "); + config.TagIncludeFilters = retrieveSimpleList(form, "TagIncludeFilters").join(", "); + config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; + config.TagMaximumDepth = parseInt(form.querySelector("#TagMaximumDepth").value, 10); + config.GenreOverride = form.querySelector("#GenreOverride").checked; + config.GenreSources = retrieveSimpleList(form, "GenreSources").join(", "); + config.GenreIncludeFilters = retrieveSimpleList(form, "GenreIncludeFilters").join(", "); + config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value; + config.GenreMaximumDepth = parseInt(form.querySelector("#GenreMaximumDepth").value, 10); + config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; + ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableList(form, "ContentRatingList")); + config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; + ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); + + // Provider settings + config.AddAniDBId = form.querySelector("#AddAniDBId").checked; + config.AddTMDBId = form.querySelector("#AddTMDBId").checked; + + // Library settings + config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; + config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; + config.SeparateMovies = form.querySelector("#SeparateMovies").checked; + config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; + config.CollectionMinSizeOfTwo = form.querySelector("#CollectionMinSizeOfTwo").checked; + config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; + config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; + config.AddTrailers = form.querySelector("#AddTrailers").checked; + config.AddCreditsAsThemeVideos = form.querySelector("#AddCreditsAsThemeVideos").checked; + config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; + config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; + + // Experimental settings + config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; + config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; + config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; + config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +async function unlinkUser(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const userId = form.querySelector("#UserSelector").value; + if (!userId) return; + + const index = config.UserList.findIndex(c => userId === c.UserId); + if (index !== -1) { + config.UserList.splice(index, 1); + } + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +async function removeMediaFolder(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const mediaFolderId = form.querySelector("#MediaFolderSelector").value; + if (!mediaFolderId) return; + + const index = config.MediaFolders.findIndex((m) => m.MediaFolderId === mediaFolderId); + if (index !== -1) { + config.MediaFolders.splice(index, 1); + } + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + form.querySelector("#MediaFolderSelector").value = ""; + form.querySelector("#MediaFolderSelector").innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .join(""); + form.querySelector("#SignalRMediaFolderSelector").innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .join(""); + + Dashboard.processPluginConfigurationUpdateResult(result); + return config; +} + +async function syncMediaFolderSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const mediaFolderId = form.querySelector("#MediaFolderSelector").value; + const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); + + config.IgnoredFolders = ignoredFolders; + form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); + config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; + config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; + if (mediaFolderConfig) { + const libraryId = mediaFolderConfig.LibraryId; + for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { + c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; + c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; + } + } + else { + config.VFS_Enabled = form.querySelector("#VFS_Enabled").checked; + config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; + } + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +async function syncSignalrSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; + const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); + + config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; + config.SignalR_AutoReconnectInSeconds = reconnectIntervals; + config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); + form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); + + const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + if (mediaFolderConfig) { + const libraryId = mediaFolderConfig.LibraryId; + for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { + c.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; + c.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; + } + } + else { + config.SignalR_FileEvents = form.querySelector("#SignalRDefaultFileEvents").checked; + config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; + } + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +async function syncUserSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const userId = form.querySelector("#UserSelector").value; + if (!userId) + return config; + + let userConfig = config.UserList.find((c) => userId === c.UserId); + if (!userConfig) { + userConfig = { UserId: userId }; + config.UserList.push(userConfig); + } + + // The user settings goes below here; + userConfig.EnableSynchronization = form.querySelector("#UserEnableSynchronization").checked; + userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; + userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; + userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; + userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; + userConfig.SyncUserDataInitialSkipEventCount = form.querySelector("#SyncUserDataInitialSkipEventCount").checked ? 2 : 0; + userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; + userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s + userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; + + // Only try to save a new token if a token is not already present. + const username = form.querySelector("#UserUsername").value; + const password = form.querySelector("#UserPassword").value; + if (!userConfig.Token) try { + const response = await getApiKey(username, password, true); + userConfig.Username = username; + userConfig.Token = response.apikey; + } + catch (err) { + Dashboard.alert(Messages.InvalidCredentials); + console.error(err, Messages.InvalidCredentials); + userConfig.Username = ""; + userConfig.Token = ""; + } + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +export default function (page) { + /** @type {HTMLFormElement} */ + const form = page.querySelector("#ShokoConfigForm"); + const userSelector = form.querySelector("#UserSelector"); + const mediaFolderSelector = form.querySelector("#MediaFolderSelector"); + const signalrMediaFolderSelector = form.querySelector("#SignalRMediaFolderSelector"); + + // Refresh the view after we changed the settings, so the view reflect the new settings. + const refreshSettings = (config) => { + if (config.ServerVersion) { + let version = `Version ${config.ServerVersion.Version}`; + const extraDetails = [ + config.ServerVersion.ReleaseChannel || "", + config.ServerVersion. + Commit ? config.ServerVersion.Commit.slice(0, 7) : "", + ].filter(s => s).join(", "); + if (extraDetails) + version += ` (${extraDetails})`; + form.querySelector("#ServerVersion").value = version; + } + else { + form.querySelector("#ServerVersion").value = "Version N/A"; + } + if (!config.CanCreateSymbolicLinks) { + form.querySelector("#WindowsSymLinkWarning1").removeAttribute("hidden"); + form.querySelector("#WindowsSymLinkWarning2").removeAttribute("hidden"); + } + if (config.ApiKey) { + form.querySelector("#Url").setAttribute("disabled", ""); + form.querySelector("#PublicUrl").setAttribute("disabled", ""); + form.querySelector("#Username").setAttribute("disabled", ""); + form.querySelector("#Password").value = ""; + form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); + form.querySelector("#ConnectionResetContainer").removeAttribute("hidden"); + form.querySelector("#ConnectionSection").removeAttribute("hidden"); + form.querySelector("#MetadataSection").removeAttribute("hidden"); + form.querySelector("#LibrarySection").removeAttribute("hidden"); + form.querySelector("#MediaFolderSection").removeAttribute("hidden"); + form.querySelector("#SignalRSection1").removeAttribute("hidden"); + form.querySelector("#SignalRSection2").removeAttribute("hidden"); + form.querySelector("#UserSection").removeAttribute("hidden"); + form.querySelector("#ExperimentalSection").removeAttribute("hidden"); + } + else { + form.querySelector("#Url").removeAttribute("disabled"); + form.querySelector("#PublicUrl").removeAttribute("disabled"); + form.querySelector("#Username").removeAttribute("disabled"); + form.querySelector("#ConnectionSetContainer").removeAttribute("hidden"); + form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); + form.querySelector("#ConnectionSection").removeAttribute("hidden"); + form.querySelector("#MetadataSection").setAttribute("hidden", ""); + form.querySelector("#LibrarySection").setAttribute("hidden", ""); + form.querySelector("#MediaFolderSection").setAttribute("hidden", ""); + form.querySelector("#SignalRSection1").setAttribute("hidden", ""); + form.querySelector("#SignalRSection2").setAttribute("hidden", ""); + form.querySelector("#UserSection").setAttribute("hidden", ""); + form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); + } + + loadUserConfig(form, form.querySelector("#UserSelector").value, config); + loadMediaFolderConfig(form, form.querySelector("#MediaFolderSelector").value, config); + loadSignalrMediaFolderConfig(form, form.querySelector("#SignalRMediaFolderSelector").value, config); + }; + + /** + * + * @param {{ IsUsable: boolean; IsActive: boolean; State: "Disconnected" | "Connected" | "Connecting" | "Reconnecting" }} status + */ + const refreshSignalr = (status) => { + form.querySelector("#SignalRStatus").value = status.IsActive ? `Enabled, ${status.State}` : status.IsUsable ? "Disabled" : "Unavailable"; + if (status.IsUsable) { + form.querySelector("#SignalRConnectButton").removeAttribute("disabled"); + } + else { + form.querySelector("#SignalRConnectButton").setAttribute("disabled", ""); + } + if (status.IsActive) { + form.querySelector("#SignalRConnectContainer").setAttribute("hidden", ""); + form.querySelector("#SignalRDisconnectContainer").removeAttribute("hidden"); + } + else { + form.querySelector("#SignalRConnectContainer").removeAttribute("hidden"); + form.querySelector("#SignalRDisconnectContainer").setAttribute("hidden", ""); + } + }; + + const onError = (err) => { + console.error(err); + Dashboard.alert(`An error occurred; ${err.message}`); + Dashboard.hideLoadingMsg(); + }; + + userSelector.addEventListener("change", function () { + loadUserConfig(page, this.value); + }); + + mediaFolderSelector.addEventListener("change", function () { + loadMediaFolderConfig(page, this.value); + }); + + signalrMediaFolderSelector.addEventListener("change", function () { + loadSignalrMediaFolderConfig(page, this.value); + }); + + form.querySelector("#UserEnableSynchronization").addEventListener("change", function () { + const disabled = !this.checked; + form.querySelector("#SyncUserDataOnImport").disabled = disabled; + form.querySelector("#SyncUserDataAfterPlayback").disabled = disabled; + form.querySelector("#SyncUserDataUnderPlayback").disabled = disabled; + form.querySelector("#SyncUserDataUnderPlaybackLive").disabled = disabled; + form.querySelector("#SyncUserDataInitialSkipEventCount").disabled = disabled; + }); + + form.querySelector("#UseGroupsForShows").addEventListener("change", function () { + form.querySelector("#SeasonOrdering").disabled = !this.checked; + if (this.checked) { + form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + } + else { + form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + } + }); + + form.querySelector("#TitleMainList").addEventListener("click", onSortableContainerClick); + form.querySelector("#TitleAlternateList").addEventListener("click", onSortableContainerClick); + form.querySelector("#DescriptionSourceList").addEventListener("click", onSortableContainerClick); + form.querySelector("#ContentRatingList").addEventListener("click", onSortableContainerClick); + form.querySelector("#ProductionLocationList").addEventListener("click", onSortableContainerClick); + + form.querySelector("#TitleMainOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleMainList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#TitleAlternateOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleAlternateList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#DescriptionSourceOverride").addEventListener("change", function () { + const list = form.querySelector("#DescriptionSourceList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#TagOverride").addEventListener("change", function () { + if (this.checked) { + form.querySelector("#TagSources").removeAttribute("hidden"); + form.querySelector("#TagIncludeFilters").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").disabled = false; + form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#TagMaximumDepthContainer").disabled = false; + } + else { + form.querySelector("#TagSources").setAttribute("hidden", ""); + form.querySelector("#TagIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").disabled = true; + form.querySelector("#TagMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#TagMaximumDepthContainer").disabled = true; + } + }); + + form.querySelector("#GenreOverride").addEventListener("change", function () { + if (this.checked) { + form.querySelector("#GenreSources").removeAttribute("hidden"); + form.querySelector("#GenreIncludeFilters").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").disabled = false; + form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#GenreMaximumDepthContainer").disabled = false; + } + else { + form.querySelector("#GenreSources").setAttribute("hidden", ""); + form.querySelector("#GenreIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").disabled = true; + form.querySelector("#GenreMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMaximumDepthContainer").disabled = true; + } + }); + + form.querySelector("#ContentRatingOverride").addEventListener("change", function () { + const list = form.querySelector("#ContentRatingList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#ProductionLocationOverride").addEventListener("change", function () { + const list = form.querySelector("#ProductionLocationList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + page.addEventListener("viewshow", async function () { + Dashboard.showLoadingMsg(); + try { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const signalrStatus = await getSignalrStatus(); + const users = await ApiClient.getUsers(); + + // Connection settings + form.querySelector("#Url").value = config.Url; + form.querySelector("#PublicUrl").value = config.PublicUrl; + form.querySelector("#Username").value = config.Username; + form.querySelector("#Password").value = ""; + + // Metadata settings + if (form.querySelector("#TitleMainOverride").checked = config.TitleMainOverride) { + form.querySelector("#TitleMainList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleMainList").setAttribute("hidden", ""); + } + initSortableList(form, "TitleMainList", config.TitleMainList, config.TitleMainOrder); + if (form.querySelector("#TitleAlternateOverride").checked = config.TitleAlternateOverride) { + form.querySelector("#TitleAlternateList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleAlternateList").setAttribute("hidden", ""); + } + initSortableList(form, "TitleAlternateList", config.TitleAlternateList, config.TitleAlternateOrder); + form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; + form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null + ? config.TitleAddForMultipleEpisodes : true; + form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; + if (form.querySelector("#DescriptionSourceOverride").checked = config.DescriptionSourceOverride) { + form.querySelector("#DescriptionSourceList").removeAttribute("hidden"); + } + else { + form.querySelector("#DescriptionSourceList").setAttribute("hidden", ""); + } + initSortableList(form, "DescriptionSourceList", config.DescriptionSourceList, config.DescriptionSourceOrder); + form.querySelector("#CleanupAniDBDescriptions").checked = ( + config.SynopsisCleanMultiEmptyLines || + config.SynopsisCleanLinks || + config.SynopsisRemoveSummary || + config.SynopsisCleanMiscLines + ); + form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; + if (form.querySelector("#TagOverride").checked = config.TagOverride) { + form.querySelector("#TagSources").removeAttribute("hidden"); + form.querySelector("#TagIncludeFilters").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").disabled = false; + form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#TagMaximumDepthContainer").disabled = false; + } + else { + form.querySelector("#TagSources").setAttribute("hidden", ""); + form.querySelector("#TagIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").disabled = true; + form.querySelector("#TagMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#TagMaximumDepthContainer").disabled = true; + } + initSimpleList(form, "TagSources", config.TagSources.split(",").map(s => s.trim()).filter(s => s)); + initSimpleList(form, "TagIncludeFilters", config.TagIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#TagMinimumWeight").value = config.TagMinimumWeight; + form.querySelector("#TagMaximumDepth").value = config.TagMaximumDepth.toString(); + if (form.querySelector("#GenreOverride").checked = config.GenreOverride) { + form.querySelector("#GenreSources").removeAttribute("hidden"); + form.querySelector("#GenreIncludeFilters").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").disabled = false; + form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#GenreMaximumDepthContainer").disabled = false; + } + else { + form.querySelector("#GenreSources").setAttribute("hidden", ""); + form.querySelector("#GenreIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").disabled = true; + form.querySelector("#GenreMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMaximumDepthContainer").disabled = true; + } + initSimpleList(form, "GenreSources", config.GenreSources.split(",").map(s => s.trim()).filter(s => s)); + initSimpleList(form, "GenreIncludeFilters", config.GenreIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#GenreMinimumWeight").value = config.GenreMinimumWeight; + form.querySelector("#GenreMaximumDepth").value = config.GenreMaximumDepth.toString(); + + if (form.querySelector("#ContentRatingOverride").checked = config.ContentRatingOverride) { + form.querySelector("#ContentRatingList").removeAttribute("hidden"); + } + else { + form.querySelector("#ContentRatingList").setAttribute("hidden", ""); + } + initSortableList(form, "ContentRatingList", config.ContentRatingList, config.ContentRatingOrder); + if (form.querySelector("#ProductionLocationOverride").checked = config.ProductionLocationOverride) { + form.querySelector("#ProductionLocationList").removeAttribute("hidden"); + } + else { + form.querySelector("#ProductionLocationList").setAttribute("hidden", ""); + } + initSortableList(form, "ProductionLocationList", config.ProductionLocationList, config.ProductionLocationOrder); + + // Provider settings + form.querySelector("#AddAniDBId").checked = config.AddAniDBId; + form.querySelector("#AddTMDBId").checked = config.AddTMDBId; + + // Library settings + if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows) { + form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + form.querySelector("#SeasonOrdering").disabled = false; + } + else { + form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + form.querySelector("#SeasonOrdering").disabled = true; + } + form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; + form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; + form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo; + form.querySelector("#SeparateMovies").checked = config.SeparateMovies; + form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; + form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes; + form.querySelector("#AddTrailers").checked = config.AddTrailers; + form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos; + form.querySelector("#AddCreditsAsSpecialFeatures").checked = config.AddCreditsAsSpecialFeatures; + form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata; + + // Media Folder settings + + form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); + form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; + form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; + form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; + form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; + mediaFolderSelector.innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .join(""); + + // SignalR settings + form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; + form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); + initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); + signalrMediaFolderSelector.innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .join(""); + form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; + form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; + + // User settings + userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); + + // Experimental settings + form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; + form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies != null + ? config.EXPERIMENTAL_SplitThenMergeMovies : true; + form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; + form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; + + if (!config.ApiKey) { + Dashboard.alert(Messages.ConnectToShoko); + } + + refreshSettings(config); + refreshSignalr(signalrStatus); + } + catch (err) { + Dashboard.alert(Messages.UnableToRender); + console.error(err, Messages.UnableToRender) + Dashboard.hideLoadingMsg(); + } + }); + + form.addEventListener("submit", function (event) { + event.preventDefault(); + if (!event.submitter) return; + switch (event.submitter.name) { + default: + case "all-settings": + Dashboard.showLoadingMsg(); + defaultSubmit(form).then(refreshSettings).catch(onError); + break; + case "settings": + Dashboard.showLoadingMsg(); + syncSettings(form).then(refreshSettings).catch(onError); + break; + case "establish-connection": + Dashboard.showLoadingMsg(); + defaultSubmit(form) + .then(refreshSettings) + .then(getSignalrStatus) + .then(refreshSignalr) + .catch(onError); + break; + case "reset-connection": + Dashboard.showLoadingMsg(); + resetConnectionSettings(form) + .then(refreshSettings) + .then(getSignalrStatus) + .then(refreshSignalr) + .catch(onError); + break; + case "remove-media-folder": + removeMediaFolder(form).then(refreshSettings).catch(onError); + break; + case "unlink-user": + unlinkUser(form).then(refreshSettings).catch(onError); + break; + case "media-folder-settings": + Dashboard.showLoadingMsg(); + syncMediaFolderSettings(form).then(refreshSettings).catch(onError); + break; + case "signalr-connect": + signalrConnect().then(refreshSignalr).catch(onError); + break; + case "signalr-disconnect": + signalrDisconnect().then(refreshSignalr).catch(onError); + break; + case "signalr-settings": + syncSignalrSettings(form).then(refreshSettings).catch(onError); + break; + case "user-settings": + Dashboard.showLoadingMsg(); + syncUserSettings(form).then(refreshSettings).catch(onError); + break; + } + return false; + }); +} + +/** + * Initialize a selectable list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @param {string[]} order + * @returns {void} + */ +function initSortableList(form, name, enabled, order) { + let index = 0; + const list = form.querySelector(`#${name} .checkboxList`); + const listItems = Array.from(list.querySelectorAll(".listItem")) + .map((item) => ({ + item, + checkbox: item.querySelector("input[data-option]"), + isSortable: item.className.includes("sortableOption"), + })) + .map(({ item, checkbox, isSortable }) => ({ + item, + checkbox, + isSortable, + option: checkbox.dataset.option, + })); + list.innerHTML = ""; + for (const option of order) { + const { item, checkbox, isSortable } = listItems.find((item) => item.option === option) || {}; + if (!item) + continue; + list.append(item); + if (enabled.includes(option)) + checkbox.checked = true; + if (isSortable) + adjustSortableListElement(item, index++); + } +} + +/** + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @returns {void} + **/ +function initSimpleList(form, name, enabled) { + for (const item of Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`))) { + if (enabled.includes(item.dataset.option)) + item.checked = true; + } +} + +/** + * Retrieve the enabled state and order list from a sortable list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @returns {[boolean, string[], string[]]} + */ +function retrieveSortableList(form, name) { + const titleElements = Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`)); + const getValue = (el) => el.dataset.option; + return [ + titleElements + .filter((el) => el.checked) + .map(getValue) + .sort(), + titleElements + .map(getValue), + ]; +} + +/** + * Retrieve the enabled state from a simple list. + * + * @param {HTMLFormElement} form + * @param {string} name - Name of the selector list to retrieve. + * @returns {string[]} + **/ +function retrieveSimpleList(form, name) { + return Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`)) + .filter(item => item.checked) + .map(item => item.dataset.option) + .sort(); +} \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html new file mode 100644 index 00000000..91b50b58 --- /dev/null +++ b/Shokofin/Configuration/configPage.html @@ -0,0 +1,1607 @@ +<div id="ShokoConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/ShokoController.js"> + <div data-role="content"> + <div class="content-primary"> + <form id="ShokoConfigForm"> + <div class="verticalSection verticalSection-extrabottompadding"> + <div class="sectionTitleContainer flex align-items-center"> + <h2 class="sectionTitle">Shoko</h2> + <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton emby-button" target="_blank" href="https://docs.shokoanime.com/shokofin/configuration/">Help</a> + </div> + <fieldset id="ConnectionSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Connection Settings</h3> + </legend> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="Url" required label="Private Host Url:" /> + <div class="fieldDescription">This is the private URL leading to where Shoko is running. It will be used internally in Jellyfin and also for all images sent to clients and redirects back to the Shoko instance if you don't set a public host URL below. It <i>should</i> include both the protocol and the IP/DNS name.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="PublicUrl" label="Public Host Url:" /> + <div class="fieldDescription">Optional. This is the public URL leading to where Shoko is running. It can be used to redirect to Shoko if you click on a Shoko ID in the UI if Shoko and/or Jellyfin is running within a container and you cannot access Shoko from the host URL provided in the connection settings section above. It will also be used for images from the plugin when viewing the "Edit Images" modal in clients. It <i>should</i> include both the protocol and the IP/DNS name.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="Username" required label="Username:" /> + <div class="fieldDescription">The user should be an administrator in Shoko, preferably without any filtering applied.</div> + </div> + <div id="ConnectionSetContainer"> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="password" id="Password" label="Password:" /> + <div class="fieldDescription">The password for account. It can be empty.</div> + </div> + <button is="emby-button" type="submit" name="establish-connection" class="raised button-submit block emby-button"> + <span>Connect</span> + </button> + <div class="fieldDescription">Establish a connection to Shoko Server using the provided credentials.</div> + </div> + <div id="ConnectionResetContainer" hidden> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="ServerVersion" label="Server Version:" disabled readonly value="Unknown Version" /> + <div class="fieldDescription">The version of Shoko Server we're connected to.</div> + </div> + <button is="emby-button" type="submit" name="reset-connection" class="raised block emby-button"> + <span>Reset Connection</span> + </button> + <div class="fieldDescription">Reset the connection. Be sure to stop any tasks using this plugin before you press the button.</div> + </div> + </fieldset> + <fieldset id="MetadataSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Metadata Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding"> + Customize how the plugin will source the metadata for entries. + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> + <span>Add prefix to episodes</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="TitleAddForMultipleEpisodes" /> + <span>Add all metadata for multi-episode entries</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Will add the title and description for every episode in a multi-episode entry.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="TitleAllowAny" /> + <span>Allow any title in selected language</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Will add any title in the selected language if no official title is found.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> + <span>Cleanup AniDB overviews</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> + <span>Ignore unverified tags.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="TitleMainOverride" /> + <span>Override main title</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for the main title selection. + </div> + </div> + <div id="TitleMainList" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced main title source:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Shoko_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Shoko | Let Shoko decide</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Default title</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Follow metadata language in library</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Use the language from the country of origin</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Default title</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Follow metadata language in library</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Use the language from the country of origin</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + </div> + <div class="fieldDescription">The metadata providers to use as the source of the main title, in priority order.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="TitleAlternateOverride" /> + <span>Override alternate title</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for the alternate title selection. + </div> + </div> + <div id="TitleAlternateList" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced alternate title source:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Shoko_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Shoko | Let Shoko decide</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Default title</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Follow metadata language in library</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Use the language from the country of origin</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Default title</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Follow metadata language in library</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Use the language from the country of origin</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + </div> + <div class="fieldDescription">The metadata providers to use as the source of the alternate title, in priority order.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="DescriptionSourceOverride" /> + <span>Override description source</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for description source selection. + </div> + </div> + <div id="DescriptionSourceList" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced description source:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem sortableOption" data-option="AniDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB</h3> + <span></span> + </div> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption" data-option="TvDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TvDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TvDB</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption" data-option="TMDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + </div> + <div class="fieldDescription">The metadata providers to use as the source of descriptions, in priority order.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="TagOverride" /> + <span>Override tag sources</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for tag source selection. + </div> + </div> + <div id="TagSources" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced tag sources:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Content Indicators</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Dynamic"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Dynamic | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicCast"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Dynamic | Cast</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicEnding"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Dynamic | Ending</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Elements"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsPornographyAndSexualAbuse"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsTropesAndMotifs"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Tropes & Motifs</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Fetishes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Fetishes</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginProduction"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Production</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginDevelopment"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Development</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingPlace"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Place</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimePeriod"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Time Period</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimeSeason"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Source Material</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Target Audience</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAdaptions"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAwards"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Awards</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsMultiAnimeProjects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Multi-Anime Projects</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesDeath"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Death</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesTales"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Tales</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Ungrouped"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Ungrouped</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Unsorted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Unsorted</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="CustomTags"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Custom User Tags</h3> + </div> + </div> + </div> + <div class="fieldDescription">The tag sources to use as the source of tags.</div> + </div> + <div id="TagIncludeFilters" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced tag include filters:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Parent"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Parent Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Child"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Child Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Abstract"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Abstract Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weightless"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weightless Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weighted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weighted Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="GlobalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="LocalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> + </div> + </div> + </div> + <div class="fieldDescription">The type of tags to include for tags.</div> + </div> + <div id="TagMinimumWeightContainer" class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="TagMinimumWeight">Advanced minimum tag weight for tags:</label> + <select is="emby-select" id="TagMinimumWeight" name="TagMinimumWeight" class="emby-select-withcolor emby-select"> + <option value="Weightless" selected>Disabled (Default)</option> + <option value="One">⯪☆☆</option> + <option value="Two">★☆☆</option> + <option value="Three">★⯪☆</option> + <option value="Four">★★☆</option> + <option value="Five">★★⯪</option> + <option value="Six">★★★</option> + </select> + <div class="fieldDescription"> + The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. + </div> + </div> + <div id="TagMaximumDepthContainer" class="inputContainer inputContainer-withDescription"> + <input is="emby-input" id="TagMaximumDepth" label="Maximum depth to add per tag source:" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> + <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for tags.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="GenreOverride" /> + <span>Override genre sources</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for genre source selection. + </div> + </div> + <div id="GenreSources" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced genre sources:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Content Indicators</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Dynamic"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Dynamic | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicCast"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Dynamic | Cast</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicEnding"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Dynamic | Ending</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Elements"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsPornographyAndSexualAbuse"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsTropesAndMotifs"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Tropes & Motifs</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Fetishes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Fetishes</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginProduction"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Production</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginDevelopment"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Development</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingPlace"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Place</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimePeriod"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Time Period</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimeSeason"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Source Material</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Target Audience</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAdaptions"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAwards"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Awards</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsMultiAnimeProjects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Multi-Anime Projects</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesDeath"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Death</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesTales"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Tales</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Ungrouped"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Ungrouped</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Unsorted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Unsorted</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="CustomTags"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Custom User Tags</h3> + </div> + </div> + </div> + <div class="fieldDescription">The tag sources to use as the source of genres.</div> + </div> + <div id="GenreIncludeFilters" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced genre include filters:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Parent"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Parent Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Child"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Child Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Abstract"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Abstract Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weightless"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weightless Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weighted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weighted Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="GlobalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="LocalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> + </div> + </div> + </div> + <div class="fieldDescription">The type of tags to include for genres.</div> + </div> + <div id="GenreMinimumWeightContainer" class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="GenreMinimumWeight">Advanced minimum tag weight for genres:</label> + <select is="emby-select" id="GenreMinimumWeight" name="GenreMinimumWeight" class="emby-select-withcolor emby-select"> + <option value="Weightless">Disabled</option> + <option value="One">⯪☆☆</option> + <option value="Two">★☆☆</option> + <option value="Three" selected>★⯪☆</option> + <option value="Four">★★☆ (Default)</option> + <option value="Five">★★⯪</option> + <option value="Six">★★★</option> + </select> + <div class="fieldDescription"> + The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. + </div> + </div> + <div id="GenreMaximumDepthContainer" class="inputContainer inputContainer-withDescription"> + <input is="emby-input" id="GenreMaximumDepth" label="Maximum depth to add per genre source:" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> + <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for genres.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="ContentRatingOverride" /> + <span>Override content rating sources</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for content rating source selection. + </div> + </div> + <div id="ContentRatingList" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced content rating sources:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem sortableOption" data-option="AniDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption" data-option="TMDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + </div> + <div class="fieldDescription">The metadata providers to use as the source of content ratings, in priority order.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="ProductionLocationOverride" /> + <span>Override production location sources</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for production location source selection. + </div> + </div> + <div id="ProductionLocationList" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced production location sources:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem sortableOption" data-option="AniDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + </button> + </div> + <div class="listItem sortableOption" data-option="TMDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</h3> + </div> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + </button> + </div> + </div> + <div class="fieldDescription">The metadata providers to use as the source of production locations, in priority order.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> + <span>Add AniDB IDs</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the AniDB ID for all supported item types where an ID is available.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddTMDBId" /> + <span>Add TMDB IDs</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the TMDB ID for all supported item types where an ID is available.</div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="LibrarySection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Library Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding"> + Placeholder description. + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="UseGroupsForShows" /> + <span>Use shoko groups for shows</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>This will use Shoko's group feature to group together AniDB anime entries into show entries.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">Pre-requirements for use.</summary> + To make the most out of this feature you first need to configure your grouping in Shoko Server. You can + either enable auto-grouping in the settings, or manually craft your own grouping structure, or a + combination of the two where new series gets automatically assigned to a fitting group and you can + override the placement if you feel it should belong elsewhere instead. For more information look up the + <a href="https://docs.shokoanime.com/server/management">Shoko docs</a> on how to manage your groups. + </details> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">Can I use this with collections?</summary> + Yes! You can use this with collections enabled, but that entails that you've configured a multi-layered + structure for your groups. Because the first group layer will be used for the shows, except if 1) the group + contains both movies and shows, and 2) you've separated the movies from the shows. In that case the + then the first layer of groups will also be used to generate a collection for your movie(s) and show + within the first layer. Also, the auto-grouping only acts on a single layer, and you need to use Shoko + Desktop (or in the future, the Web UI) to create your nested structure. For more information look up the + <a href="https://docs.shokoanime.com/server/management">Shoko docs</a> on how to manage your groups. + </details> + </div> + </div> + <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription" hidden> + <label class="selectLabel" for="SeasonOrdering">Season ordering:</label> + <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> + <option value="Default" selected>Let Shoko decide</option> + <option value="ReleaseDate">Order seasons by release date</option> + <option value="Chronological">Order seasons in chronological order (use indirect relations)</option> + <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations)</option> + </select> + <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SeparateMovies" /> + <span>Separate movies from shows</span> + </label> + <div class="fieldDescription checkboxFieldDescription">This filters out movies from the shows in your library. Disable this if you want your movies to show up as episodes within seasons of your shows instead.</div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="SpecialsPlacement">Specials placement within seasons:</label> + <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> + <option value="AfterSeason">Always place specials after the normal episodes (Default)</option> + <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> + <option value="InBetweenSeasonByOtherData">Loosely use the TvDB/TMDB data available in Shoko to place specials</option> + <option value="InBetweenSeasonMixed">Either loosely use the TvDB/TMDB data available in Shoko or fallback to using release dates to place specials</option> + <option value="Excluded">Exclude specials from the seasons</option> + </select> + <div class="fieldDescription selectFieldDescription">Determines how specials are placed within seasons. <strong>Warning:</strong> Modifying this setting requires a recreation (read as; delete existing then create a new) of any libraries using this plugin — otherwise you <strong>will</strong> have mixed metadata.</div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="CollectionGrouping">Collections:</label> + <select is="emby-select" id="CollectionGrouping" name="CollectionGrouping" class="emby-select-withcolor emby-select"> + <option value="None" selected>Do not create collections</option> + <option value="Movies">Create collections for movies based upon Shoko's series</option> + <option value="Shared">Create collections for movies and shows based upon Shoko's groups and series</option> + </select> + <div class="fieldDescription"> + <div>Determines what entities to group into collections.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">Custom CSS for the collections</summary> + Here's some optional custom CSS you can add to "rename" the sections in the collections to + better align with what you'd expect them to be named. If you want it in another language then + you need to translate it yourself and replace it in the CSS before setting it in your server. + <pre> +.collectionItems .verticalSection:has(div[data-type="Movie"]) .sectionTitle.sectionTitle-cards > span { + visibility: hidden; +} +.collectionItems .verticalSection:has(div[data-type="Movie"]) .sectionTitle.sectionTitle-cards > span::before { + visibility: initial; + content: "Movies"; +} +.collectionItems .verticalSection:has(div[data-type="BoxSet"]) .sectionTitle.sectionTitle-cards > span { + visibility: hidden; +} +.collectionItems .verticalSection:has(div[data-type="BoxSet"]) .sectionTitle.sectionTitle-cards > span::before { + visibility: initial; + content: "Collections"; +} +</pre> + </details> + </div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="CollectionMinSizeOfTwo" /> + <span>Require two entries for a collection</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add a minimum requirement of two entries with the same collection id before creating a collection for them.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> + <span>Force movie special features</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special features for the movies. By default only some specials will be automatically recognized as special features, but by enabling this option you will force all specials to be used as special features. This setting applies to movie series across all library types, and may break some movie series in a show type library unless appropriate measures are taken.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddTrailers" /> + <span>Add trailers</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add trailers to entities within the VFS. Trailers within the trailers directory when not using the VFS are not affected by this option.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddCreditsAsThemeVideos" /> + <span>Add credits as theme videos</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add all credits as theme videos to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddCreditsAsSpecialFeatures" /> + <span>Add credits as special features</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add all credits as special features to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> + <span>Add Missing Episodes/Seasons</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add the metadata for missing episodes/seasons not in the local collection.</div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="MediaFolderSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Media Folder Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding"> + Placeholder description. + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> + <div class="fieldDescription">A comma separated list of folder names which will be ignored during library filtering.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_AddReleaseGroup" /> + <span>Add Release Group to VFS entries</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the full or short release group name to all automatically linked files in the VFS, and "No Group" for all manually linked files in the VFS. <strong>Warning</strong>: The release group in the file name may change if the release group info is incomplete, unavailable, or otherwise updated in Shoko at a later date, and thus may cause episode/movie entries to be "removed" and "added" as new entries when that happens. <strong>Use at your own risk.</strong></div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_AddResolution" /> + <span>Add Resolution to VFS entries</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the standardized resolution (e.g. 480p, 1080p, 4K, etc.) to all files in the VFS, <strong>IF</strong> it's available. <strong>Warning</strong>: Though rare, we may have failed to read the media info in Shoko when the files were first added (e.g. because of a corrupt file, encountering an unsupported <i>new</i> codec, etc.), then reading it later. This may lead to episode/movie entries to be "removed" and "added" as new entries the next time they are refreshed after the metadata has been added. <strong>Use at your own risk.</strong></div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="MediaFolderSelector">Configure settings for:</label> + <select is="emby-select" id="MediaFolderSelector" name="MediaFolderSelector" value="" class="emby-select-withcolor emby-select"> + <option value="">Default settings for new media folders</option> + </select> + <div class="fieldDescription selectFieldDescription">Select a media folder to add or modify the media folder settings for.</div> + </div> + <div id="MediaFolderDefaultSettingsContainer"> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_Enabled" /> + <span>Virtual File System™</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enables the use of the Virtual File System™ for any new media libraries managed by the plugin.</div> + <div id="WindowsSymLinkWarning1" hidden><strong>Warning</strong>: Windows users are required to <a href="https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging#use-regedit-to-enable-your-device" target="_blank" rel="noopener noreferrer">enable Developer Mode</a> then restart Jellyfin to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does this mean?</summary> + Enabling this setting should in theory make it so you won't have to think about file structure incompatibilities, since it will override your existing file structure and replace it with a layer of symbolic links managed by the plugin instead of your actual file structure. +   + <strong>Do not enable this for an existing library. If you do, then delete and re-create your library from scratch.</strong> + </details> + </div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="LibraryFilteringMode">Library Filtering:</label> + <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select"> + <option value="Auto">Auto</option> + <option value="Strict" selected>Strict</option> + <option value="Lax" selected>Lax</option> + </select> + <div class="fieldDescription"> + <div>Choose how the plugin filters out videos in your new libraries. This option only applies if the VFS is not used.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does auto filtering entail?</summary> + Auto filtering means the plugin will only filter out unrecognized videos if no other metadata provider is enabled, otherwise it will leave them in the library. + </details> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does strict filtering entail?</summary> + Strict filtering means the plugin will filter out any and all unrecognized videos from the library. + </details> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does lax filtering entail?</summary> + Lax filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. + </details> + </div> + </div> + </div> + <div id="MediaFolderPerFolderSettingsContainer" hidden> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="MediaFolderImportFolderName" label="Mapped Import Folder:" disabled readonly value="-" /> + <div class="fieldDescription">The Shoko Import Folder the Media Folder is mapped to.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MediaFolderVirtualFileSystem" /> + <span>Virtual File System™</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enables the use of the Virtual File System™ for the library.</div> + <div id="WindowsSymLinkWarning2" hidden><strong>Warning</strong>: Windows users are required to <a href="https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging#use-regedit-to-enable-your-device" target="_blank" rel="noopener noreferrer">enable Developer Mode</a> then restart Jellyfin to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does this mean?</summary> + Enabling this setting should in theory make it so you won't have to think about file structure incompatibilities, since it will override your existing file structure and replace it with a layer of symbolic links managed by the plugin instead of your actual file structure. +   + <strong>Do not enable this for an existing library. If you do, then delete and re-create your library from scratch.</strong> + </details> + </div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="MediaFolderLibraryFilteringMode">Library Filtering:</label> + <select is="emby-select" id="MediaFolderLibraryFilteringMode" name="MediaFolderLibraryFilteringMode" class="emby-select-withcolor emby-select"> + <option value="Auto">Auto</option> + <option value="Strict" selected>Strict</option> + <option value="Lax" selected>Lax</option> + </select> + <div class="fieldDescription"> + <div>Choose how the plugin filters out videos in your new libraries. This option only applies if the VFS is not used.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does auto filtering entail?</summary> + Auto filtering means the plugin will only filter out unrecognized videos if no other metadata provider is enabled, otherwise it will leave them in the library. + </details> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does strict filtering entail?</summary> + Strict filtering means the plugin will filter out any and all unrecognized videos from the library. + </details> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does lax filtering entail?</summary> + Lax filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. + </details> + </div> + </div> + </div> + <div id="MediaFolderDeleteContainer" class="inputContainer inputContainer-withDescription" hidden> + <button is="emby-button" type="submit" name="remove-media-folder" class="raised button-delete block emby-button"> + <span>Delete</span> + </button> + <div class="fieldDescription">This will delete the saved settings and reset the mapping for the media folder.</div> + </div> + <button is="emby-button" type="submit" name="media-folder-settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="SignalRSection1" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>SignalR Connection</h3> + </legend> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="SignalRStatus" label="Status:" disabled readonly value="Inactive"> + <div class="fieldDescription">SignalR connection status.</div> + </div> + <div id="SignalRConnectContainer" hidden> + <button id="SignalRConnectButton" is="emby-button" type="submit" name="signalr-connect" class="raised button-submit block emby-button" disabled> + <span>Connect</span> + </button> + <div class="fieldDescription">Establish a SignalR connection to Shoko Server.</div> + </div> + <div id="SignalRDisconnectContainer"> + <button is="emby-button" type="submit" name="signalr-disconnect" class="raised block emby-button"> + <span>Disconnect</span> + </button> + <div class="fieldDescription">Terminate the SignalR connection to Shoko Server.</div> + </div> + </fieldset> + <fieldset id="SignalRSection2" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>SignalR Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRAutoConnect" /> + <span>Auto Connect On Start</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Automatically establish a SignalR connection to Shoko Server when Jellyfin starts. + </div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="SignalRAutoReconnectIntervals" label="Auto Reconnect Intervals:" /> + <div class="fieldDescription">A comma separated list of intervals in seconds to try re-establish the connection if the plugin gets disconnected from Shoko Server.</div> + </div> + <div id="SignalREventSources" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">SignalR Event Sources</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Shoko"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Shoko</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</h3> + </div> + </div> + </div> + <div class="fieldDescription">Which event sources should be listened to via the SignalR connection.</div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="SignalRMediaFolderSelector">Configure settings for:</label> + <select is="emby-select" id="SignalRMediaFolderSelector" name="SignalRMediaFolderSelector" value="" class="emby-select-withcolor emby-select"> + <option value="">Default settings for new media folders</option> + </select> + <div class="fieldDescription selectFieldDescription">Select a media folder to add or modify the SignalR settings for.</div> + </div> + <div id="SignalRMediaFolderDefaultSettingsContainer"> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRDefaultFileEvents" /> + <span>File Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enable the SignalR file events for any new media folders.</div> + </div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRDefaultRefreshEvents" /> + <span>Metadata Update Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enable the SignalR metadata update events for any new media folders.</div> + </div> + </div> + </div> + <div id="SignalRMediaFolderPerFolderSettingsContainer" hidden> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="SignalRMediaFolderImportFolderName" label="Mapped Import Folder:" disabled readonly value="-" /> + <div class="fieldDescription">The Shoko Import Folder the Media Folder is mapped to.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRFileEvents" /> + <span>File Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enable the SignalR file events for the media folder.</div> + </div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRRefreshEvents" /> + <span>Refresh Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enable the SignalR metadata update events for the media folder.</div> + </div> + </div> + </div> + <button is="emby-button" type="submit" name="signalr-settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="UserSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>User Settings</h3> + </legend> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="UserSelector">Configure settings for:</label> + <select is="emby-select" id="UserSelector" name="UserSelector" value="" class="emby-select-withcolor emby-select"> + <option value="">Click here to select a user</option> + </select> + <div class="fieldDescription selectFieldDescription">Select a user to add, modify or delete the user settings for.</div> + </div> + <div id="UserSettingsContainer" hidden> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="UserEnableSynchronization" /> + <span>Enable synchronization features</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting is mandatory to enable the sync features below. Select the sync feature you would like to use by enabling the checkbox.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SyncUserDataOnImport" /> + <span>Sync watch-state on import or refresh</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will import the watch-state for items from Shoko on import or refresh.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SyncUserDataAfterPlayback" /> + <span>Sync watch-state after playback</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko after playback has ended.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SyncUserDataUnderPlayback" /> + <span>Sync watch-state events during playback</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko on every play/pause/resume/stop event during playback.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SyncUserDataUnderPlaybackLive" /> + <span>Sync watch-state live during playback</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko live during playback.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SyncUserDataInitialSkipEventCount" /> + <span>Lazy sync watch-state events with shoko</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will add a safe buffer of 10 seconds of playback before the plugin will start the sync-back to shoko. This will prevent accidental clicks and/or previews from marking the file as watched in shoko, and will also keep them more in sync with jellyfin, since it's closer to how Jellyfin handles the watch-state internally.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SyncRestrictedVideos" /> + <span>Sync watch-state for restricted videos</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko for restricted videos (H).</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="UserUsername" label="Username:" /> + <div class="fieldDescription">The username of the account to synchronize with the currently selected user.</div> + </div> + <div id="UserPasswordContainer" class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="password" id="UserPassword" label="Password:" /> + <div class="fieldDescription">The password for account. It can be empty.</div> + </div> + <div id="UserDeleteContainer" class="inputContainer inputContainer-withDescription" hidden> + <button is="emby-button" type="submit" name="unlink-user" class="raised button-delete block emby-button"> + <span>Delete</span> + </button> + <div class="fieldDescription">This will delete any saved settings for the user.</div> + </div> + <button is="emby-button" type="submit" name="user-settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </div> + </fieldset> + <fieldset id="ExperimentalSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Experimental Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding">Any features/settings in this section is still considered to be in an experimental state. <strong>You can enable them, but at the risk if them messing up your library.</strong></div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_AutoMergeVersions" /> + <span>Automatically merge multiple versions</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Automatically merge multiple versions of the same item together after a library scan. Only applies to items with a Shoko ID set.</div></div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_SplitThenMergeMovies" /> + <span>Always split existing versions of movies before merging multiple versions</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Always split existing merged items before starting the merge.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will make sure there will be no merge conflicts by splitting up all the existing merged versions. It will probably also make the plugin do a lot of unneeded work 99.5% of the time, but will help for the last 0.5% that actually need this to be done before every merge. An example is ensuring multiple parts of a movie series (e.g. "Part 1", "Part 2", etc.) are not merged in core Jellyfin because they contain the same Shoko Series Id.</details></div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_SplitThenMergeEpisodes" /> + <span>Always split existing versions of episodes before merging multiple versions</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Always split existing merged items before starting the merge.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will make sure there will be no merge conflicts by splitting up all the existing merged versions. It will probably also make the plugin do a lot of unneeded work 99.5% of the time, but will help for the last 0.5% that actually need this to be done before every merge.</details></div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> + <span>Automatically merge seasons</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Blur the boundaries between AniDB anime further by merging entries which could had just been a single anime entry based on name matching and a configurable merge window.</div></div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + </div> + </form> + </div> + </div> +</div> diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs new file mode 100644 index 00000000..3c735544 --- /dev/null +++ b/Shokofin/Events/EventDispatchService.cs @@ -0,0 +1,715 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.Configuration; +using Shokofin.Events.Interfaces; +using Shokofin.ExternalIds; +using Shokofin.Resolvers; +using Shokofin.Resolvers.Models; +using Shokofin.Utils; + +using File = System.IO.File; +using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; +using ImageType = MediaBrowser.Model.Entities.ImageType; +using LibraryOptions = MediaBrowser.Model.Configuration.LibraryOptions; +using MetadataRefreshMode = MediaBrowser.Controller.Providers.MetadataRefreshMode; +using Timer = System.Timers.Timer; + +namespace Shokofin.Events; + +public class EventDispatchService +{ + private readonly ShokoAPIManager ApiManager; + + private readonly ShokoAPIClient ApiClient; + + private readonly ILibraryManager LibraryManager; + + private readonly ILibraryMonitor LibraryMonitor; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + private readonly MediaFolderConfigurationService ConfigurationService; + + private readonly VirtualFileSystemService ResolveManager; + + private readonly IFileSystem FileSystem; + + private readonly IDirectoryService DirectoryService; + + private readonly ILogger<EventDispatchService> Logger; + + private int ChangesDetectionSubmitterCount = 0; + + private readonly Timer ChangesDetectionTimer; + + private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List, Guid trackerId)> ChangesPerSeries = new(); + + private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List, Guid trackerId)> ChangesPerFile = new(); + + private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = new(); + + // It's so magical that it matches the magical value in the library monitor in JF core. 🪄 + private const int MagicalDelayValue = 45000; + + private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); + + public EventDispatchService( + ShokoAPIManager apiManager, + ShokoAPIClient apiClient, + ILibraryManager libraryManager, + VirtualFileSystemService resolveManager, + MediaFolderConfigurationService configurationService, + ILibraryMonitor libraryMonitor, + LibraryScanWatcher libraryScanWatcher, + IFileSystem fileSystem, + IDirectoryService directoryService, + ILogger<EventDispatchService> logger + ) + { + ApiManager = apiManager; + ApiClient = apiClient; + LibraryManager = libraryManager; + LibraryMonitor = libraryMonitor; + ResolveManager = resolveManager; + ConfigurationService = configurationService; + LibraryScanWatcher = libraryScanWatcher; + FileSystem = fileSystem; + DirectoryService = directoryService; + Logger = logger; + ChangesDetectionTimer = new() { AutoReset = true, Interval = TimeSpan.FromSeconds(4).TotalMilliseconds }; + ChangesDetectionTimer.Elapsed += OnIntervalElapsed; + } + + ~EventDispatchService() + { + + ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; + } + + #region Event Detection + + public IDisposable RegisterEventSubmitter() + { + var count = ChangesDetectionSubmitterCount++; + if (count is 0) + ChangesDetectionTimer.Start(); + + return new DisposableAction(() => DeregisterEventSubmitter()); + } + + private void DeregisterEventSubmitter() + { + var count = --ChangesDetectionSubmitterCount; + if (count is 0) { + ChangesDetectionTimer.Stop(); + if (ChangesPerFile.Count > 0) + ClearFileEvents(); + if (ChangesPerSeries.Count > 0) + ClearMetadataUpdatedEvents(); + } + } + + private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>, Guid trackerId)>(); + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>, Guid trackerId)>(); + lock (ChangesPerFile) { + if (ChangesPerFile.Count > 0) { + var now = DateTime.Now; + foreach (var (fileId, (lastUpdated, list, trackerId)) in ChangesPerFile) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + filesToProcess.Add((fileId, list, trackerId)); + } + foreach (var (fileId, _, _) in filesToProcess) + ChangesPerFile.Remove(fileId); + } + } + lock (ChangesPerSeries) { + if (ChangesPerSeries.Count > 0) { + var now = DateTime.Now; + foreach (var (metadataId, (lastUpdated, list, trackerId)) in ChangesPerSeries) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + seriesToProcess.Add((metadataId, list, trackerId)); + } + foreach (var (metadataId, _, _) in seriesToProcess) + ChangesPerSeries.Remove(metadataId); + } + } + foreach (var (fileId, changes, trackerId) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes, trackerId)); + foreach (var (metadataId, changes, trackerId) in seriesToProcess) + Task.Run(() => ProcessMetadataEvents(metadataId, changes, trackerId)); + } + + private void ClearFileEvents() + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>, Guid trackerId)>(); + lock (ChangesPerFile) { + foreach (var (fileId, (lastUpdated, list, trackerId)) in ChangesPerFile) { + filesToProcess.Add((fileId, list, trackerId)); + } + ChangesPerFile.Clear(); + } + foreach (var (fileId, changes, trackerId) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes, trackerId)); + } + + private void ClearMetadataUpdatedEvents() + { + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>, Guid trackerId)>(); + lock (ChangesPerSeries) { + foreach (var (metadataId, (lastUpdated, list, trackerId)) in ChangesPerSeries) { + seriesToProcess.Add((metadataId, list, trackerId)); + } + ChangesPerSeries.Clear(); + } + foreach (var (metadataId, changes, trackerId) in seriesToProcess) + Task.Run(() => ProcessMetadataEvents(metadataId, changes, trackerId)); + } + + #endregion + + #region File Events + + public void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, string filePath, IFileEventArgs eventArgs) + { + lock (ChangesPerFile) { + if (ChangesPerFile.TryGetValue(fileId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new(), Plugin.Instance.Tracker.Add($"File event. (Reason=\"{reason}\",ImportFolder={eventArgs.ImportFolderId},RelativePath=\"{eventArgs.RelativePath}\")"))); + tuple.List.Add((reason, importFolderId, filePath, eventArgs)); + } + } + + private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes, Guid trackerId) + { + try { + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogInformation("Skipped processing {EventCount} file change events because a library scan is running. (File={FileId})", changes.Count, fileId); + return; + } + + Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); + + // Something was added or updated. + var locationsToNotify = new List<string>(); + var mediaFoldersToNotify = new Dictionary<string, (string pathToReport, Folder mediaFolder)>(); + var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); + var libraries = ConfigurationService.GetAvailableMediaFoldersForLibraries(c => c.IsFileEventsEnabled); + var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); + if (reason is not UpdateReason.Removed) { + Logger.LogTrace("Processing file changed. (File={FileId})", fileId); + foreach (var (vfsPath, mainMediaFolderPath, collectionType, mediaConfigs) in libraries) { + foreach (var (importFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToImportFolderList(importFolderId, relativePath)) { + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, relativePath[importFolderSubPath.Length..]); + if (!File.Exists(sourceLocation)) + continue; + + // Let the core logic handle the rest. + if (!vfsEnabled) { + locationsToNotify.Add(sourceLocation); + break; + } + + var result = new LinkGenerationResult(); + var topFolders = new HashSet<string>(); + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(collectionType, vfsPath, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, importDate!.Value); + foreach (var path in symLinks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) + topFolders.Add(path); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = mediaConfigs.Select(c => c.MediaFolderId).ToArray(), + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ); + Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); + foreach (var video in videos) { + if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { + Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + continue; + } + Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + RemoveSymbolicLink(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolderPath); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { + locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mainMediaFolderPath, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + mediaFoldersToNotify.TryAdd(mainMediaFolderPath, (fileOrFolder, mainMediaFolderPath.GetFolderForPath())); + } + break; + } + } + } + } + // Something was removed, so assume the location is gone. + else if (changes.FirstOrDefault(t => t.Reason is UpdateReason.Removed).Event is IFileEventArgs firstRemovedEvent) { + Logger.LogTrace("Processing file removed. (File={FileId})", fileId); + relativePath = firstRemovedEvent.RelativePath; + importFolderId = firstRemovedEvent.ImportFolderId; + foreach (var (vfsPath, mainMediaFolderPath, collectionType, mediaConfigs) in libraries) { + foreach (var (importFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToImportFolderList(importFolderId, relativePath)) { + foreach (var mediaFolderPath in mediaFolderPaths) { + // Let the core logic handle the rest. + if (!vfsEnabled) { + var sourceLocation = Path.Join(mediaFolderPath, relativePath[importFolderSubPath.Length..]); + locationsToNotify.Add(sourceLocation); + break; + } + + // Check if we can use another location for the file. + var result = new LinkGenerationResult(); + var vfsSymbolicLinks = new HashSet<string>(); + var topFolders = new HashSet<string>(); + var newSourceLocation = await GetNewSourceLocation(importFolderId, importFolderSubPath, fileId, relativePath, mediaFolderPath); + if (!string.IsNullOrEmpty(newSourceLocation)) { + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(collectionType, vfsPath, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, importDate!.Value); + foreach (var path in symLinks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) + topFolders.Add(path); + } + vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ); + Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); + foreach (var video in videos) { + if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { + Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + continue; + } + Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + RemoveSymbolicLink(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolderPath); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { + locationsToNotify.AddRange(vfsSymbolicLinks); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mainMediaFolderPath, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + mediaFoldersToNotify.TryAdd(mainMediaFolderPath, (fileOrFolder, mainMediaFolderPath.GetFolderForPath())); + } + break; + } + } + } + } + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogDebug("Skipped notifying Jellyfin about {LocationCount} changes because a library scan is running. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + return; + } + + // We let jellyfin take it from here. + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count + mediaFoldersToNotify.Count, fileId.ToString()); + foreach (var location in locationsToNotify) + LibraryMonitor.ReportFileSystemChanged(location); + if (mediaFoldersToNotify.Count > 0) + await Task.WhenAll(mediaFoldersToNotify.Values.Select(tuple => ReportMediaFolderChanged(tuple.mediaFolder, tuple.pathToReport))).ConfigureAwait(false); + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) + { + HashSet<string> seriesIds; + if (fileEvent is not null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) { + seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) + .Distinct() + .ToHashSet(); + } + else { + try { + var file = await ApiClient.GetFile(fileId.ToString()); + seriesIds = file.CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .Distinct() + .ToHashSet(); + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return new HashSet<string>(); + } + } + + // TODO: Postpone the processing of the file if the episode or series is not available yet. + + var filteredSeriesIds = new HashSet<string>(); + foreach (var seriesId in seriesIds) { + var (primaryId, extraIds) = await ApiManager.GetSeriesIdsForSeason(seriesId); + var seriesPathSet = await ApiManager.GetPathSetForSeries(primaryId, extraIds); + if (seriesPathSet.Count > 0) { + filteredSeriesIds.Add(seriesId); + } + } + + // Return all series if we only have this file for all of them, + // otherwise return only the series were we have other files that are + // not linked to other series. + return filteredSeriesIds.Count is 0 ? seriesIds : filteredSeriesIds; + } + + private async Task<string?> GetNewSourceLocation(int importFolderId, string importFolderSubPath, int fileId, string relativePath, string mediaFolderPath) + { + // Check if the file still exists, and if it has any other locations we can use. + try { + var file = await ApiClient.GetFile(fileId.ToString()); + var usableLocation = file.Locations + .Where(loc => loc.ImportFolderId == importFolderId && (string.IsNullOrEmpty(importFolderSubPath) || relativePath.StartsWith(importFolderSubPath + Path.DirectorySeparatorChar)) && loc.RelativePath != relativePath) + .FirstOrDefault(); + if (usableLocation is null) + return null; + + var sourceLocation = Path.Join(mediaFolderPath, usableLocation.RelativePath[importFolderSubPath.Length..]); + if (!File.Exists(sourceLocation)) + return null; + + return sourceLocation; + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return null; + } + } + + private void RemoveSymbolicLink(string filePath) + { + // TODO: If this works better, the move it to an utility and also use it in the VFS if needed, or remove this comment if it's not needed. + try { + var fileExists = File.Exists(filePath); + var fileInfo = new System.IO.FileInfo(filePath); + var fileInfoExists = fileInfo.Exists; + var reparseFlag = fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint); + Logger.LogTrace( + "Result for if file is a reparse point; {FilePath} (Exists1={FileExists},Exists2={FileInfoExists},ReparsePoint={IsReparsePoint},Attributes={AllAttributes})", + filePath, + fileExists, + fileInfoExists, + reparseFlag, + fileInfo.Attributes + ); + + try { + File.Delete(filePath); + } + catch (Exception ex) { + Logger.LogError(ex, "Unable to remove symbolic link at path {Path}; {ErrorMessage}", filePath, ex.Message); + } + } + catch (Exception ex) { + Logger.LogTrace(ex, "Unable to check if file path exists and is a reparse point; {FilePath}", filePath); + } + } + + private async Task ReportMediaFolderChanged(Folder mediaFolder, string pathToReport) + { + if (LibraryManager.GetLibraryOptions(mediaFolder) is not LibraryOptions libraryOptions || !libraryOptions.EnableRealtimeMonitor) { + LibraryMonitor.ReportFileSystemChanged(pathToReport); + return; + } + + // Since we're blocking real-time file events on the media folder because + // it uses the VFS then we need to temporarily unblock it, then block it + // afterwards again. + var path = mediaFolder.Path; + var delayTime = TimeSpan.Zero; + lock (MediaFolderChangeMonitor) { + if (MediaFolderChangeMonitor.TryGetValue(path, out var entry)) { + MediaFolderChangeMonitor[path] = (entry.refCount + 1, entry.delayEnd); + delayTime = entry.delayEnd - DateTime.Now; + } + else { + MediaFolderChangeMonitor[path] = (1, DateTime.Now + TimeSpan.FromMilliseconds(MagicalDelayValue)); + delayTime = TimeSpan.FromMilliseconds(MagicalDelayValue); + } + } + + LibraryMonitor.ReportFileSystemChangeComplete(path, false); + + if (delayTime > TimeSpan.Zero) + await Task.Delay((int)delayTime.TotalMilliseconds).ConfigureAwait(false); + + LibraryMonitor.ReportFileSystemChanged(pathToReport); + + var shouldResume = false; + lock (MediaFolderChangeMonitor) { + if (MediaFolderChangeMonitor.TryGetValue(path, out var tuple)) { + if (tuple.refCount is 1) { + shouldResume = true; + MediaFolderChangeMonitor.Remove(path); + } + else { + MediaFolderChangeMonitor[path] = (tuple.refCount - 1, tuple.delayEnd); + } + } + } + + if (shouldResume) + LibraryMonitor.ReportFileSystemChangeBeginning(path); + } + + #endregion + + #region Refresh Events + + public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArgs) + { + lock (ChangesPerSeries) { + if (ChangesPerSeries.TryGetValue(metadataId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new(), Plugin.Instance.Tracker.Add($"Metadata event. (Reason=\"{eventArgs.Reason}\",Kind=\"{eventArgs.Kind}\",ProviderUId=\"{eventArgs.ProviderUId}\")"))); + tuple.List.Add(eventArgs); + } + } + + private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes, Guid trackerId) + { + try { + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogDebug("Skipped processing {EventCount} metadata change events because a library scan is running. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + return; + } + + if (!changes.Any(e => e.Kind is BaseItemKind.Episode && e.EpisodeId.HasValue || e.Kind is BaseItemKind.Series && e.SeriesId.HasValue)) { + Logger.LogDebug("Skipped processing {EventCount} metadata change events because no series or episode ids to use. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + return; + } + + var seriesId = changes.First(e => e.SeriesId.HasValue).SeriesId!.Value.ToString(); + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo is null) { + Logger.LogDebug("Unable to find show info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); + return; + } + + var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (seasonInfo is null) { + Logger.LogDebug("Unable to find season info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); + return; + } + + Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + + var updateCount = await ProcessSeriesEvents(showInfo, changes); + updateCount += await ProcessMovieEvents(seasonInfo, changes); + + Logger.LogInformation("Scheduled {UpdateCount} updates for {EventCount} metadata change events. (Metadata={ProviderUniqueId})", updateCount, changes.Count, metadataId); + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes) + { + // Update the series if we got a series event _or_ an episode removed event. + var updateCount = 0; + var animeEvent = changes.Find(e => e.Kind is BaseItemKind.Series || e.Kind is BaseItemKind.Episode && e.Reason is UpdateReason.Removed); + if (animeEvent is not null) { + var shows = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Series }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, showInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var show in shows) { + Logger.LogInformation("Refreshing show {ShowName}. (Show={ShowId},Series={SeriesId})", show.Name, show.Id, showInfo.Id); + await show.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + // Otherwise update all season/episodes where appropriate. + else { + var episodeIds = changes + .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) + .Select(e => e.EpisodeId!.Value.ToString()) + .ToHashSet(); + var seasonIds = changes + .Where(e => e.EpisodeId.HasValue && e.SeriesId.HasValue && e.Reason is UpdateReason.Removed) + .Select(e => e.SeriesId!.Value.ToString()) + .ToHashSet(); + var seasonList = showInfo.SeasonList + .Where(seasonInfo => seasonIds.Contains(seasonInfo.Id) || seasonIds.Overlaps(seasonInfo.ExtraIds)) + .ToList(); + foreach (var seasonInfo in seasonList) { + var seasons = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Season }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, seasonInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var season in seasons) { + Logger.LogInformation("Refreshing season {SeasonName}. (Season={SeasonId},Series={SeriesId},ExtraSeries={ExtraIds})", season.Name, season.Id, seasonInfo.Id, seasonInfo.ExtraIds); + await season.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + var episodeList = showInfo.SeasonList + .Except(seasonList) + .SelectMany(seasonInfo => seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.SpecialsList)) + .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) + .ToList(); + foreach (var episodeInfo in episodeList) { + var episodes = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var episode in episodes) { + Logger.LogInformation("Refreshing episode {EpisodeName}. (Episode={EpisodeId},Episode={EpisodeId},Series={SeriesId})", episode.Name, episode.Id, episodeInfo.Id, episodeInfo.Shoko.IDs.Series.ToString()); + await episode.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + } + return updateCount; + } + + private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadataUpdatedEventArgs> changes) + { + // Find movies and refresh them. + var updateCount = 0; + var episodeIds = changes + .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) + .Select(e => e.EpisodeId!.Value.ToString()) + .ToHashSet(); + var episodeList = seasonInfo.EpisodeList + .Concat(seasonInfo.AlternateEpisodesList) + .Concat(seasonInfo.SpecialsList) + .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) + .ToList(); + foreach (var episodeInfo in episodeList) { + var movies = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var movie in movies) { + Logger.LogInformation("Refreshing movie {MovieName}. (Movie={MovieId},Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds})", movie.Name, movie.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); + await movie.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + return updateCount; + } + + #endregion +} \ No newline at end of file diff --git a/Shokofin/Events/Interfaces/IFileEventArgs.cs b/Shokofin/Events/Interfaces/IFileEventArgs.cs new file mode 100644 index 00000000..a37e79f3 --- /dev/null +++ b/Shokofin/Events/Interfaces/IFileEventArgs.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Shokofin.Events.Interfaces; + +public interface IFileEventArgs +{ + /// <summary> + /// Shoko file id. + /// </summary> + int FileId { get; } + + /// <summary> + /// Shoko file location id, if available. + /// </summary> + int? FileLocationId { get; } + + /// <summary> + /// The ID of the new import folder the event was detected in. + /// </summary> + /// <value></value> + int ImportFolderId { get; } + + /// <summary> + /// The relative path from the base of the <see cref="ImportFolder"/> to + /// where the <see cref="File"/> lies, with a leading slash applied at + /// the start and normalized for the local system. + /// </summary> + string RelativePath { get; } + + /// <summary> + /// Indicates that the event has cross references provided. They may still + /// be empty, but now we don't need to fetch them separately. + /// </summary> + bool HasCrossReferences { get; } + + /// <summary> + /// Cross references of episodes linked to this file. + /// </summary> + List<FileCrossReference> CrossReferences { get; } + + public class FileCrossReference + { + /// <summary> + /// AniDB episode id. + /// </summary> + [JsonPropertyName("AnidbEpisodeID")] + public int AnidbEpisodeId { get; set; } + + /// <summary> + /// AniDB anime id. + /// </summary> + [JsonPropertyName("AnidbAnimeID")] + public int AnidbAnimeId { get; set; } + + /// <summary> + /// Shoko episode id. + /// </summary> + [JsonPropertyName("EpisodeID")] + public int? ShokoEpisodeId { get; set; } + + /// <summary> + /// Shoko series id. + /// </summary> + [JsonPropertyName("SeriesID")] + public int? ShokoSeriesId { get; set; } + } +} \ No newline at end of file diff --git a/Shokofin/Events/Interfaces/IFileRelocationEventArgs.cs b/Shokofin/Events/Interfaces/IFileRelocationEventArgs.cs new file mode 100644 index 00000000..57647978 --- /dev/null +++ b/Shokofin/Events/Interfaces/IFileRelocationEventArgs.cs @@ -0,0 +1,19 @@ + +namespace Shokofin.Events.Interfaces; + +public interface IFileRelocationEventArgs : IFileEventArgs +{ + + /// <summary> + /// The ID of the old import folder the event was detected in. + /// </summary> + /// <value></value> + int PreviousImportFolderId { get; } + + /// <summary> + /// The relative path from the previous base of the + /// <see cref="ImportFolder"/> to where the <see cref="File"/> previously + /// lied, with a leading slash applied at the start. + /// </summary> + string PreviousRelativePath { get; } +} \ No newline at end of file diff --git a/Shokofin/Events/Interfaces/IMetadataUpdatedEventArgs.cs b/Shokofin/Events/Interfaces/IMetadataUpdatedEventArgs.cs new file mode 100644 index 00000000..11957879 --- /dev/null +++ b/Shokofin/Events/Interfaces/IMetadataUpdatedEventArgs.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Data.Enums; + +namespace Shokofin.Events.Interfaces; + +public interface IMetadataUpdatedEventArgs +{ + /// <summary> + /// The update reason. + /// </summary> + UpdateReason Reason { get; } + + /// <summary> + /// The provider metadata type. + /// </summary> + BaseItemKind Kind { get; } + + /// <summary> + /// The provider metadata source. + /// </summary> + ProviderName ProviderName { get; } + + /// <summary> + /// The provided metadata episode id. + /// </summary> + int ProviderId { get; } + + /// <summary> + /// Provider unique id. + /// </summary> + string ProviderUId => $"{ProviderName}:{ProviderId.ToString(CultureInfo.InvariantCulture)}"; + + /// <summary> + /// The provided metadata series id. + /// </summary> + int? ProviderParentId { get; } + + /// <summary> + /// Provider unique parent id. + /// </summary> + string? ProviderParentUId => ProviderParentId.HasValue ? $"{ProviderName}:{ProviderParentId.Value.ToString(CultureInfo.InvariantCulture)}" : null; + + /// <summary> + /// The first shoko episode id affected by this update. + /// </summary> + int? EpisodeId => EpisodeIds.Count > 0 ? EpisodeIds[0] : null; + + /// <summary> + /// Shoko episode ids affected by this update. + /// </summary> + IReadOnlyList<int> EpisodeIds { get; } + + /// <summary> + /// The first shoko series id affected by this update. + /// </summary> + int? SeriesId => SeriesIds.Count > 0 ? SeriesIds[0] : null; + + /// <summary> + /// Shoko series ids affected by this update. + /// </summary> + IReadOnlyList<int> SeriesIds { get; } + + /// <summary> + /// The first shoko group id affected by this update. + /// </summary> + int? GroupId => GroupIds.Count > 0 ? GroupIds[0] : null; + + /// <summary> + /// Shoko group ids affected by this update. + /// </summary> + IReadOnlyList<int> GroupIds { get; } +} \ No newline at end of file diff --git a/Shokofin/Events/Interfaces/ProviderName.cs b/Shokofin/Events/Interfaces/ProviderName.cs new file mode 100644 index 00000000..b9d3e4f1 --- /dev/null +++ b/Shokofin/Events/Interfaces/ProviderName.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Shokofin.Events.Interfaces; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProviderName +{ + None = 0, + Shoko = 1, + AniDB = 2, + TMDB = 3, +} \ No newline at end of file diff --git a/Shokofin/Events/Interfaces/UpdateReason.cs b/Shokofin/Events/Interfaces/UpdateReason.cs new file mode 100644 index 00000000..b3d9431e --- /dev/null +++ b/Shokofin/Events/Interfaces/UpdateReason.cs @@ -0,0 +1,13 @@ + +using System.Text.Json.Serialization; + +namespace Shokofin.Events.Interfaces; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UpdateReason +{ + None = 0, + Added = 1, + Updated = 2, + Removed = 3, +} \ No newline at end of file diff --git a/Shokofin/Events/Stub/FileEventArgsStub.cs b/Shokofin/Events/Stub/FileEventArgsStub.cs new file mode 100644 index 00000000..6211aba0 --- /dev/null +++ b/Shokofin/Events/Stub/FileEventArgsStub.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using Shokofin.API.Models; +using Shokofin.Events.Interfaces; + +namespace Shokofin.Events.Stub; + +public class FileEventArgsStub : IFileEventArgs +{ + /// <inheritdoc/> + public int FileId { get; private init; } + + /// <inheritdoc/> + public int? FileLocationId { get; private init; } + + /// <inheritdoc/> + public int ImportFolderId { get; private init; } + + /// <inheritdoc/> + public string RelativePath { get; private init; } + + /// <inheritdoc/> + public bool HasCrossReferences => true; + + /// <inheritdoc/> + public List<IFileEventArgs.FileCrossReference> CrossReferences { get; private init; } + + public FileEventArgsStub(int fileId, int? fileLocationId, int importFolderId, string relativePath, IEnumerable<IFileEventArgs.FileCrossReference> xrefs) + { + FileId = fileId; + FileLocationId = fileLocationId; + ImportFolderId = importFolderId; + RelativePath = relativePath; + CrossReferences = xrefs.ToList(); + } + + public FileEventArgsStub(File.Location location, File file) + { + FileId = file.Id; + FileLocationId = location.Id; + ImportFolderId = location.ImportFolderId; + RelativePath = location.RelativePath; + CrossReferences = file.CrossReferences + .SelectMany(xref => xref.Episodes.Select(episodeXref => new IFileEventArgs.FileCrossReference() { + AnidbEpisodeId = episodeXref.AniDB, + AnidbAnimeId = xref.Series.AniDB, + ShokoEpisodeId = episodeXref.Shoko, + ShokoSeriesId = xref.Series.Shoko, + })) + .ToList(); + } +} diff --git a/Shokofin/ExternalIds/ShokoCollectionGroupId.cs b/Shokofin/ExternalIds/ShokoCollectionGroupId.cs new file mode 100644 index 00000000..b0ce2096 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoCollectionGroupId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoCollectionGroupId : IExternalId +{ + public const string Name = "ShokoCollectionGroup"; + + public bool Supports(IHasProviderIds item) + => item is BoxSet; + + public string ProviderName + => "Shoko Group"; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/group/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoCollectionSeriesId.cs b/Shokofin/ExternalIds/ShokoCollectionSeriesId.cs new file mode 100644 index 00000000..1a964a7a --- /dev/null +++ b/Shokofin/ExternalIds/ShokoCollectionSeriesId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoCollectionSeriesId : IExternalId +{ + public const string Name = "ShokoCollectionSeries"; + + public bool Supports(IHasProviderIds item) + => item is BoxSet; + + public string ProviderName + => "Shoko Series"; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/series/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoEpisodeId.cs b/Shokofin/ExternalIds/ShokoEpisodeId.cs new file mode 100644 index 00000000..6ab4cffa --- /dev/null +++ b/Shokofin/ExternalIds/ShokoEpisodeId.cs @@ -0,0 +1,27 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoEpisodeId : IExternalId +{ + public const string Name = "Shoko Episode"; + + public bool Supports(IHasProviderIds item) + => item is Episode or Movie; + + public string ProviderName + => Name; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/redirect/episode/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoFileId.cs b/Shokofin/ExternalIds/ShokoFileId.cs new file mode 100644 index 00000000..6f821d3f --- /dev/null +++ b/Shokofin/ExternalIds/ShokoFileId.cs @@ -0,0 +1,27 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoFileId : IExternalId +{ + public const string Name = "Shoko File"; + + public bool Supports(IHasProviderIds item) + => item is Episode or Movie; + + public string ProviderName + => Name; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/redirect/file/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoGroupId.cs b/Shokofin/ExternalIds/ShokoGroupId.cs new file mode 100644 index 00000000..3603e8b5 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoGroupId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoGroupId : IExternalId +{ + public const string Name = "Shoko Group"; + + public bool Supports(IHasProviderIds item) + => item is Series; + + public string ProviderName + => Name; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/group/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoSeriesId.cs b/Shokofin/ExternalIds/ShokoSeriesId.cs new file mode 100644 index 00000000..0e3c4091 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoSeriesId.cs @@ -0,0 +1,27 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoSeriesId : IExternalId +{ + public const string Name = "Shoko Series"; + + public bool Supports(IHasProviderIds item) + => item is Series or Season or Episode or Movie; + + public string ProviderName + => Name; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/series/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/FolderExtensions.cs b/Shokofin/FolderExtensions.cs new file mode 100644 index 00000000..87aae4eb --- /dev/null +++ b/Shokofin/FolderExtensions.cs @@ -0,0 +1,10 @@ +using System.IO; +using MediaBrowser.Controller.Entities; + +namespace Shokofin; + +public static class FolderExtensions +{ + public static string GetVirtualRoot(this Folder mediaFolder) + => Path.Join(Plugin.Instance.VirtualRoot, mediaFolder.Id.ToString()); +} \ No newline at end of file diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs new file mode 100644 index 00000000..0ed4cd97 --- /dev/null +++ b/Shokofin/IdLookup.cs @@ -0,0 +1,294 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Shokofin.API; +using Shokofin.ExternalIds; +using Shokofin.Providers; + +namespace Shokofin; + +public interface IIdLookup +{ + #region Base Item + + /// <summary> + /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. + /// </summary> + /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> to check.</param> + /// <returns>True if the plugin is enabled for the <see cref="MediaBrowser.Controller.Entities.BaseItem" /></returns> + bool IsEnabledForItem(BaseItem item); + + /// <summary> + /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. + /// </summary> + /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> to check.</param> + /// <param name="isSoleProvider">True if the plugin is the only metadata provider enabled for the item.</param> + /// <returns>True if the plugin is enabled for the <see cref="MediaBrowser.Controller.Entities.BaseItem" /></returns> + bool IsEnabledForItem(BaseItem item, out bool isSoleProvider); + + #endregion + #region Series Id + + bool TryGetSeriesIdFor(string path, [NotNullWhen(true)] out string? seriesId); + + bool TryGetSeriesIdFromEpisodeId(string episodeId, [NotNullWhen(true)] out string? seriesId); + + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />. + /// </summary> + /// <param name="series">The <see cref="MediaBrowser.Controller.Entities.TV.Series" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />.</returns> + bool TryGetSeriesIdFor(Series series, [NotNullWhen(true)] out string? seriesId); + + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdFor(Season season, [NotNullWhen(true)] out string? seriesId); + + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdFor(Movie movie, [NotNullWhen(true)] out string? seriesId); + + #endregion + #region Series Path + + bool TryGetPathForSeriesId(string seriesId, [NotNullWhen(true)] out string? path); + + #endregion + #region Episode Id + + bool TryGetEpisodeIdFor(string path, [NotNullWhen(true)] out string? episodeId); + + bool TryGetEpisodeIdFor(BaseItem item, [NotNullWhen(true)] out string? episodeId); + + bool TryGetEpisodeIdsFor(string path, [NotNullWhen(true)] out List<string>? episodeIds); + + bool TryGetEpisodeIdsFor(BaseItem item, [NotNullWhen(true)] out List<string>? episodeIds); + + #endregion + #region Episode Path + + bool TryGetPathForEpisodeId(string episodeId, [NotNullWhen(true)] out string? path); + + #endregion + #region File Id + + bool TryGetFileIdFor(BaseItem item, [NotNullWhen(true)] out string? fileId); + + #endregion +} + +public class IdLookup : IIdLookup +{ + private readonly ShokoAPIManager ApiManager; + + private readonly ILibraryManager LibraryManager; + + public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) + { + ApiManager = apiManager; + LibraryManager = libraryManager; + } + + #region Base Item + + private readonly HashSet<string> AllowedTypes = new() { nameof(Series), nameof(Season), nameof(Episode), nameof(Movie) }; + + public bool IsEnabledForItem(BaseItem item) => + IsEnabledForItem(item, out var _); + + public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) + { + var reItem = item switch { + Series s => s, + Season s => s.Series, + Episode e => e.Series, + _ => item, + }; + if (reItem == null) { + isSoleProvider = false; + return false; + } + + var libraryOptions = LibraryManager.GetLibraryOptions(reItem); + if (libraryOptions == null) { + isSoleProvider = false; + return false; + } + + var isEnabled = false; + isSoleProvider = true; + foreach (var options in libraryOptions.TypeOptions) { + if (!AllowedTypes.Contains(options.Type)) + continue; + var isEnabledForType = options.MetadataFetchers.Contains(Plugin.MetadataProviderName); + if (isEnabledForType) { + if (!isEnabled) + isEnabled = true; + if (options.MetadataFetchers.Length > 1 && isSoleProvider) + isSoleProvider = false; + } + } + return isEnabled; + } + + #endregion + #region Series Id + + public bool TryGetSeriesIdFor(string path, [NotNullWhen(true)] out string? seriesId) + { + if (ApiManager.TryGetSeriesIdForPath(path, out seriesId!)) + return true; + + seriesId = string.Empty; + return false; + } + + public bool TryGetSeriesIdFromEpisodeId(string episodeId, [NotNullWhen(true)] out string? seriesId) + { + if (ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId!)) + return true; + + seriesId = string.Empty; + return false; + } + + public bool TryGetSeriesIdFor(Series series, [NotNullWhen(true)] out string? seriesId) + { + if (series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) + return true; + + if (TryGetSeriesIdFor(series.Path, out seriesId)) { + if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) + SeriesProvider.AddProviderIds(series, defaultSeriesId); + else + SeriesProvider.AddProviderIds(series, seriesId); + // Make sure the presentation unique is not cached, so we won't reuse the cache key. + series.PresentationUniqueKey = null; + return true; + } + + return false; + } + + public bool TryGetSeriesIdFor(Season season, [NotNullWhen(true)] out string? seriesId) + { + if (season.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) + return true; + + return TryGetSeriesIdFor(season.Path, out seriesId); + } + + public bool TryGetSeriesIdFor(Movie movie, [NotNullWhen(true)] out string? seriesId) + { + if (movie.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) + return true; + + if (TryGetEpisodeIdFor(movie.Path, out var episodeId) && TryGetSeriesIdFromEpisodeId(episodeId, out seriesId)) + return true; + + return false; + } + + #endregion + #region Series Path + + public bool TryGetPathForSeriesId(string seriesId, [NotNullWhen(true)] out string? path) + { + if (ApiManager.TryGetSeriesPathForId(seriesId, out path!)) + return true; + + path = string.Empty; + return false; + } + + #endregion + #region Episode Id + + public bool TryGetEpisodeIdFor(string path, [NotNullWhen(true)] out string? episodeId) + { + if (ApiManager.TryGetEpisodeIdForPath(path, out episodeId!)) + return true; + + episodeId = string.Empty; + return false; + } + + public bool TryGetEpisodeIdFor(BaseItem item, [NotNullWhen(true)] out string? episodeId) + { + // This will account for virtual episodes and existing episodes + if (item.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out episodeId!) && !string.IsNullOrEmpty(episodeId)) { + return true; + } + + // This will account for new episodes that haven't received their first metadata update yet. + if (TryGetEpisodeIdFor(item.Path, out episodeId)) { + return true; + } + + return false; + } + + public bool TryGetEpisodeIdsFor(string path, [NotNullWhen(true)] out List<string>? episodeIds) + { + if (ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds!)) + return true; + + episodeIds = new(); + return false; + } + + public bool TryGetEpisodeIdsFor(BaseItem item, [NotNullWhen(true)] out List<string>? episodeIds) + { + // This will account for virtual episodes and existing episodes + if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && item.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, seriesId, out episodeIds!)) + return true; + + // This will account for new episodes that haven't received their first metadata update yet. + if (TryGetEpisodeIdsFor(item.Path, out episodeIds)) + return true; + + return false; + } + + #endregion + #region Episode Path + + public bool TryGetPathForEpisodeId(string episodeId, [NotNullWhen(true)] out string? path) + { + if (ApiManager.TryGetEpisodePathForId(episodeId, out path!)) + return true; + + path = string.Empty; + return false; + } + + #endregion + #region File Id + + public bool TryGetFileIdFor(BaseItem episode, [NotNullWhen(true)] out string? fileId) + { + if (episode.ProviderIds.TryGetValue(ShokoFileId.Name, out fileId!)) + return true; + + if (ApiManager.TryGetFileIdForPath(episode.Path, out fileId!)) + return true; + + fileId = string.Empty; + return false; + } + + #endregion +} \ No newline at end of file diff --git a/Shokofin/ListExtensions.cs b/Shokofin/ListExtensions.cs new file mode 100644 index 00000000..c47a602b --- /dev/null +++ b/Shokofin/ListExtensions.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Shokofin; + +public static class ListExtensions +{ + public static bool TryRemoveAt<T>(this List<T> list, int index, [NotNullWhen(true)] out T? item) + { + if (index < 0 || index >= list.Count) { + item = default; + return false; + } + item = list[index]!; + list.RemoveAt(index); + return true; + } +} \ No newline at end of file diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs new file mode 100644 index 00000000..f2d46d62 --- /dev/null +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Shokofin.ExternalIds; + +namespace Shokofin.MergeVersions; + +/// <summary> +/// Responsible for merging multiple versions of the same video together into a +/// single UI element (by linking the videos together and letting Jellyfin +/// handle the rest). +/// </summary> +/// +/// Based upon; +/// https://github.com/danieladov/jellyfin-plugin-mergeversions +public class MergeVersionsManager +{ + /// <summary> + /// Library manager. Used to fetch items from the library. + /// </summary> + private readonly ILibraryManager LibraryManager; + + /// <summary> + /// Shoko ID Lookup. Used to check if the plugin is enabled for the videos. + /// </summary> + private readonly IIdLookup Lookup; + + /// <summary> + /// Used by the DI IoC to inject the needed interfaces. + /// </summary> + /// <param name="libraryManager">Library manager.</param> + /// <param name="lookup">Shoko ID Lookup.</param> + /// <param name="logger">Logger.</param> + public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup) + { + LibraryManager = libraryManager; + Lookup = lookup; + } + + #region Shared + + /// <summary> + /// Group and merge all videos with a Shoko Episode ID set. + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the merging is + /// complete.</returns> + public async Task MergeAll(IProgress<double> progress, CancellationToken cancellationToken) + { + // Shared progress; + double episodeProgressValue = 0d, movieProgressValue = 0d; + + // Setup the movie task. + var movieProgress = new Progress<double>(value => { + movieProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + }); + var movieTask = MergeAllMovies(movieProgress, cancellationToken); + + // Setup the episode task. + var episodeProgress = new Progress<double>(value => { + episodeProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + }); + var episodeTask = MergeAllEpisodes(episodeProgress, cancellationToken); + + // Run them in parallel. + await Task.WhenAll(movieTask, episodeTask); + } + + /// <summary> + /// Split up all merged videos with a Shoko Episode ID set. + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the splitting is + /// complete.</returns> + public async Task SplitAll(IProgress<double> progress, CancellationToken cancellationToken) + { + // Shared progress; + double episodeProgressValue = 0d, movieProgressValue = 0d; + + // Setup the movie task. + var movieProgress = new Progress<double>(value => { + movieProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + }); + var movieTask = SplitAllMovies(movieProgress, cancellationToken); + + // Setup the episode task. + var episodeProgress = new Progress<double>(value => { + episodeProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + progress?.Report(50d + (value / 2d)); + }); + var episodeTask = SplitAllEpisodes(episodeProgress, cancellationToken); + + // Run them in parallel. + await Task.WhenAll(movieTask, episodeTask); + } + + #endregion Shared + #region Movies + + /// <summary> + /// Get all movies with a Shoko Episode ID set across all libraries. + /// </summary> + /// <returns>A list of all movies with a Shoko Episode ID set.</returns> + private List<Movie> GetMoviesFromLibrary() + { + return LibraryManager.GetItemList(new() { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + IsVirtualItem = false, + Recursive = true, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, + }) + .Cast<Movie>() + .Where(Lookup.IsEnabledForItem) + .ToList(); + } + + /// <summary> + /// Merge movie entries together. + /// </summary> + /// <param name="movies">Movies to merge.</param> + /// <returns>An async task that will silently complete when the merging is + /// complete.</returns> + public static async Task MergeMovies(IEnumerable<Movie> movies) + => await MergeVideos(movies.Cast<Video>().OrderBy(e => e.Id).ToList()); + + /// <summary> + /// Merge all movie entries with a Shoko Episode ID set. + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the merging is + /// complete.</returns> + public async Task MergeAllMovies(IProgress<double> progress, CancellationToken cancellationToken) + { + if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { + await SplitAndMergeAllMovies(progress, cancellationToken); + return; + } + + // Merge all movies with more than one version. + var movies = GetMoviesFromLibrary(); + var duplicationGroups = movies + .GroupBy(x => (x.GetTopParent()?.Path, x.ProviderIds[ShokoEpisodeId.Name])) + .Where(x => x.Count() > 1) + .ToList(); + double currentCount = 0d; + double totalGroups = duplicationGroups.Count; + foreach (var movieGroup in duplicationGroups) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalGroups) * 100; + progress?.Report(percent); + + // Link the movies together as alternate sources. + await MergeMovies(movieGroup); + } + + progress?.Report(100); + } + + /// <summary> + /// Split up all existing merged movies with a Shoko Episode ID set. + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the splitting is + /// complete.</returns> + public async Task SplitAllMovies(IProgress<double> progress, CancellationToken cancellationToken) + { + // Split up any existing merged movies. + var movies = GetMoviesFromLibrary(); + double currentCount = 0d; + double totalMovies = movies.Count; + foreach (var movie in movies) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalMovies) * 100d; + progress?.Report(percent); + + // Remove all alternate sources linked to the movie. + await RemoveAlternateSources(movie); + } + + progress?.Report(100); + } + + /// <summary> + /// + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the splitting + /// followed by merging is complete.</returns> + private async Task SplitAndMergeAllMovies(IProgress<double> progress, CancellationToken cancellationToken) + { + // Split up any existing merged movies. + var movies = GetMoviesFromLibrary(); + double currentCount = 0d; + double totalCount = movies.Count; + foreach (var movie in movies) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalCount) * 50d; + progress?.Report(percent); + + // Remove all alternate sources linked to the movie. + await RemoveAlternateSources(movie); + } + + // Merge all movies with more than one version (again). + var duplicationGroups = movies + .GroupBy(movie => (movie.GetTopParent()?.Path, movie.ProviderIds[ShokoEpisodeId.Name])) + .Where(movie => movie.Count() > 1) + .ToList(); + currentCount = 0d; + totalCount = duplicationGroups.Count; + foreach (var movieGroup in duplicationGroups) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = 50d + ((currentCount++ / totalCount) * 50d); + progress?.Report(percent); + + // Link the movies together as alternate sources. + await MergeMovies(movieGroup); + } + + progress?.Report(100); + } + + #endregion Movies + #region Episodes + + /// <summary> + /// Get all episodes with a Shoko Episode ID set across all libraries. + /// </summary> + /// <returns>A list of all episodes with a Shoko Episode ID set.</returns> + private List<Episode> GetEpisodesFromLibrary() + { + return LibraryManager.GetItemList(new() { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, + IsVirtualItem = false, + Recursive = true, + }) + .Cast<Episode>() + .Where(Lookup.IsEnabledForItem) + .ToList(); + } + + /// <summary> + /// Merge episode entries together. + /// </summary> + /// <param name="episodes">Episodes to merge.</param> + /// <returns>An async task that will silently complete when the merging is + /// complete.</returns> + public static async Task MergeEpisodes(IEnumerable<Episode> episodes) + => await MergeVideos(episodes.Cast<Video>().OrderBy(e => e.Id).ToList()); + + /// <summary> + /// Split up all existing merged versions of each movie and merge them + /// again afterwards. Only applied to movies with a Shoko Episode ID set. + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the merging is + /// complete.</returns> + public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + { + if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeEpisodes) { + await SplitAndMergeAllEpisodes(progress, cancellationToken); + return; + } + + // Merge episodes with more than one version, and with the same number + // of additional episodes. + var episodes = GetEpisodesFromLibrary(); + var duplicationGroups = episodes + .GroupBy(e => (e.GetTopParent()?.Path, $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}")) + .Where(e => e.Count() > 1) + .ToList(); + double currentCount = 0d; + double totalGroups = duplicationGroups.Count; + foreach (var episodeGroup in duplicationGroups) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalGroups) * 100d; + progress?.Report(percent); + + // Link the episodes together as alternate sources. + await MergeEpisodes(episodeGroup); + } + + progress?.Report(100); + } + + /// <summary> + /// Split up all existing merged episodes with a Shoko Episode ID set. + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the splitting is + /// complete.</returns> + public async Task SplitAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + { + // Split up any existing merged episodes. + var episodes = GetEpisodesFromLibrary(); + double currentCount = 0d; + double totalEpisodes = episodes.Count; + foreach (var e in episodes) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalEpisodes) * 100d; + progress?.Report(percent); + + // Remove all alternate sources linked to the episode. + await RemoveAlternateSources(e); + } + + progress?.Report(100); + } + + /// <summary> + /// Split up all existing merged versions of each episode and merge them + /// again afterwards. Only applied to episodes with a Shoko Episode ID set. + /// </summary> + /// <param name="progress">Progress indicator.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async task that will silently complete when the splitting + /// followed by merging is complete.</returns> + private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + { + // Split up any existing merged episodes. + var episodes = GetEpisodesFromLibrary(); + double currentCount = 0d; + double totalCount = episodes.Count; + foreach (var e in episodes) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalCount) * 100d; + progress?.Report(percent); + + // Remove all alternate sources linked to the episode. + await RemoveAlternateSources(e); + } + + // Merge episodes with more than one version (again), and with the same + // number of additional episodes. + var duplicationGroups = episodes + .GroupBy(e => (e.GetTopParent()?.Path, $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}")) + .Where(e => e.Count() > 1) + .ToList(); + currentCount = 0d; + totalCount = duplicationGroups.Count; + foreach (var episodeGroup in duplicationGroups) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = currentCount++ / totalCount * 100d; + progress?.Report(percent); + + // Link the episodes together as alternate sources. + await MergeEpisodes(episodeGroup); + } + } + + #endregion Episodes + + /// <summary> + /// Merges multiple videos into a single UI element. + /// </summary> + /// + /// Modified from; + /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L192 + private static async Task MergeVideos(List<Video> videos) + { + if (videos.Count < 2) + return; + + var primaryVersion = videos.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)) ?? + videos + .OrderBy(i => + { + if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) + return 1; + + return 0; + }) + .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) + .First(); + + // Add any videos not already linked to the primary version to the list. + var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions + .ToList(); + foreach (var video in videos.Where(v => !v.Id.Equals(primaryVersion.Id))) + { + video.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, video.Path, StringComparison.OrdinalIgnoreCase))) { + alternateVersionsOfPrimary.Add(new() { + Path = video.Path, + ItemId = video.Id, + }); + } + + foreach (var linkedItem in video.LinkedAlternateVersions) { + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + alternateVersionsOfPrimary.Add(linkedItem); + } + + // Reset the linked alternate versions for the linked videos. + if (video.LinkedAlternateVersions.Length > 0) + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + + // Save the changes back to the repository. + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); + } + + primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary + .ToArray(); + await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); + } + + /// <summary> + /// Removes all alternate video sources from a video and all it's linked + /// videos. + /// </summary> + /// <param name="baseItem">The primary video to clean up.</param> + /// + /// Modified from; + /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L152 + private async Task RemoveAlternateSources(Video video) + { + // Find the primary video. + if (video.LinkedAlternateVersions.Length == 0) { + // Ensure we're not running on an unlinked item. + if (string.IsNullOrEmpty(video.PrimaryVersionId)) + return; + + // Make sure the primary video still exists before we proceed. + if (LibraryManager.GetItemById(video.PrimaryVersionId) is not Video primaryVideo) + return; + video = primaryVideo; + } + + // Remove the link for every linked video. + foreach (var linkedVideo in video.GetLinkedAlternateVersions()) + { + linkedVideo.SetPrimaryVersionId(null); + linkedVideo.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); + } + + // Remove the link for the primary video. + video.SetPrimaryVersionId(null); + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); + } +} diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs new file mode 100644 index 00000000..61bcaa3f --- /dev/null +++ b/Shokofin/Plugin.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Logging; +using Shokofin.Configuration; +using Shokofin.Utils; + +namespace Shokofin; + +public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages +{ + public const string MetadataProviderName = "Shoko"; + + public override string Name => MetadataProviderName; + + public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + + /// <summary> + /// Indicates that we can create symbolic links. + /// </summary> + public readonly bool CanCreateSymbolicLinks; + + /// <summary> + /// Usage tracker for automagically clearing the caches when nothing is using them. + /// </summary> + public readonly UsageTracker Tracker; + + private readonly ILogger<Plugin> Logger; + + /// <summary> + /// "Virtual" File System Root Directory. + /// </summary> + public readonly string VirtualRoot; + + /// <summary> + /// Gets or sets the event handler that is triggered when this configuration changes. + /// </summary> + public new event EventHandler<PluginConfiguration>? ConfigurationChanged; + + public Plugin(ILoggerFactory loggerFactory, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) + { + Instance = this; + base.ConfigurationChanged += OnConfigChanged; + VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); + Tracker = new(loggerFactory.CreateLogger<UsageTracker>(), TimeSpan.FromSeconds(60)); + Logger = logger; + CanCreateSymbolicLinks = true; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + var target = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestTarget.txt"); + var link = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestLink.txt"); + try { + if (!Directory.Exists(Path.GetDirectoryName(VirtualRoot)!)) + Directory.CreateDirectory(Path.GetDirectoryName(VirtualRoot)!); + File.WriteAllText(target, string.Empty); + File.CreateSymbolicLink(link, target); + } + catch { + CanCreateSymbolicLinks = false; + } + finally { + if (File.Exists(link)) + File.Delete(link); + if (File.Exists(target)) + File.Delete(target); + } + } + IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); + Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); + Logger.LogDebug("Virtual File System Location; {Path}", VirtualRoot); + Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); + } + + public void UpdateConfiguration() + { + UpdateConfiguration(this.Configuration); + } + + public void OnConfigChanged(object? sender, BasePluginConfiguration e) + { + if (e is not PluginConfiguration config) + return; + IgnoredFolders = config.IgnoredFolders.ToHashSet(); + Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); + ConfigurationChanged?.Invoke(sender, config); + } + + public HashSet<string> IgnoredFolders; + +#pragma warning disable 8618 + public static Plugin Instance { get; private set; } +#pragma warning restore 8618 + + public IEnumerable<PluginPageInfo> GetPages() + { + return new[] + { + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html", + }, + new PluginPageInfo + { + Name = "ShokoController.js", + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configController.js", + }, + }; + } +} diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs new file mode 100644 index 00000000..5cc3702d --- /dev/null +++ b/Shokofin/PluginServiceRegistrator.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Shokofin; + +/// <inheritdoc /> +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// <inheritdoc /> + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton<Utils.LibraryScanWatcher>(); + serviceCollection.AddSingleton<API.ShokoAPIClient>(); + serviceCollection.AddSingleton<API.ShokoAPIManager>(); + serviceCollection.AddSingleton<Configuration.MediaFolderConfigurationService>(); + serviceCollection.AddSingleton<IIdLookup, IdLookup>(); + serviceCollection.AddSingleton<Sync.UserDataSyncManager>(); + serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); + serviceCollection.AddSingleton<Collections.CollectionManager>(); + serviceCollection.AddSingleton<Resolvers.VirtualFileSystemService>(); + serviceCollection.AddSingleton<Events.EventDispatchService>(); + serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); + serviceCollection.AddHostedService<SignalR.SignalREntryPoint>(); + serviceCollection.AddHostedService<Resolvers.ShokoLibraryMonitor>(); + serviceCollection.AddControllers(options => options.Filters.Add<Web.ImageHostUrl>()); + } +} diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs new file mode 100644 index 00000000..8e2292e6 --- /dev/null +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + public int Order => -1; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<BoxSetProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } + + public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) + { + try { + // Try to read the shoko group id + if (info.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || info.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId)) + using (Plugin.Instance.Tracker.Enter($"Providing info for Collection \"{info.Name}\". (Path=\"{info.Path}\",Collection=\"{collectionId}\")")) + return await GetShokoGroupMetadata(info, collectionId); + + // Try to read the shoko series id + if (info.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || info.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId)) + using (Plugin.Instance.Tracker.Enter($"Providing info for Collection \"{info.Name}\". (Path=\"{info.Path}\",Series=\"{seriesId}\")")) + return await GetShokoSeriesMetadata(info, seriesId); + + return new(); + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<BoxSet>(); + } + } + + private async Task<MetadataResult<BoxSet>> GetShokoSeriesMetadata(BoxSetInfo info, string seriesId) + { + // First try to re-use any existing series id. + var result = new MetadataResult<BoxSet>(); + var season = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (season == null) { + Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); + return result; + } + + var (displayTitle, alternateTitle) = Text.GetSeasonTitles(season, info.MetadataLanguage); + + Logger.LogInformation("Found collection {CollectionName} (Series={SeriesId},ExtraSeries={ExtraIds})", displayTitle, season.Id, season.ExtraIds); + + result.Item = new BoxSet { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(season), + PremiereDate = season.AniDB.AirDate, + EndDate = season.AniDB.EndDate, + ProductionYear = season.AniDB.AirDate?.Year, + Tags = season.Tags.ToArray(), + CommunityRating = season.AniDB.Rating.ToFloat(10), + }; + result.Item.SetProviderId(ShokoCollectionSeriesId.Name, season.Id); + result.HasMetadata = true; + + return result; + } + + private async Task<MetadataResult<BoxSet>> GetShokoGroupMetadata(BoxSetInfo info, string groupId) + { + // Filter out all manually created collections. We don't help those. + var result = new MetadataResult<BoxSet>(); + var collection = await ApiManager.GetCollectionInfoForGroup(groupId); + if (collection == null) { + Logger.LogWarning("Unable to find collection info for name {Name} and path {Path}", info.Name, info.Path); + return result; + } + + Logger.LogInformation("Found collection {CollectionName} (Series={SeriesId})", collection.Name, collection.Id); + + result.Item = new BoxSet { + Name = collection.Name, + Overview = collection.Shoko.Description, + }; + result.Item.SetProviderId(ShokoCollectionGroupId.Name, collection.Id); + result.HasMetadata = true; + + return result; + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} diff --git a/Shokofin/Providers/CustomBoxSetProvider.cs b/Shokofin/Providers/CustomBoxSetProvider.cs new file mode 100644 index 00000000..c5a47d5c --- /dev/null +++ b/Shokofin/Providers/CustomBoxSetProvider.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Info; +using Shokofin.Collections; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +/// <summary> +/// The custom episode provider. Responsible for de-duplicating episodes. +/// </summary> +/// <remarks> +/// This needs to be it's own class because of internal Jellyfin shenanigans +/// about how a provider cannot also be a custom provider otherwise it won't +/// save the metadata. +/// </remarks> +public class CustomBoxSetProvider : ICustomMetadataProvider<BoxSet> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomBoxSetProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + private readonly ILibraryManager LibraryManager; + + private readonly CollectionManager CollectionManager; + + public CustomBoxSetProvider(ILogger<CustomBoxSetProvider> logger, ShokoAPIManager apiManager, ILibraryManager libraryManager, CollectionManager collectionManager) + { + Logger = logger; + ApiManager = apiManager; + LibraryManager = libraryManager; + CollectionManager = collectionManager; + } + + public async Task<ItemUpdateType> FetchAsync(BoxSet collection, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // Abort if the collection root is not made yet (which should never happen). + var collectionRoot = await CollectionManager.GetCollectionsFolder(false); + if (collectionRoot is null) + return ItemUpdateType.None; + + // Try to read the shoko group id + if (collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || collection.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId)) + using (Plugin.Instance.Tracker.Enter($"Providing custom info for Collection \"{collection.Name}\". (Path=\"{collection.Path}\",Collection=\"{collectionId}\")")) + if (await EnsureGroupCollectionIsCorrect(collectionRoot, collection, collectionId, cancellationToken)) + return ItemUpdateType.MetadataEdit; + + // Try to read the shoko series id + if (collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || collection.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId)) + using (Plugin.Instance.Tracker.Enter($"Providing custom info for Collection \"{collection.Name}\". (Path=\"{collection.Path}\",Series=\"{seriesId}\")")) + if (await EnsureSeriesCollectionIsCorrect(collection, seriesId, cancellationToken)) + return ItemUpdateType.MetadataEdit; + + return ItemUpdateType.None; + } + + private async Task<bool> EnsureSeriesCollectionIsCorrect(BoxSet collection, string seriesId, CancellationToken cancellationToken) + { + var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (seasonInfo is null) + return false; + + var updated = EnsureNoTmdbIdIsSet(collection); + var metadataLanguage = LibraryManager.GetLibraryOptions(collection)?.PreferredMetadataLanguage; + var (displayName, alternateTitle) = Text.GetSeasonTitles(seasonInfo, metadataLanguage); + if (!string.Equals(collection.Name, displayName)) { + collection.Name = displayName; + updated = true; + } + if (!string.Equals(collection.OriginalTitle, alternateTitle)) { + collection.OriginalTitle = alternateTitle; + updated = true; + } + + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); + Logger.LogDebug("Fixed collection {CollectionName} (Series={SeriesId})", collection.Name, seriesId); + } + + return updated; + } + + private async Task<bool> EnsureGroupCollectionIsCorrect(Folder collectionRoot, BoxSet collection, string collectionId, CancellationToken cancellationToken) + { + var collectionInfo = await ApiManager.GetCollectionInfoForGroup(collectionId); + if (collectionInfo is null) + return false; + + var updated = EnsureNoTmdbIdIsSet(collection); + var parent = collectionInfo.IsTopLevel ? collectionRoot : await GetCollectionByGroupId(collectionRoot, collectionInfo.ParentId); + if (collection.ParentId != parent.Id) { + collection.SetParent(parent); + updated = true; + } + if (!string.Equals(collection.Name, collectionInfo.Name)) { + collection.Name = collectionInfo.Name; + updated = true; + } + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); + Logger.LogDebug("Fixed collection {CollectionName} (Group={GroupId})", collection.Name, collectionId); + } + + return updated; + } + + private bool EnsureNoTmdbIdIsSet(BoxSet collection) + { + var willRemove = collection.ProviderIds.ContainsKey(MetadataProvider.TmdbCollection.ToString()); + collection.ProviderIds.Remove(MetadataProvider.TmdbCollection.ToString()); + return willRemove; + } + + private async Task<BoxSet> GetCollectionByGroupId(Folder collectionRoot, string? collectionId) + { + if (string.IsNullOrEmpty(collectionId)) + throw new ArgumentNullException(nameof(collectionId)); + + var collectionInfo = await ApiManager.GetCollectionInfoForGroup(collectionId) ?? + throw new Exception($"Unable to find collection info for the parent collection with id \"{collectionId}\""); + + var collection = GetCollectionByPath(collectionRoot, collectionInfo); + if (collection is not null) + return collection; + + var list = LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + + HasAnyProviderId = new() { { ShokoCollectionGroupId.Name, collectionId } }, + IsVirtualItem = false, + Recursive = true, + }) + .OfType<BoxSet>() + .ToList(); + if (list.Count == 0) { + throw new NullReferenceException("Unable to a find collection with the given group id."); + } + if (list.Count > 1) { + throw new Exception("Found multiple collections with the same group id."); + } + return list[0]!; + } + + private BoxSet? GetCollectionByPath(Folder collectionRoot, CollectionInfo collectionInfo) + { + var baseName = $"{collectionInfo.Name.ForceASCII()} [{ShokoCollectionGroupId.Name}={collectionInfo.Id}]"; + var folderName = BaseItem.FileSystem.GetValidFilename(baseName) + " [boxset]"; + var path = Path.Combine(collectionRoot.Path, folderName); + return LibraryManager.FindByPath(path, true) as BoxSet; + } + +} diff --git a/Shokofin/Providers/CustomEpisodeProvider.cs b/Shokofin/Providers/CustomEpisodeProvider.cs new file mode 100644 index 00000000..35b3647a --- /dev/null +++ b/Shokofin/Providers/CustomEpisodeProvider.cs @@ -0,0 +1,118 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.ExternalIds; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers; + +/// <summary> +/// The custom episode provider. Responsible for de-duplicating episodes. +/// </summary> +/// <remarks> +/// This needs to be it's own class because of internal Jellyfin shenanigans +/// about how a provider cannot also be a custom provider otherwise it won't +/// save the metadata. +/// </remarks> +public class CustomEpisodeProvider : ICustomMetadataProvider<Episode> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomEpisodeProvider> Logger; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + public CustomEpisodeProvider(ILogger<CustomEpisodeProvider> logger, IIdLookup lookup, ILibraryManager libraryManager) + { + Logger = logger; + Lookup = lookup; + LibraryManager = libraryManager; + } + + public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var series = episode.Series; + if (series is null) + return Task.FromResult(ItemUpdateType.None); + + // Abort if we're unable to get the shoko episode id + if (episode.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out var episodeId)) + using (Plugin.Instance.Tracker.Enter($"Providing custom info for Episode \"{episode.Name}\". (Path=\"{episode.Path}\",IsMissingEpisode={episode.IsMissingEpisode})")) + if (RemoveDuplicates(LibraryManager, Logger, episodeId, episode, series.GetPresentationUniqueKey())) + return Task.FromResult(ItemUpdateType.MetadataEdit); + + return Task.FromResult(ItemUpdateType.None); + } + + public static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, string episodeId, Episode episode, string seriesPresentationUniqueKey) + { + // Remove any extra virtual episodes that matches the newly refreshed episode. + var searchList = libraryManager.GetItemList( + new() { + ExcludeItemIds = new[] { episode.Id }, + HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, + IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + GroupByPresentationUniqueKey = false, + GroupBySeriesPresentationUniqueKey = true, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new(true), + }, + true + ) + .Where(item => string.IsNullOrEmpty(item.Path)) + .ToList(); + if (searchList.Count > 0) { + logger.LogDebug("Removing {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", searchList.Count, episode.Name, episodeId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + libraryManager.DeleteItem(item, deleteOptions); + + return true; + } + + return false; + } + + private static bool EpisodeExists(ILibraryManager libraryManager, ILogger logger, string seriesPresentationUniqueKey, string episodeId, string seriesId, string? groupId) + { + var searchList = libraryManager.GetItemList( + new() { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, + GroupByPresentationUniqueKey = false, + GroupBySeriesPresentationUniqueKey = true, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new(true), + }, + true + ); + if (searchList.Count > 0) { + logger.LogTrace("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); + return true; + } + return false; + } + + public static bool AddVirtualEpisode(ILibraryManager libraryManager, ILogger logger, Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season, Series series) + { + if (EpisodeExists(libraryManager, logger, series.GetPresentationUniqueKey(), episodeInfo.Id, seasonInfo.Id, showInfo.GroupId)) + return false; + + var episodeId = libraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); + var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); + + logger.LogInformation("Adding virtual Episode {EpisodeNumber} in Season {SeasonNumber} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds},Group={GroupId})", episode.IndexNumber, season.IndexNumber, showInfo.Name, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds, showInfo.GroupId); + + season.AddChild(episode); + + return true; + } +} diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs new file mode 100644 index 00000000..ba5e0338 --- /dev/null +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers; + +/// <summary> +/// The custom season provider. Responsible for de-duplicating seasons and +/// adding/removing "missing" episodes. +/// </summary> +/// <remarks> +/// This needs to be it's own class because of internal Jellyfin shenanigans +/// about how a provider cannot also be a custom provider otherwise it won't +/// save the metadata. +/// </remarks> +public class CustomSeasonProvider : ICustomMetadataProvider<Season> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomSeasonProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; + + public CustomSeasonProvider(ILogger<CustomSeasonProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + { + Logger = logger; + ApiManager = apiManager; + Lookup = lookup; + LibraryManager = libraryManager; + } + + public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return ItemUpdateType.None; + + // Silently abort if we're unable to get the shoko series id. + var series = season.Series; + if (!series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId)) + return ItemUpdateType.None; + + var seasonNumber = season.IndexNumber!.Value; + var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Season \"{season.Name}\". (Path=\"{season.Path}\",Series=\"{seriesId}\",Season={seasonNumber})"); + try { + // Loudly abort if the show metadata doesn't exist. + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); + return ItemUpdateType.None; + } + + // Remove duplicates of the same season. + var itemUpdated = ItemUpdateType.None; + if (RemoveDuplicates(LibraryManager, Logger, seasonNumber, season, series, seriesId)) + itemUpdated |= ItemUpdateType.MetadataEdit; + + // Special handling of specials (pun intended). + if (seasonNumber == 0) { + // Get known episodes, existing episodes, and episodes to remove. + var knownEpisodeIds = ShouldAddMetadata + ? showInfo.SpecialsDict.Keys.ToHashSet() + : showInfo.SpecialsDict + .Where(pair => pair.Value) + .Select(pair => pair.Key) + .ToHashSet(); + var existingEpisodes = new HashSet<string>(); + var toRemoveEpisodes = new List<Episode>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + toRemoveEpisodes.Add(episode); + else + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) + toRemoveEpisodes.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var sI in showInfo.SeasonList) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(sI)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, sI, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + } + // Every other "season." + else { + // Loudly abort if the season metadata doesn't exist. + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + return ItemUpdateType.None; + } + + // Get known episodes, existing episodes, and episodes to remove. + var episodeList = Math.Abs(seasonNumber - baseSeasonNumber) == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; + var knownEpisodeIds = ShouldAddMetadata + ? episodeList.Select(episodeInfo => episodeInfo.Id).ToHashSet() + : new HashSet<string>(); + var existingEpisodes = new HashSet<string>(); + var toRemoveEpisodes = new List<Episode>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + toRemoveEpisodes.Add(episode); + else + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) + toRemoveEpisodes.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in episodeList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + return itemUpdated; + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + private static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, int seasonNumber, Season season, Series series, string seriesId) + { + // Remove the virtual season that matches the season. + var searchList = libraryManager + .GetItemList( + new() { + ParentId = season.ParentId, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + ExcludeItemIds = new [] { season.Id }, + IndexNumber = seasonNumber, + DtoOptions = new(true), + }, + true + ) + .Where(item => !item.IndexNumber.HasValue) + .ToList(); + if (searchList.Count > 0) + { + logger.LogDebug("Removing {Count} duplicates of Season {SeasonNumber} from Series {SeriesName} (Series={SeriesId})", searchList.Count, seasonNumber, series.Name, seriesId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + libraryManager.DeleteItem(item, deleteOptions); + + return true; + } + return false; + } + + private static bool SeasonExists(ILibraryManager libraryManager, ILogger logger, string seriesPresentationUniqueKey, string seriesName, int seasonNumber) + { + var searchList = libraryManager.GetItemList( + new() { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + IndexNumber = seasonNumber, + GroupByPresentationUniqueKey = false, + GroupBySeriesPresentationUniqueKey = true, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new(true), + }, + true + ); + + if (searchList.Count > 0) { + logger.LogTrace("Season {SeasonNumber} for Series {SeriesName} exists.", seasonNumber, seriesName); + return true; + } + + return false; + } + + public static Season? AddVirtualSeasonZero(ILibraryManager libraryManager, ILogger logger, Series series) + { + if (SeasonExists(libraryManager, logger, series.GetPresentationUniqueKey(), series.Name, 0)) + return null; + + var seasonName = libraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; + var season = new Season { + Name = seasonName, + IndexNumber = 0, + SortName = seasonName, + ForcedSortName = seasonName, + Id = libraryManager.GetNewItemId(series.Id + "Season 0", typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateCreated = series.DateCreated, + DateModified = series.DateModified, + DateLastSaved = series.DateLastSaved, + }; + + logger.LogInformation("Adding virtual Season {SeasonNumber} to Series {SeriesName}.", 0, series.Name); + + series.AddChild(season); + + return season; + } + + public static Season? AddVirtualSeason(ILibraryManager libraryManager, ILogger logger, Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) + { + if (SeasonExists(libraryManager, logger, series.GetPresentationUniqueKey(), series.Name, seasonNumber)) + return null; + + var seasonId = libraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(System.Globalization.CultureInfo.InvariantCulture), typeof(Season)); + var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); + + logger.LogInformation("Adding virtual Season {SeasonNumber} to Series {SeriesName}. (Series={SeriesId},ExtraSeries={ExtraIds})", seasonNumber, series.Name, seasonInfo.Id, seasonInfo.ExtraIds); + + series.AddChild(season); + + return season; + } +} \ No newline at end of file diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs new file mode 100644 index 00000000..ffe8e3f6 --- /dev/null +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers; + +public class CustomSeriesProvider : ICustomMetadataProvider<Series> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomSeriesProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; + + public CustomSeriesProvider(ILogger<CustomSeriesProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + { + Logger = logger; + ApiManager = apiManager; + Lookup = lookup; + LibraryManager = libraryManager; + } + + public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // Abort if we're unable to get the shoko series id + if (!series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId)) + return ItemUpdateType.None; + + var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Series \"{series.Name}\". (Series=\"{seriesId}\")"); + try { + // Provide metadata for a series using Shoko's Group feature + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); + return ItemUpdateType.None; + } + + // Get the existing seasons and known seasons. + var itemUpdated = ItemUpdateType.None; + var allSeasons = series.Children + .OfType<Season>() + .Where(season => season.IndexNumber.HasValue) + .ToList(); + var seasons = allSeasons + .OrderBy(season => season.IndexNumber!.Value) + .ThenBy(season => season.IsVirtualItem) + .ThenBy(season => season.Path) + .GroupBy(season => season.IndexNumber!.Value) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.First()); + var extraSeasonsToRemove = allSeasons + .Except(seasons.Values) + .ToList(); + var knownSeasonIds = ShouldAddMetadata + ? showInfo.SeasonOrderDictionary.Keys.ToHashSet() + : showInfo.SeasonOrderDictionary + .Where(pair => !pair.Value.IsEmpty(Math.Abs(pair.Key - showInfo.GetBaseSeasonNumberForSeasonInfo(pair.Value)))) + .Select(pair => pair.Key) + .ToHashSet(); + if (ShouldAddMetadata ? showInfo.HasSpecials : showInfo.HasSpecialsWithFiles) + knownSeasonIds.Add(0); + + // Remove unknown or unwanted seasons. + var toRemoveSeasons = seasons.ExceptBy(knownSeasonIds, season => season.Key) + .Where(season => string.IsNullOrEmpty(season.Value.Path) || season.Value.IsVirtualItem) + .ToList(); + foreach (var (seasonNumber, season) in toRemoveSeasons) { + Logger.LogDebug("Removing Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Name, seriesId); + seasons.Remove(seasonNumber); + LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); + } + + foreach (var season in extraSeasonsToRemove) { + if (seasons.TryGetValue(season.IndexNumber!.Value, out var mainSeason)) { + var episodes = season.Children + .OfType<Episode>() + .Where(episode => !string.IsNullOrEmpty(episode.Path) && episode.ParentId == season.Id) + .ToList(); + foreach (var episode in episodes) { + Logger.LogInformation("Updating parent of physical episode {EpisodeNumber} {EpisodeName} in Season {SeasonNumber} for {SeriesName} (Series={SeriesId})", episode.IndexNumber, episode.Name, season.IndexNumber, series.Name, seriesId); + episode.SetParent(mainSeason); + } + await LibraryManager.UpdateItemsAsync(episodes, mainSeason, ItemUpdateType.MetadataEdit, CancellationToken.None); + } + + Logger.LogDebug("Removing extra Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", season.IndexNumber!.Value, series.Name, seriesId); + LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); + } + + // Add missing seasons. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) + foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) { + itemUpdated |= ItemUpdateType.MetadataImport; + seasons.TryAdd(seasonNumber, season); + } + + // Special handling of Specials (pun intended). + if (seasons.TryGetValue(0, out var zeroSeason)) { + // Get known episodes, existing episodes, and episodes to remove. + var knownEpisodeIds = ShouldAddMetadata + ? showInfo.SpecialsDict.Keys.ToHashSet() + : showInfo.SpecialsDict + .Where(pair => pair.Value) + .Select(pair => pair.Key) + .ToHashSet(); + var existingEpisodes = new HashSet<string>(); + var toRemoveEpisodes = new List<Episode>(); + foreach (var episode in zeroSeason.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + toRemoveEpisodes.Add(episode); + else + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) + toRemoveEpisodes.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) + foreach (var seasonInfo in showInfo.SeasonList) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in seasonInfo.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, zeroSeason, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + // All other seasons. + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + // Silently continue if the season doesn't exist. + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; + + // Loudly skip if the season metadata doesn't exist. + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + continue; + } + + // Get known episodes, existing episodes, and episodes to remove. + var episodeList = Math.Abs(seasonNumber - baseSeasonNumber) == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; + var knownEpisodeIds = ShouldAddMetadata ? episodeList.Select(episodeInfo => episodeInfo.Id).ToHashSet() : new HashSet<string>(); + var existingEpisodes = new HashSet<string>(); + var toRemoveEpisodes = new List<Episode>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + toRemoveEpisodes.Add(episode); + else + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) + toRemoveEpisodes.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { + var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; + + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + return itemUpdated; + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) + { + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + if (seasons.ContainsKey(seasonNumber)) + continue; + var offset = seasonNumber - showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); + var season = CustomSeasonProvider.AddVirtualSeason(LibraryManager, Logger, seasonInfo, offset, seasonNumber, series); + if (season == null) + continue; + yield return (seasonNumber, season); + } + + if (showInfo.HasSpecials && !seasons.ContainsKey(0)) { + var season = CustomSeasonProvider.AddVirtualSeasonZero(LibraryManager, Logger, series); + if (season != null) + yield return (0, season); + } + } +} \ No newline at end of file diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs new file mode 100644 index 00000000..7a70ace5 --- /dev/null +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +using Info = Shokofin.API.Info; +using SeriesType = Shokofin.API.Models.SeriesType; +using EpisodeType = Shokofin.API.Models.EpisodeType; + +namespace Shokofin.Providers; + +public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + public int Order => 0; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<EpisodeProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) + { + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Episode \"{info.Name}\". (Path=\"{info.Path}\",IsMissingEpisode={info.IsMissingEpisode})"); + try { + var result = new MetadataResult<Episode>(); + var config = Plugin.Instance.Configuration; + + // Fetch the episode, series and group info (and file info, but that's not really used (yet)) + Info.FileInfo? fileInfo = null; + Info.EpisodeInfo? episodeInfo = null; + Info.SeasonInfo? seasonInfo = null; + Info.ShowInfo? showInfo = null; + if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { + // We're unable to fetch the latest metadata for the virtual episode. + if (!info.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out var episodeId)) + return result; + + episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); + if (episodeInfo == null) + return result; + + seasonInfo = await ApiManager.GetSeasonInfoForEpisode(episodeId); + if (seasonInfo == null) + return result; + + showInfo = await ApiManager.GetShowInfoForSeries(seasonInfo.Id); + if (showInfo == null || showInfo.SeasonList.Count == 0) + return result; + } + else { + (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); + episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; + } + + // if the episode info is null then the series info and conditionally the group info is also null. + if (episodeInfo == null || seasonInfo == null || showInfo == null) { + Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); + return result; + } + + result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds, showInfo?.GroupId); + + result.HasMetadata = true; + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Episode>(); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Season season, Guid episodeId) + => CreateMetadata(group, series, episode, null, season.GetPreferredMetadataLanguage(), season, episodeId); + + public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage) + => CreateMetadata(group, series, episode, file, metadataLanguage, null, Guid.Empty); + + private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage, Season? season, Guid episodeId) + { + var config = Plugin.Instance.Configuration; + string? displayTitle, alternateTitle, description; + if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { + var displayTitles = new List<string?>(); + var alternateTitles = new List<string?>(); + foreach (var (episodeInfo, _, _) in file.EpisodeList) { + string defaultEpisodeTitle = episodeInfo.Shoko.Name; + if ( + // Movies + (series.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) || + // OVAs + (series.AniDB.Type == SeriesType.OVA && episodeInfo.AniDB.Type == EpisodeType.Normal && episodeInfo.AniDB.EpisodeNumber == 1 && episodeInfo.Shoko.Name == "OVA") + ) { + string defaultSeriesTitle = series.Shoko.Name; + var (dTitle, aTitle) = Text.GetMovieTitles(episodeInfo, series, metadataLanguage); + displayTitles.Add(dTitle); + alternateTitles.Add(aTitle); + } + else { + var (dTitle, aTitle) = Text.GetEpisodeTitles(episodeInfo, series, metadataLanguage); + displayTitles.Add(dTitle); + alternateTitles.Add(aTitle); + } + } + displayTitle = Text.JoinText(displayTitles); + alternateTitle = Text.JoinText(alternateTitles); + description = Text.GetDescription(file.EpisodeList.Select(tuple => tuple.Episode)); + } + else { + string defaultEpisodeTitle = episode.Shoko.Name; + if ( + // Movies + (series.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) || + // OVAs + (series.AniDB.Type == SeriesType.OVA && episode.AniDB.Type == EpisodeType.Normal && episode.AniDB.EpisodeNumber == 1 && episode.Shoko.Name == "OVA") + ) { + string defaultSeriesTitle = series.Shoko.Name; + (displayTitle, alternateTitle) = Text.GetMovieTitles(episode, series, metadataLanguage); + } + else { + (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episode, series, metadataLanguage); + } + description = Text.GetDescription(episode); + } + + if (config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { + case EpisodeType.Other: + case EpisodeType.Normal: + break; + case EpisodeType.Special: { + // We're guaranteed to find the index, because otherwise it would've thrown when getting the episode number. + var index = series.SpecialsList.FindIndex(ep => ep == episode); + displayTitle = $"S{index + 1} {displayTitle}"; + alternateTitle = $"S{index + 1} {alternateTitle}"; + break; + } + case EpisodeType.ThemeSong: + case EpisodeType.EndingSong: + case EpisodeType.OpeningSong: + displayTitle = $"C{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"C{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + case EpisodeType.Trailer: + displayTitle = $"T{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"T{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + case EpisodeType.Parody: + displayTitle = $"P{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"P{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + default: + displayTitle = $"U{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"U{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + } + + var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); + var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); + var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, isSpecial) = Ordering.GetSpecialPlacement(group, series, episode); + + Episode result; + if (season != null) { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, + Id = episodeId, + IsVirtualItem = true, + SeasonId = season.Id, + SeriesId = season.Series.Id, + Overview = description, + CommunityRating = episode.AniDB.Rating.Value > 0 ? episode.AniDB.Rating.ToFloat(10) : 0, + PremiereDate = episode.AniDB.AirDate, + SeriesName = season.Series.Name, + SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, + SeasonName = season.Name, + ProductionLocations = TagFilter.GetSeasonContentRating(series).ToArray(), + OfficialRating = ContentRating.GetSeasonContentRating(series), + DateLastSaved = DateTime.UtcNow, + RunTimeTicks = episode.AniDB.Duration.Ticks, + }; + result.PresentationUniqueKey = result.GetPresentationUniqueKey(); + } + else { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, + PremiereDate = episode.AniDB.AirDate, + Overview = description, + OfficialRating = ContentRating.GetSeasonContentRating(series), + CustomRating = group.CustomRating, + CommunityRating = episode.AniDB.Rating.Value > 0 ? episode.AniDB.Rating.ToFloat(10) : 0, + }; + } + + if (file != null && file.EpisodeList.Count > 1) { + var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; + if (episodeNumberEnd != episodeNumber && episode.AniDB.EpisodeNumber != episodeNumberEnd) + result.IndexNumberEnd = episodeNumberEnd; + } + + AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, seriesId: file?.SeriesId, anidbId: episode.AniDB.Id.ToString()); + + return result; + } + + private static void AddProviderIds(IHasProviderIds item, string episodeId, string? fileId = null, string? seriesId = null, string? anidbId = null, string? tmdbId = null) + { + var config = Plugin.Instance.Configuration; + item.SetProviderId(ShokoEpisodeId.Name, episodeId); + if (!string.IsNullOrEmpty(fileId)) + item.SetProviderId(ShokoFileId.Name, fileId); + if (!string.IsNullOrEmpty(seriesId)) + item.SetProviderId(ShokoSeriesId.Name, seriesId); + if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") + item.SetProviderId("AniDB", anidbId); + if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tmdb, tmdbId); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs new file mode 100644 index 00000000..43a77ad6 --- /dev/null +++ b/Shokofin/Providers/ImageProvider.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; + +namespace Shokofin.Providers; + +public class ImageProvider : IRemoteImageProvider, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + public int Order => 0; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<ImageProvider> Logger; + + private readonly ShokoAPIClient ApiClient; + + private readonly ShokoAPIManager ApiManager; + + private readonly IIdLookup Lookup; + + public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, IIdLookup lookup) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiClient = apiClient; + ApiManager = apiManager; + Lookup = lookup; + } + + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var list = new List<RemoteImageInfo>(); + var baseKind = item.GetBaseItemKind(); + var trackerId = Plugin.Instance.Tracker.Add($"Providing images for {baseKind} \"{item.Name}\". (Path=\"{item.Path}\")"); + try { + switch (item) { + case Episode episode: { + if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); + if (episodeInfo is not null) + AddImagesForEpisode(ref list, episodeInfo); + Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); + } + break; + } + case Series series: { + if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages is not null) + AddImagesForSeries(ref list, seriesImages); + // Also attach any images linked to the "seasons" (AKA series within the group). + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo is not null && !showInfo.IsStandalone) { + foreach (var seasonInfo in showInfo.SeasonList) { + seriesImages = await ApiClient.GetSeriesImages(seasonInfo.Id); + if (seriesImages is not null) + AddImagesForSeries(ref list, seriesImages); + if (seasonInfo?.ExtraIds is not null) { + foreach (var extraId in seasonInfo.ExtraIds) { + seriesImages = await ApiClient.GetSeriesImages(extraId); + if (seriesImages is not null) + AddImagesForSeries(ref list, seriesImages); + } + } + } + list = list + .DistinctBy(image => image.Url) + .ToList(); + } + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); + } + break; + } + case Season season: { + if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) { + var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages is not null) + AddImagesForSeries(ref list, seriesImages); + if (seasonInfo?.ExtraIds is not null) { + foreach (var extraId in seasonInfo.ExtraIds) { + seriesImages = await ApiClient.GetSeriesImages(extraId); + if (seriesImages is not null) + AddImagesForSeries(ref list, seriesImages); + } + list = list + .DistinctBy(image => image.Url) + .ToList(); + } + Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId})", list.Count, season.IndexNumber, season.SeriesName, seriesId); + } + break; + } + case Movie movie: { + if (Lookup.TryGetSeriesIdFor(movie, out var seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages is not null) + AddImagesForSeries(ref list, seriesImages); + Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); + } + break; + } + case BoxSet collection: { + string? groupId = null; + if (!collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) && + collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out groupId)) + seriesId = (await ApiManager.GetCollectionInfoForGroup(groupId))?.Shoko.IDs.MainSeries.ToString(); + if (!string.IsNullOrEmpty(seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages is not null) + AddImagesForSeries(ref list, seriesImages); + Logger.LogInformation("Getting {Count} images for collection {CollectionName} (Group={GroupId},Series={SeriesId})", list.Count, collection.Name, groupId, groupId == null ? seriesId : null); + } + break; + } + } + return list; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return list; + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + private static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) + { + AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); + } + + private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) + { + foreach (var image in images.Posters.OrderByDescending(image => image.IsDefault)) + AddImage(ref list, ImageType.Primary, image); + foreach (var image in images.Fanarts.OrderByDescending(image => image.IsDefault)) + AddImage(ref list, ImageType.Backdrop, image); + foreach (var image in images.Banners.OrderByDescending(image => image.IsDefault)) + AddImage(ref list, ImageType.Banner, image); + } + + private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image? image) + { + if (image == null || !image.IsAvailable) + return; + list.Add(new RemoteImageInfo { + ProviderName = Plugin.MetadataProviderName, + Type = imageType, + Width = image.Width, + Height = image.Height, + Url = image.ToURLString(), + }); + } + + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + => new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; + + public bool Supports(BaseItem item) + => item is Series or Season or Episode or Movie or BoxSet; + + public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + var index = url.IndexOf("Plugin/Shokofin/Host"); + if (index is -1) + return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); + url = $"{Plugin.Instance.Configuration.Url}/api/v3{url[(index + 20)..]}"; + return await HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } +} diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs new file mode 100644 index 00000000..21164f93 --- /dev/null +++ b/Shokofin/Providers/MovieProvider.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + public int Order => 0; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<MovieProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger, ShokoAPIManager apiManager) + { + Logger = logger; + HttpClientFactory = httpClientFactory; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) + { + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Movie \"{info.Name}\". (Path=\"{info.Path}\")"); + try { + var result = new MetadataResult<Movie>(); + var (file, season, _) = await ApiManager.GetFileInfoByPath(info.Path); + var episode = file?.EpisodeList.FirstOrDefault().Episode; + + // if file is null then series and episode is also null. + if (file == null || episode == null || season == null) { + Logger.LogWarning("Unable to find movie info for path {Path}", info.Path); + return result; + } + + var (displayTitle, alternateTitle) = Text.GetMovieTitles(episode, season, info.MetadataLanguage); + Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds})", displayTitle, file.Id, episode.Id, season.Id, season.ExtraIds); + + bool isMultiEntry = season.Shoko.Sizes.Total.Episodes > 1; + bool isMainEntry = episode.AniDB.Type == API.Models.EpisodeType.Normal && episode.Shoko.Name.Trim() == "Complete Movie"; + var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : season.AniDB.Rating.ToFloat(10); + + result.Item = new Movie { + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episode.AniDB.AirDate, + // Use the file description if collection contains more than one movie and the file is not the main entry, otherwise use the collection description. + Overview = isMultiEntry && !isMainEntry ? Text.GetDescription(episode) : Text.GetDescription(season), + ProductionYear = episode.AniDB.AirDate?.Year, + Tags = season.Tags.ToArray(), + Genres = season.Genres.ToArray(), + Studios = season.Studios.ToArray(), + ProductionLocations = TagFilter.GetMovieContentRating(season, episode).ToArray(), + OfficialRating = ContentRating.GetMovieContentRating(season, episode), + CommunityRating = rating, + DateCreated = file.Shoko.ImportedAt ?? file.Shoko.CreatedAt, + }; + result.Item.SetProviderId(ShokoFileId.Name, file.Id); + result.Item.SetProviderId(ShokoEpisodeId.Name, episode.Id); + result.Item.SetProviderId(ShokoSeriesId.Name, season.Id); + + result.HasMetadata = true; + + result.ResetPeople(); + foreach (var person in season.Staff) + result.AddPerson(person); + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Movie>(); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs new file mode 100644 index 00000000..797d3638 --- /dev/null +++ b/Shokofin/Providers/SeasonProvider.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers; + +public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + public int Order => 0; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<SeasonProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Season>(); + if (!info.IndexNumber.HasValue) + return result; + + // Special handling of the "Specials" season (pun intended). + if (info.IndexNumber.Value == 0) { + // We're forcing the sort names to start with "ZZ" to make it + // always appear last in the UI. + var seasonName = info.Name; + result.Item = new Season { + Name = seasonName, + IndexNumber = info.IndexNumber, + SortName = $"ZZ - {seasonName}", + ForcedSortName = $"ZZ - {seasonName}", + }; + result.HasMetadata = true; + + return result; + } + + if (!info.SeriesProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) || !info.IndexNumber.HasValue) { + Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); + return result; + } + + var seasonNumber = info.IndexNumber.Value; + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Season \"{info.Name}\". (Path=\"{info.Path}\",Series=\"{seriesId}\",Season={seasonNumber})"); + try { + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null) { + Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); + return result; + } + + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.GroupId); + return result; + } + + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.GroupId); + + var offset = Math.Abs(seasonNumber - baseSeasonNumber); + + result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage); + result.HasMetadata = true; + result.ResetPeople(); + foreach (var person in seasonInfo.Staff) + result.AddPerson(person); + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Season>(); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage) + => CreateMetadata(seasonInfo, seasonNumber, offset, metadataLanguage, null, Guid.Empty); + + public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, Series series, Guid seasonId) + => CreateMetadata(seasonInfo, seasonNumber, offset, series.GetPreferredMetadataLanguage(), series, seasonId); + + public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, Series? series, Guid seasonId) + { + var (displayTitle, alternateTitle) = Text.GetSeasonTitles(seasonInfo, offset, metadataLanguage); + var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; + Season season; + if (series != null) { + season = new Season { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = seasonNumber, + SortName = sortTitle, + ForcedSortName = sortTitle, + Id = seasonId, + IsVirtualItem = true, + Overview = Text.GetDescription(seasonInfo), + PremiereDate = seasonInfo.AniDB.AirDate, + EndDate = seasonInfo.AniDB.EndDate, + ProductionYear = seasonInfo.AniDB.AirDate?.Year, + Tags = seasonInfo.Tags.ToArray(), + Genres = seasonInfo.Genres.ToArray(), + Studios = seasonInfo.Studios.ToArray(), + ProductionLocations = TagFilter.GetSeasonContentRating(seasonInfo).ToArray(), + OfficialRating = ContentRating.GetSeasonContentRating(seasonInfo), + CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateModified = DateTime.UtcNow, + DateLastSaved = DateTime.UtcNow, + }; + } + else { + season = new Season { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = seasonNumber, + SortName = sortTitle, + ForcedSortName = sortTitle, + Overview = Text.GetDescription(seasonInfo), + PremiereDate = seasonInfo.AniDB.AirDate, + EndDate = seasonInfo.AniDB.EndDate, + ProductionYear = seasonInfo.AniDB.AirDate?.Year, + Tags = seasonInfo.Tags.ToArray(), + Genres = seasonInfo.Genres.ToArray(), + Studios = seasonInfo.Studios.ToArray(), + ProductionLocations = TagFilter.GetSeasonContentRating(seasonInfo).ToArray(), + OfficialRating = ContentRating.GetSeasonContentRating(seasonInfo), + CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), + }; + } + season.ProviderIds.Add(ShokoSeriesId.Name, seasonInfo.Id); + if (Plugin.Instance.Configuration.AddAniDBId) + season.ProviderIds.Add("AniDB", seasonInfo.AniDB.Id.ToString()); + + return season; + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} + diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs new file mode 100644 index 00000000..b6dbe198 --- /dev/null +++ b/Shokofin/Providers/SeriesProvider.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + public int Order => 0; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<SeriesProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + private readonly IFileSystem FileSystem; + + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) + { + Logger = logger; + HttpClientFactory = httpClientFactory; + ApiManager = apiManager; + FileSystem = fileSystem; + } + + public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) + { + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Series \"{info.Name}\". (Path=\"{info.Path}\")"); + try { + var result = new MetadataResult<Series>(); + var show = await ApiManager.GetShowInfoByPath(info.Path); + if (show == null) { + try { + // Look for the "season" directories to probe for the group information + var entries = FileSystem.GetDirectories(info.Path, false); + foreach (var entry in entries) { + show = await ApiManager.GetShowInfoByPath(entry.FullName); + if (show != null) + break; + } + if (show == null) { + Logger.LogWarning("Unable to find show info for path {Path}", info.Path); + return result; + } + } + catch (DirectoryNotFoundException) { + return result; + } + } + + var (displayTitle, alternateTitle) = Text.GetShowTitles(show, info.MetadataLanguage); + var premiereDate = show.PremiereDate; + var endDate = show.EndDate; + result.Item = new Series { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(show), + PremiereDate = premiereDate, + ProductionYear = premiereDate?.Year, + EndDate = endDate, + Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = show.Tags.ToArray(), + Genres = show.Genres.ToArray(), + Studios = show.Studios.ToArray(), + ProductionLocations = TagFilter.GetShowContentRating(show).ToArray(), + OfficialRating = ContentRating.GetShowContentRating(show), + CustomRating = show.CustomRating, + CommunityRating = show.CommunityRating, + }; + result.HasMetadata = true; + result.ResetPeople(); + foreach (var person in show.Staff) + result.AddPerson(person); + + AddProviderIds(result.Item, show.Id, show.GroupId, show.DefaultSeason.AniDB.Id.ToString()); + + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, show.Id, show.GroupId); + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Series>(); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + public static void AddProviderIds(IHasProviderIds item, string seriesId, string? groupId = null, string? anidbId = null, string? tmdbId = null) + { + item.SetProviderId(MetadataProvider.Custom, $"shoko://shoko-series={seriesId}"); + + var config = Plugin.Instance.Configuration; + item.SetProviderId(ShokoSeriesId.Name, seriesId); + if (!string.IsNullOrEmpty(groupId)) + item.SetProviderId(ShokoGroupId.Name, groupId); + if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") + item.SetProviderId("AniDB", anidbId); + if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tmdb, tmdbId); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} diff --git a/Shokofin/Providers/TrailerProvider.cs b/Shokofin/Providers/TrailerProvider.cs new file mode 100644 index 00000000..706a2bac --- /dev/null +++ b/Shokofin/Providers/TrailerProvider.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +public class TrailerProvider: IRemoteMetadataProvider<Trailer, TrailerInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + // Always run first, so we can react to the VFS entries. + public int Order => -1; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<TrailerProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public TrailerProvider(IHttpClientFactory httpClientFactory, ILogger<TrailerProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Trailer>(); + if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + return result; + } + + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Trailer \"{info.Name}\". (Path=\"{info.Path}\")"); + try { + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); + var episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; + if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { + Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); + return result; + } + + var (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); + var description = Text.GetDescription(episodeInfo); + result.Item = new() + { + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episodeInfo.AniDB.AirDate, + ProductionYear = episodeInfo.AniDB.AirDate?.Year ?? seasonInfo.AniDB.AirDate?.Year, + Overview = description, + CommunityRating = episodeInfo.AniDB.Rating.Value > 0 ? episodeInfo.AniDB.Rating.ToFloat(10) : 0, + }; + Logger.LogInformation("Found trailer {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); + + result.HasMetadata = true; + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Trailer>(); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} diff --git a/Shokofin/Providers/VideoProvider.cs b/Shokofin/Providers/VideoProvider.cs new file mode 100644 index 00000000..31644aa8 --- /dev/null +++ b/Shokofin/Providers/VideoProvider.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +public class VideoProvider: IRemoteMetadataProvider<Video, ItemLookupInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + // Always run first, so we can react to the VFS entries. + public int Order => -1; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<VideoProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public VideoProvider(IHttpClientFactory httpClientFactory, ILogger<VideoProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Video>(); + if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + return result; + } + + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Video \"{info.Name}\". (Path=\"{info.Path}\")"); + try { + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); + var episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; + if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { + Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); + return result; + } + + var (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); + var description = Text.GetDescription(episodeInfo); + result.Item = new() + { + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episodeInfo.AniDB.AirDate, + ProductionYear = episodeInfo.AniDB.AirDate?.Year ?? seasonInfo.AniDB.AirDate?.Year, + Overview = description, + CommunityRating = episodeInfo.AniDB.Rating.Value > 0 ? episodeInfo.AniDB.Rating.ToFloat(10) : 0, + }; + Logger.LogInformation("Found video {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds},Group={GroupId})", result.Item.Name, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds, showInfo?.GroupId); + + result.HasMetadata = true; + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Video>(); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ItemLookupInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} diff --git a/Shokofin/Resolvers/Models/LinkGenerationResult.cs b/Shokofin/Resolvers/Models/LinkGenerationResult.cs new file mode 100644 index 00000000..9c9cfe19 --- /dev/null +++ b/Shokofin/Resolvers/Models/LinkGenerationResult.cs @@ -0,0 +1,99 @@ + +using System; +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Shokofin.Resolvers.Models; + +public class LinkGenerationResult +{ + private DateTime CreatedAt { get; init; } = DateTime.Now; + + public ConcurrentBag<string> Paths { get; init; } = new(); + + public int Total => + TotalVideos + TotalSubtitles; + + public int Created => + CreatedVideos + CreatedSubtitles; + + public int Fixed => + FixedVideos + FixedSubtitles; + + public int Skipped => + SkippedVideos + SkippedSubtitles; + + public int Removed => + RemovedVideos + RemovedSubtitles + RemovedNfos; + + public int TotalVideos => + CreatedVideos + FixedVideos + SkippedVideos; + + public int CreatedVideos { get; set; } + + public int FixedVideos { get; set; } + + public int SkippedVideos { get; set; } + + public int RemovedVideos { get; set; } + + public int TotalSubtitles => + CreatedSubtitles + FixedSubtitles + SkippedSubtitles; + + public int CreatedSubtitles { get; set; } + + public int FixedSubtitles { get; set; } + + public int SkippedSubtitles { get; set; } + + public int RemovedSubtitles { get; set; } + + public int RemovedNfos { get; set; } + + public void Print(ILogger logger, string path) + { + var timeSpent = DateTime.Now - CreatedAt; + logger.LogInformation( + "Created {CreatedTotal} ({CreatedMedia},{CreatedSubtitles}), fixed {FixedTotal} ({FixedMedia},{FixedSubtitles}), skipped {SkippedTotal} ({SkippedMedia},{SkippedSubtitles}), and removed {RemovedTotal} ({RemovedMedia},{RemovedSubtitles},{RemovedNFO}) entries in folder at {Path} in {TimeSpan} (Total={Total})", + Created, + CreatedVideos, + CreatedSubtitles, + Fixed, + FixedVideos, + FixedSubtitles, + Skipped, + SkippedVideos, + SkippedSubtitles, + Removed, + RemovedVideos, + RemovedSubtitles, + RemovedNfos, + path, + timeSpent, + Total + ); + } + + public static LinkGenerationResult operator +(LinkGenerationResult a, LinkGenerationResult b) + { + // Re-use the same instance so the parallel execution will share the same bag. + var paths = a.Paths; + foreach (var path in b.Paths) + a.Paths.Add(path); + + return new() + { + CreatedAt = a.CreatedAt, + Paths = paths, + CreatedVideos = a.CreatedVideos + b.CreatedVideos, + FixedVideos = a.FixedVideos + b.FixedVideos, + SkippedVideos = a.SkippedVideos + b.SkippedVideos, + RemovedVideos = a.RemovedVideos + b.RemovedVideos, + CreatedSubtitles = a.CreatedSubtitles + b.CreatedSubtitles, + FixedSubtitles = a.FixedSubtitles + b.FixedSubtitles, + SkippedSubtitles = a.SkippedSubtitles + b.SkippedSubtitles, + RemovedSubtitles = a.RemovedSubtitles + b.RemovedSubtitles, + RemovedNfos = a.RemovedNfos + b.RemovedNfos, + }; + } +} \ No newline at end of file diff --git a/Shokofin/Resolvers/Models/ShokoWatcher.cs b/Shokofin/Resolvers/Models/ShokoWatcher.cs new file mode 100644 index 00000000..927fc051 --- /dev/null +++ b/Shokofin/Resolvers/Models/ShokoWatcher.cs @@ -0,0 +1,26 @@ + +using System; +using System.IO; +using MediaBrowser.Controller.Entities; +using Shokofin.Configuration; + +namespace Shokofin.Resolvers.Models; + +public class ShokoWatcher +{ + public Folder MediaFolder; + + public MediaFolderConfiguration Configuration; + + public FileSystemWatcher Watcher; + + public IDisposable SubmitterLease; + + public ShokoWatcher(Folder mediaFolder, MediaFolderConfiguration configuration, FileSystemWatcher watcher, IDisposable lease) + { + MediaFolder = mediaFolder; + Configuration = configuration; + Watcher = watcher; + SubmitterLease = lease; + } +} diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs new file mode 100644 index 00000000..ffe2391e --- /dev/null +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -0,0 +1,216 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Naming.Common; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.Configuration; +using Shokofin.Utils; + +namespace Shokofin.Resolvers; +#pragma warning disable CS8766 + +public class ShokoIgnoreRule : IResolverIgnoreRule +{ + private readonly ILogger<ShokoIgnoreRule> Logger; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ShokoAPIManager ApiManager; + + private readonly MediaFolderConfigurationService ConfigurationService; + + private readonly NamingOptions NamingOptions; + + public ShokoIgnoreRule( + ILogger<ShokoIgnoreRule> logger, + IIdLookup lookup, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ShokoAPIManager apiManager, + MediaFolderConfigurationService configurationService, + NamingOptions namingOptions + ) + { + Lookup = lookup; + Logger = logger; + LibraryManager = libraryManager; + FileSystem = fileSystem; + ApiManager = apiManager; + ConfigurationService = configurationService; + NamingOptions = namingOptions; + } + + public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) + { + // Check if the parent is not made yet, or the file info is missing. + if (parent is null || fileInfo is null) + return false; + + // Check if the root is not made yet. This should **never** be false at + // this point in time, but if it is, then bail. + var root = LibraryManager.RootFolder; + if (root is null || parent.Id == root.Id) + return false; + + // Assume anything within the VFS is already okay. + if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + return false; + + Guid? trackerId = null; + try { + // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. + if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) + return false; + + trackerId = Plugin.Instance.Tracker.Add($"Should ignore path \"{fileInfo.FullName}\"."); + if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); + return true; + } + + if (!fileInfo.IsDirectory && !NamingOptions.VideoFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); + return false; + } + + var fullPath = fileInfo.FullName; + var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent); + + // Ignore any media folders that aren't mapped to shoko. + var mediaFolderConfig = ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder); + if (!mediaFolderConfig.IsMapped) { + Logger.LogDebug("Skipped media folder for path {Path} (MediaFolder={MediaFolderId})", fileInfo.FullName, mediaFolderConfig.MediaFolderId); + return false; + } + + // Filter out anything in the media folder if the VFS is enabled, + // because the VFS is pre-filtered, and we should **never** reach + // this point except for the folders in the root of the media folder + // that we're not even going to use. + if (mediaFolderConfig.IsVirtualFileSystemEnabled) + return true; + + var shouldIgnore = mediaFolderConfig.LibraryFilteringMode switch { + Ordering.LibraryFilteringMode.Strict => true, + Ordering.LibraryFilteringMode.Lax => false, + // Ordering.LibraryFilteringMode.Auto => + _ => mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider, + }; + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + if (fileInfo.IsDirectory) + return await ShouldFilterDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); + + return await ShouldFilterFile(partialPath, fullPath, shouldIgnore).ConfigureAwait(false); + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } + finally { + if (trackerId.HasValue) + Plugin.Instance.Tracker.Remove(trackerId.Value); + } + } + + private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPath, CollectionType? collectionType, bool shouldIgnore) + { + var season = await ApiManager.GetSeasonInfoByPath(fullPath).ConfigureAwait(false); + + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. + if (season == null) { + // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. + if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length is 1) { + try { + var entries = FileSystem.GetDirectories(fullPath, false).ToList(); + Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); + foreach (var entry in entries) { + season = await ApiManager.GetSeasonInfoByPath(entry.FullName).ConfigureAwait(false); + if (season is not null) { + Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId},ExtraSeries={ExtraIds})", season.Shoko.Name, partialPath, season.Id, season.ExtraIds); + break; + } + } + } + catch (DirectoryNotFoundException) { } + } + if (season is null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); + return shouldIgnore; + } + } + + // Filter library if we enabled the option. + var isMovieSeason = season.Type is SeriesType.Movie; + switch (collectionType) { + case CollectionType.tvshows: + if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) { + Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); + return true; + } + break; + case CollectionType.movies: + if (!isMovieSeason) { + Logger.LogInformation("Found show in movie library, ignoring shoko series. (Series={SeriesId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); + return true; + } + break; + } + + var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false)!; + if (!string.IsNullOrEmpty(show!.GroupId)) + Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},ExtraSeries={ExtraIds},Group={GroupId})", show.Name, season.Id, season.ExtraIds, show.GroupId); + else + Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId},ExtraSeries={ExtraIds})", season.Shoko.Name, season.Id, season.ExtraIds); + + return false; + } + + private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, bool shouldIgnore) + { + var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath).ConfigureAwait(false); + + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. + if (file is null || season is null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown file at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); + return shouldIgnore; + } + + Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},ExtraSeries={ExtraIds},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, season.ExtraIds, file.Id); + + // We're going to post process this file later, but we don't want to include it in our library for now. + if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) { + Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},ExtraSeries={ExtraIds},File={FileId})", season.Id, season.ExtraIds, file.Id); + return true; + } + + return false; + } + + #region IResolverIgnoreRule Implementation + + bool IResolverIgnoreRule.ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) + => ShouldFilterItem(parent as Folder, fileInfo) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + #endregion +} diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs new file mode 100644 index 00000000..1aedf429 --- /dev/null +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Common; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Configuration; +using Shokofin.Configuration.Models; +using Shokofin.Events; +using Shokofin.Events.Interfaces; +using Shokofin.Events.Stub; +using Shokofin.ExternalIds; +using Shokofin.Resolvers.Models; +using Shokofin.Utils; + +using ApiException = Shokofin.API.Models.ApiException; + +namespace Shokofin.Resolvers; + +public class ShokoLibraryMonitor : IHostedService +{ + private readonly ILogger<ShokoLibraryMonitor> Logger; + + private readonly ShokoAPIClient ApiClient; + + private readonly EventDispatchService Events; + + private readonly MediaFolderConfigurationService ConfigurationService; + + private readonly ILibraryManager LibraryManager; + + private readonly ILibraryMonitor LibraryMonitor; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + private readonly NamingOptions NamingOptions; + + private readonly GuardedMemoryCache Cache; + + private readonly ConcurrentDictionary<string, ShokoWatcher> FileSystemWatchers = new(); + + /// <summary> + /// A delay so magical it will give Shoko Server some time to finish it's + /// rename/move operation before we ask it if it knows the path. + /// </summary> + private const int MagicalDelay = 5000; // 5 seconds in milliseconds… for now. + + // follow the core jf behavior, but use config added/removed instead of library added/removed. + + public ShokoLibraryMonitor( + ILogger<ShokoLibraryMonitor> logger, + ShokoAPIClient apiClient, + EventDispatchService events, + MediaFolderConfigurationService configurationService, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor, + LibraryScanWatcher libraryScanWatcher, + NamingOptions namingOptions + ) + { + Logger = logger; + ApiClient = apiClient; + Events = events; + ConfigurationService = configurationService; + ConfigurationService.ConfigurationAdded += OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationUpdated += OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationRemoved += OnMediaFolderConfigurationRemoved; + LibraryManager = libraryManager; + LibraryMonitor = libraryMonitor; + LibraryScanWatcher = libraryScanWatcher; + LibraryScanWatcher.ValueChanged += OnLibraryScanRunningChanged; + NamingOptions = namingOptions; + Cache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromSeconds(30) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(1) }); + } + + ~ShokoLibraryMonitor() + { + ConfigurationService.ConfigurationAdded -= OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationUpdated -= OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationRemoved -= OnMediaFolderConfigurationRemoved; + LibraryScanWatcher.ValueChanged -= OnLibraryScanRunningChanged; + } + + Task IHostedService.StartAsync(CancellationToken cancellationToken) + { + StartWatching(); + return Task.CompletedTask; + } + + Task IHostedService.StopAsync(CancellationToken cancellationToken) + { + StopWatching(); + return Task.CompletedTask; + } + + public void StartWatching() + { + // add blockers/watchers for every media folder with VFS enabled and real time monitoring enabled. + foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders.ToList()) { + if (LibraryManager.GetItemById(mediaConfig.MediaFolderId) is not Folder mediaFolder) + continue; + + var libraryOptions = LibraryManager.GetLibraryOptions(mediaFolder); + if (libraryOptions != null && libraryOptions.EnableRealtimeMonitor && mediaConfig.IsVirtualFileSystemEnabled) + StartWatchingMediaFolder(mediaFolder, mediaConfig); + } + } + + public void StopWatching() + { + foreach (var path in FileSystemWatchers.Keys.ToList()) + StopWatchingPath(path); + } + + private void OnLibraryScanRunningChanged(object? sender, bool isScanRunning) + { + if (isScanRunning) + StopWatching(); + else + StartWatching(); + } + + private void OnMediaFolderConfigurationAddedOrUpdated(object? sender, MediaConfigurationChangedEventArgs eventArgs) + { + // Don't add/remove watchers during a scan. + if (LibraryScanWatcher.IsScanRunning) + return; + + var libraryOptions = LibraryManager.GetLibraryOptions(eventArgs.MediaFolder); + if (libraryOptions != null && libraryOptions.EnableRealtimeMonitor && eventArgs.Configuration.IsVirtualFileSystemEnabled) + StartWatchingMediaFolder(eventArgs.MediaFolder, eventArgs.Configuration); + else + StopWatchingPath(eventArgs.MediaFolder.Path); + } + + private void OnMediaFolderConfigurationRemoved(object? sender, MediaConfigurationChangedEventArgs eventArgs) + { + // Don't add/remove watchers during a scan. + if (LibraryScanWatcher.IsScanRunning) + return; + + StopWatchingPath(eventArgs.MediaFolder.Path); + } + + private void StartWatchingMediaFolder(Folder mediaFolder, MediaFolderConfiguration config) + { + // Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do it in parallel. + Task.Run(() => { + try { + var watcher = new FileSystemWatcher(mediaFolder.Path, "*") { + IncludeSubdirectories = true, + InternalBufferSize = 65536, + NotifyFilter = NotifyFilters.CreationTime | + NotifyFilters.DirectoryName | + NotifyFilters.FileName | + NotifyFilters.LastWrite | + NotifyFilters.Size | + NotifyFilters.Attributes + }; + + watcher.Created += OnWatcherChanged; + watcher.Deleted += OnWatcherChanged; + watcher.Renamed += OnWatcherChanged; + watcher.Changed += OnWatcherChanged; + watcher.Error += OnWatcherError; + + var lease = Events.RegisterEventSubmitter(); + if (FileSystemWatchers.TryAdd(mediaFolder.Path, new(mediaFolder, config, watcher, lease))) { + LibraryMonitor.ReportFileSystemChangeBeginning(mediaFolder.Path); + watcher.EnableRaisingEvents = true; + Logger.LogInformation("Watching directory {Path}", mediaFolder.Path); + } + else { + lease.Dispose(); + DisposeWatcher(watcher, false); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Error watching path: {Path}", mediaFolder.Path); + } + }); + } + + private void StopWatchingPath(string path) + { + if (FileSystemWatchers.TryGetValue(path, out var watcher)) + { + DisposeWatcher(watcher.Watcher, true); + } + } + + private void DisposeWatcher(FileSystemWatcher watcher, bool removeFromList = true) + { + try + { + using (watcher) + { + Logger.LogInformation("Stopping directory watching for path {Path}", watcher.Path); + + watcher.Created -= OnWatcherChanged; + watcher.Deleted -= OnWatcherChanged; + watcher.Renamed -= OnWatcherChanged; + watcher.Changed -= OnWatcherChanged; + watcher.Error -= OnWatcherError; + + watcher.EnableRaisingEvents = false; + } + } + finally + { + if (removeFromList && FileSystemWatchers.TryRemove(watcher.Path, out var shokoWatcher)) { + LibraryMonitor.ReportFileSystemChangeComplete(watcher.Path, false); + shokoWatcher.SubmitterLease.Dispose(); + } + } + } + + private void OnWatcherError(object sender, ErrorEventArgs eventArgs) + { + var ex = eventArgs.GetException(); + if (sender is not FileSystemWatcher watcher) + return; + + Logger.LogError(ex, "Error in Directory watcher for: {Path}", watcher.Path); + + DisposeWatcher(watcher); + } + + private void OnWatcherChanged(object? sender, FileSystemEventArgs e) + { + try + { + if (sender is not FileSystemWatcher watcher || !FileSystemWatchers.TryGetValue(watcher.Path, out var shokoWatcher)) + return; + Task.Run(() => ReportFileSystemChanged(shokoWatcher.Configuration, e.ChangeType, e.FullPath)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Exception in ReportFileSystemChanged. Path: {FullPath}", e.FullPath); + } + } + + public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, WatcherChangeTypes changeTypes, string path) + { + Logger.LogTrace("Found potential path with change {ChangeTypes}; {Path}", changeTypes, path); + + if (!path.StartsWith(mediaConfig.MediaFolderPath)) { + Logger.LogTrace("Skipped path because it is not in the watched folder; {Path}", path); + return; + } + + if (!IsVideoFile(path)) { + Logger.LogTrace("Skipped path because it is not a video file; {Path}", path); + return; + } + + await Task.Delay(MagicalDelay).ConfigureAwait(false); + + if (changeTypes is not WatcherChangeTypes.Deleted && !File.Exists(path)) { + Logger.LogTrace("Skipped path because it is disappeared after awhile before we could process it; {Path}", path); + return; + } + + // Using a "cache" here is more to ensure we only run for the same path once in a given time span. + await Cache.GetOrCreateAsync( + path, + (_) => Logger.LogTrace("Skipped path because it was handled within a second ago; {Path}", path), + async () => { + string? fileId = null; + IFileEventArgs eventArgs; + var reason = changeTypes is WatcherChangeTypes.Deleted ? UpdateReason.Removed : changeTypes is WatcherChangeTypes.Created ? UpdateReason.Added : UpdateReason.Updated; + var relativePath = path[mediaConfig.MediaFolderPath.Length..]; + var trackerId = Plugin.Instance.Tracker.Add($"Library Monitor: Path=\"{path}\""); + try { + var files = await ApiClient.GetFileByPath(relativePath); + var file = files.FirstOrDefault(file => file.Locations.Any(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath)); + if (file is null) { + if (reason is not UpdateReason.Removed) { + Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); + return null; + } + if (LibraryManager.FindByPath(path, false) is not Video video) { + Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); + return null; + } + if (!video.ProviderIds.TryGetValue(ShokoFileId.Name, out fileId)) { + Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); + return null; + } + // It may throw an ApiException with 404 here, + file = await ApiClient.GetFile(fileId); + } + + var fileLocation = file.Locations.First(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath); + eventArgs = new FileEventArgsStub(fileLocation, file); + } + // which we catch here. + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + if (fileId is null) { + Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); + return null; + } + + Logger.LogTrace("Failed to get file info from Shoko during a file deleted event. (File={FileId})", fileId); + eventArgs = new FileEventArgsStub(int.Parse(fileId), null, mediaConfig.ImportFolderId, relativePath, Array.Empty<IFileEventArgs.FileCrossReference>()); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + + Logger.LogDebug( + "File {EventName}; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", + reason, + eventArgs.ImportFolderId, + relativePath, + eventArgs.FileId, + eventArgs.FileLocationId, + true + ); + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return null; + } + + Events.AddFileEvent(eventArgs.FileId, reason, eventArgs.ImportFolderId, relativePath, eventArgs); + return eventArgs; + } + ); + } + + private bool IsVideoFile(string path) + => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path)); +} diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs new file mode 100644 index 00000000..9c5fc7eb --- /dev/null +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Naming.Common; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.ExternalIds; + +using File = System.IO.File; +using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; +using TvSeries = MediaBrowser.Controller.Entities.TV.Series; + +namespace Shokofin.Resolvers; +#pragma warning disable CS8768 + +public class ShokoResolver : IItemResolver, IMultiItemResolver +{ + private readonly ILogger<ShokoResolver> Logger; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ShokoAPIManager ApiManager; + + private readonly VirtualFileSystemService ResolveManager; + + private readonly NamingOptions NamingOptions; + + public ShokoResolver( + ILogger<ShokoResolver> logger, + IIdLookup lookup, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ShokoAPIManager apiManager, + VirtualFileSystemService resolveManager, + NamingOptions namingOptions + ) + { + Logger = logger; + Lookup = lookup; + LibraryManager = libraryManager; + FileSystem = fileSystem; + ApiManager = apiManager; + ResolveManager = resolveManager; + NamingOptions = namingOptions; + } + + public async Task<BaseItem?> ResolveSingle(Folder? parent, CollectionType? collectionType, FileSystemMetadata fileInfo) + { + if (!(collectionType is CollectionType.tvshows or CollectionType.movies or null) || parent is null || fileInfo is null) + return null; + + var root = LibraryManager.RootFolder; + if (root is null || parent == root) + return null; + + Guid? trackerId = null; + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; + + // Skip anything outside the VFS. + if (!fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + return null; + + if (parent.GetTopParent() is not Folder mediaFolder) + return null; + + trackerId = Plugin.Instance.Tracker.Add($"Resolve path \"{fileInfo.FullName}\"."); + var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) + return null; + + if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { + if (!fileInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + return null; + + return new TvSeries() { + Path = fileInfo.FullName, + }; + } + + return null; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } + finally { + if (trackerId.HasValue) + Plugin.Instance.Tracker.Remove(trackerId.Value); + } + } + + public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, CollectionType? collectionType, List<FileSystemMetadata> fileInfoList) + { + if (!(collectionType is CollectionType.tvshows or CollectionType.movies or null) || parent is null) + return null; + + var root = LibraryManager.RootFolder; + if (root is null || parent == root) + return null; + + Guid? trackerId = null; + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; + + if (parent.GetTopParent() is not Folder mediaFolder) + return null; + + trackerId = Plugin.Instance.Tracker.Add($"Resolve children of \"{parent.Path}\". (Children={fileInfoList.Count})"); + var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) + return null; + + // Redirect children of a VFS managed media folder to the VFS. + if (parent.IsTopParent) { + var createMovies = collectionType is CollectionType.movies || (collectionType is null && Plugin.Instance.Configuration.SeparateMovies); + var pathsToRemoveBag = new ConcurrentBag<(string, bool)>(); + var items = FileSystem.GetDirectories(vfsPath) + .AsParallel() + .SelectMany(dirInfo => { + if (!dirInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + return Array.Empty<BaseItem>(); + + var season = ApiManager.GetSeasonInfoForSeries(seriesId) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + if (season is null) { + pathsToRemoveBag.Add((dirInfo.FullName, true)); + return Array.Empty<BaseItem>(); + } + + if (createMovies && season.Type is SeriesType.Movie) { + return FileSystem.GetFiles(dirInfo.FullName) + .AsParallel() + .Select(fileInfo => { + // Only allow the video files, since the subtitle files also have the ids set. + if (!NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) + return null; + + if (!VirtualFileSystemService.TryGetIdsForPath(fileInfo.FullName, out seriesId, out var fileId)) + return null; + + // This will hopefully just re-use the pre-cached entries from the cache, but it may + // also get it from remote if the cache was emptied for whatever reason. + var file = ApiManager.GetFileInfo(fileId, seriesId) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + // Abort if the file was not recognized. + if (file is null) { + pathsToRemoveBag.Add((fileInfo.FullName, false)); + return null; + } + + // Or if it's a recognized extra. + if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) + return null; + + return new Movie() { + Path = fileInfo.FullName, + } as BaseItem; + }) + .ToArray(); + } + + return new BaseItem[1] { + new TvSeries() { + Path = dirInfo.FullName, + }, + }; + }) + .OfType<BaseItem>() + .ToList(); + + if (!pathsToRemoveBag.IsEmpty) { + var start = DateTime.Now; + var pathsToRemove = pathsToRemoveBag.ToArray().DistinctBy(tuple => tuple.Item1).ToList(); + Logger.LogDebug("Cleaning up {Count} removed entries in {Path}", pathsToRemove.Count, mediaFolder.Path); + foreach (var (pathToRemove, isDirectory) in pathsToRemove) { + try { + if (isDirectory) { + Logger.LogTrace("Removing directory: {Path}", pathToRemove); + Directory.Delete(pathToRemove, true); + Logger.LogTrace("Removed directory: {Path}", pathToRemove); + + } + else { + Logger.LogTrace("Removing file: {Path}", pathToRemove); + File.Delete(pathToRemove); + Logger.LogTrace("Removed file: {Path}", pathToRemove); + } + } + catch (Exception ex) { + Logger.LogTrace(ex, "Failed to remove "); + } + } + var deltaTime = DateTime.Now - start; + Logger.LogDebug("Cleaned up {Count} removed entries in {Time}", pathsToRemove.Count, deltaTime); + } + + // TODO: uncomment the code snippet once we reach JF 10.10. + // return new() { Items = items, ExtraFiles = new() }; + + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. + if (!items.Any(i => i is Movie) && items.Count > 0) { + fileInfoList.Clear(); + fileInfoList.AddRange(items.OrderBy(s => int.Parse(s.Path.GetAttributeValue(ShokoSeriesId.Name)!)).Select(s => FileSystem.GetFileSystemInfo(s.Path))); + } + + return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; + } + + return null; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } + finally { + if (trackerId.HasValue) + Plugin.Instance.Tracker.Remove(trackerId.Value); + } + } + + #region IItemResolver + + ResolverPriority IItemResolver.Priority => ResolverPriority.Plugin; + + BaseItem? IItemResolver.ResolvePath(ItemResolveArgs args) + => ResolveSingle(args.Parent, args.CollectionType, args.FileInfo) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + #endregion + + #region IMultiItemResolver + + MultiItemResolverResult? IMultiItemResolver.ResolveMultiple(Folder parent, List<FileSystemMetadata> files, CollectionType? collectionType, IDirectoryService directoryService) + => ResolveMultiple(parent, collectionType, files) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + #endregion +} diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs new file mode 100644 index 00000000..16f23060 --- /dev/null +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -0,0 +1,1071 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Common; +using Emby.Naming.ExternalFiles; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.Configuration; +using Shokofin.ExternalIds; +using Shokofin.Resolvers.Models; +using Shokofin.Utils; + +using File = System.IO.File; + +namespace Shokofin.Resolvers; + +public class VirtualFileSystemService +{ + private readonly ShokoAPIManager ApiManager; + + private readonly ShokoAPIClient ApiClient; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ILogger<VirtualFileSystemService> Logger; + + private readonly MediaFolderConfigurationService ConfigurationService; + + private readonly NamingOptions NamingOptions; + + private readonly ExternalPathParser ExternalPathParser; + + private readonly GuardedMemoryCache DataCache; + + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 characters. + private const int NameCutOff = 64; + + private static readonly HashSet<string> IgnoreFolderNames = [ + "backdrops", + "behind the scenes", + "deleted scenes", + "interviews", + "scenes", + "samples", + "shorts", + "featurettes", + "clips", + "other", + "extras", + "trailers", + ]; + + public VirtualFileSystemService( + ShokoAPIManager apiManager, + ShokoAPIClient apiClient, + MediaFolderConfigurationService configurationService, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger<VirtualFileSystemService> logger, + ILocalizationManager localizationManager, + NamingOptions namingOptions + ) + { + ApiManager = apiManager; + ApiClient = apiClient; + ConfigurationService = configurationService; + LibraryManager = libraryManager; + FileSystem = fileSystem; + Logger = logger; + DataCache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), SlidingExpiration = TimeSpan.FromMinutes(15) }); + NamingOptions = namingOptions; + ExternalPathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Subtitle); + LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; + Plugin.Instance.Tracker.Stalled += OnTrackerStalled; + } + + ~VirtualFileSystemService() + { + LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + Plugin.Instance.Tracker.Stalled -= OnTrackerStalled; + DataCache.Dispose(); + } + + private void OnTrackerStalled(object? sender, EventArgs eventArgs) + => Clear(); + + public void Clear() + { + Logger.LogDebug("Clearing data…"); + DataCache.Clear(); + } + + #region Changes Tracking + + private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) + { + // Remove the VFS directory for any media library folders when they're removed. + var root = LibraryManager.RootFolder; + if (e.Item != null && root != null && e.Item != root && e.Item is CollectionFolder folder) { + var vfsPath = folder.GetVirtualRoot(); + DataCache.Remove($"should-skip-vfs-path:{vfsPath}"); + if (Directory.Exists(vfsPath)) { + Logger.LogInformation("Removing VFS directory for folder at {Path}", folder.Path); + Directory.Delete(vfsPath, true); + Logger.LogInformation("Removed VFS directory for folder at {Path}", folder.Path); + } + } + } + + #endregion + + #region Generate Structure + + /// <summary> + /// Generates the VFS structure if the VFS is enabled for the <paramref name="mediaFolder"/>. + /// </summary> + /// <param name="mediaFolder">The media folder to generate a structure for.</param> + /// <param name="path">The file or folder within the media folder to generate a structure for.</param> + /// <returns>The VFS path, if it succeeded.</returns> + public async Task<(string?, bool)> GenerateStructureInVFS(Folder mediaFolder, string path) + { + var (vfsPath, mainMediaFolderPath, collectionType, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, config => config.IsVirtualFileSystemEnabled); + if (string.IsNullOrEmpty(vfsPath) || string.IsNullOrEmpty(mainMediaFolderPath) || mediaConfigs.Count is 0) + return (null, false); + + if (!Plugin.Instance.CanCreateSymbolicLinks) + throw new Exception("Windows users are required to enable Developer Mode then restart Jellyfin to be able to create symbolic links, a feature required to use the VFS."); + + // Skip link generation if we've already generated for the library. + if (DataCache.TryGetValue<bool>($"should-skip-vfs-path:{vfsPath}", out var shouldReturnPath)) + return ( + shouldReturnPath ? vfsPath : null, + path.StartsWith(vfsPath + Path.DirectorySeparatorChar) || path == mainMediaFolderPath + ); + + // Check full path and all parent directories if they have been indexed. + if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).Prepend(vfsPath).ToArray(); + while (pathSegments.Length > 1) { + var subPath = Path.Join(pathSegments); + if (DataCache.TryGetValue<bool>($"should-skip-vfs-path:{subPath}", out _)) + return (vfsPath, true); + pathSegments = pathSegments.SkipLast(1).ToArray(); + } + } + + // Only do this once. + var key = mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) + ? $"should-skip-vfs-path:{vfsPath}" + : $"should-skip-vfs-path:{path}"; + shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async () => { + // Iterate the files already in the VFS. + string? pathToClean = null; + IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; + if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + var allPaths = GetPathsForMediaFolder(mediaConfigs); + var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); + switch (pathSegments.Length) { + // show/movie-folder level + case 1: { + var seriesName = pathSegments[0]; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + // movie-folder + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId) ) { + if (!int.TryParse(episodeId, out _)) + break; + + pathToClean = path; + allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfigs, allPaths); + break; + } + + // show + pathToClean = path; + allFiles = GetFilesForShow(seriesId, null, mediaConfigs, allPaths); + break; + } + + // season/movie level + case 2: { + var (seriesName, seasonOrMovieName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + // movie + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out _)) { + if (!seasonOrMovieName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; + + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfigs, allPaths); + break; + } + + // "season" or extras + if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) + break; + + pathToClean = path; + allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfigs, allPaths); + break; + } + + // episodes level + case 3: { + var (seriesName, seasonName, episodeName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out _)) + break; + + if (!episodeName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; + + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfigs, allPaths); + break; + } + } + } + // Iterate files in the "real" media folder. + else if (mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath))) { + var allPaths = GetPathsForMediaFolder(mediaConfigs); + pathToClean = vfsPath; + allFiles = GetFilesForImportFolder(mediaConfigs, allPaths); + } + + if (allFiles is null) + return false; + + // Generate and cleanup the structure in the VFS. + var result = await GenerateStructure(collectionType, vfsPath, allFiles); + if (!string.IsNullOrEmpty(pathToClean)) + result += CleanupStructure(vfsPath, pathToClean, result.Paths.ToArray()); + + // Save which paths we've already generated so we can skip generation + // for them and their sub-paths later, and also print the result. + result.Print(Logger, mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) ? vfsPath : path); + + return true; + }); + + return ( + shouldReturnPath ? vfsPath : null, + path.StartsWith(vfsPath + Path.DirectorySeparatorChar) || path == mainMediaFolderPath + ); + } + + private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs) + { + var libraryId = mediaConfigs[0].LibraryId; + Logger.LogDebug("Looking for files in library across {Count} folders. (Library={LibraryId})", mediaConfigs.Count, libraryId); + var start = DateTime.UtcNow; + var paths = new HashSet<string>(); + foreach (var mediaConfig in mediaConfigs) { + Logger.LogDebug("Looking for files in folder at {Path}. (Library={LibraryId})", mediaConfig.MediaFolderPath, libraryId); + var folderStart = DateTime.UtcNow; + var before = paths.Count; + paths.UnionWith( + FileSystem.GetFilePaths(mediaConfig.MediaFolderPath, true) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + ); + Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}. (Library={LibraryId})", paths.Count - before, mediaConfig.MediaFolderPath, DateTime.UtcNow - folderStart, libraryId); + } + + Logger.LogDebug("Found {FileCount} files in library across {Count} in {TimeSpan}. (Library={LibraryId})", paths.Count, mediaConfigs.Count, DateTime.UtcNow - start, libraryId); + return paths; + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) + { + var totalFiles = 0; + var start = DateTime.UtcNow; + var file = ApiClient.GetFile(fileId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (file is null || !file.CrossReferences.Any(xref => xref.Series.ToString() == seriesId)) + yield break; + Logger.LogDebug( + "Iterating files to potentially use within {Count} media folders. (File={FileId},Series={SeriesId},Library={LibraryId})", + mediaConfigs.Count, + fileId, + seriesId, + mediaConfigs[0].LibraryId + ); + + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in mediaConfigs.ToImportFolderList()) { + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location is null) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, fileId, seriesId); + goto forLoopBreak; + } + + continue; + forLoopBreak: break; + } + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {Count} file(s) to potentially use within {Count} media folders in {TimeSpan} (File={FileId},Series={SeriesId},Library={LibraryId})", + totalFiles, + mediaConfigs.Count, + timeSpent, + fileId, + seriesId, + mediaConfigs[0].LibraryId + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) + { + var start = DateTime.UtcNow; + var totalFiles = 0; + var seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (seasonInfo is null) + yield break; + Logger.LogDebug( + "Iterating files to potentially use within {Count} media folders. (Episode={EpisodeId},Series={SeriesId},Library={LibraryId})", + mediaConfigs.Count, + episodeId, + seriesId, + mediaConfigs[0].LibraryId + ); + + var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(tuple => tuple.file.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) + .ToList(); + foreach (var (file, fileSeriesId, location) in fileLocations) { + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in mediaConfigs.ToImportFolderList()) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } + + continue; + forLoopBreak: break; + } + } + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {Count} file(s) to potentially use within {Count} media folders in {TimeSpan} (Episode={EpisodeId},Series={SeriesId},Library={LibraryId})", + totalFiles, + mediaConfigs.Count, + timeSpent, + episodeId, + seriesId, + mediaConfigs[0].LibraryId + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) + { + var start = DateTime.UtcNow; + var showInfo = ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (showInfo is null) + yield break; + Logger.LogDebug( + "Iterating files to potentially use within {Count} media folders. (Series={SeriesId},Season={SeasonNumber},Library={LibraryId})", + mediaConfigs.Count, + seriesId, + seasonNumber, + mediaConfigs[0].LibraryId + ); + + // Only return the files for the given season. + var totalFiles = 0; + var configList = mediaConfigs.ToImportFolderList(); + if (seasonNumber.HasValue) { + // Special handling of specials (pun intended) + if (seasonNumber.Value is 0) { + foreach (var seasonInfo in showInfo.SeasonList) { + var episodeIds = seasonInfo.SpecialsList.Select(episode => episode.Id).ToHashSet(); + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(tuple => tuple.file.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) + .ToList(); + foreach (var (file, fileSeriesId, location) in fileLocations) { + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in configList) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } + + continue; + forLoopBreak: break; + } + } + } + } + // All other seasons. + else { + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber.Value); + if (seasonInfo != null) { + var baseNumber = showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); + var offset = seasonNumber.Value - baseNumber; + var episodeIds = (offset is 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(tuple => tuple.file.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) + .ToList(); + foreach (var (file, fileSeriesId, location) in fileLocations) { + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in configList) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } + + continue; + forLoopBreak: break; + } + } + } + } + } + // Return all files for the show. + else { + foreach (var seasonInfo in showInfo.SeasonList) { + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) + .ToList(); + foreach (var (file, fileSeriesId, location) in fileLocations) { + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in configList) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } + + continue; + forLoopBreak: break; + } + } + } + } + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {FileCount} files to potentially use within {Count} media folders in {TimeSpan} (Series={SeriesId},Season={SeasonNumber},Library={LibraryId})", + totalFiles, + mediaConfigs.Count, + timeSpent, + seriesId, + seasonNumber, + mediaConfigs[0].LibraryId + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) + { + var start = DateTime.UtcNow; + var singleSeriesIds = new HashSet<int>(); + var multiSeriesFiles = new List<(API.Models.File, string)>(); + var totalSingleSeriesFiles = 0; + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in mediaConfigs.ToImportFolderList()) { + var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); + var pageData = firstPage + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + var totalPages = pageData.List.Count == pageData.Total ? 1 : (int)Math.Ceiling((float)pageData.Total / pageData.List.Count); + Logger.LogDebug( + "Iterating ≤{FileCount} files to potentially use within media folder at {Path} by checking {TotalCount} matches. (ImportFolder={FolderId},RelativePath={RelativePath},PageSize={PageSize},TotalPages={TotalPages})", + fileSet.Count, + mediaFolderPaths, + pageData.Total, + importFolderId, + importFolderSubPath, + pageData.List.Count == pageData.Total ? null : pageData.List.Count, + totalPages + ); + + // Ensure at most 5 pages are in-flight at any given time, until we're done fetching the pages. + var semaphore = new SemaphoreSlim(5); + var pages = new List<Task<ListResult<API.Models.File>>>() { firstPage }; + for (var page = 2; page <= totalPages; page++) + pages.Add(GetImportFolderFilesPage(importFolderId, importFolderSubPath, page, semaphore)); + + do { + var task = Task.WhenAny(pages).ConfigureAwait(false).GetAwaiter().GetResult(); + pages.Remove(task); + semaphore.Release(); + pageData = task.Result; + + Logger.LogTrace( + "Iterating page {PageNumber} with size {PageSize} (ImportFolder={FolderId},RelativePath={RelativePath})", + totalPages - pages.Count, + pageData.List.Count, + importFolderId, + importFolderSubPath + ); + foreach (var file in pageData.List) { + if (file.CrossReferences.Count is 0) + continue; + + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location is null) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + // Yield all single-series files now, and offset the processing of all multi-series files for later. + var seriesIds = file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.All(e => e.Shoko.HasValue)).Select(x => x.Series.Shoko!.Value).ToHashSet(); + if (seriesIds.Count is 1) { + totalSingleSeriesFiles++; + singleSeriesIds.Add(seriesIds.First()); + foreach (var seriesId in seriesIds) + yield return (sourceLocation, file.Id.ToString(), seriesId.ToString()); + } + else if (seriesIds.Count > 1) { + multiSeriesFiles.Add((file, sourceLocation)); + } + break; + } + } + } while (pages.Count > 0); + } + + // Check which series of the multiple series we have, and only yield + // the paths for the series we have. This will fail if an OVA episode is + // linked to both the OVA and e.g. a specials for the TV Series. + var totalMultiSeriesFiles = 0; + if (multiSeriesFiles.Count > 0) { + var mappedSingleSeriesIds = singleSeriesIds + .Select(seriesId => + ApiManager.GetShowInfoForSeries(seriesId.ToString()) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult()?.Id + ) + .OfType<string>() + .ToHashSet(); + foreach (var (file, sourceLocation) in multiSeriesFiles) { + var seriesIds = file.CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .Distinct() + .Select(seriesId => ( + seriesId, + showId: ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult()?.Id + )) + .Where(tuple => !string.IsNullOrEmpty(tuple.showId) && mappedSingleSeriesIds.Contains(tuple.showId)) + .Select(tuple => tuple.seriesId) + .ToList(); + foreach (var seriesId in seriesIds) + yield return (sourceLocation, file.Id.ToString(), seriesId); + totalMultiSeriesFiles += seriesIds.Count; + } + } + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {FileCount} ({MultiFileCount}→{MultiFileCount}) files to potentially use within {Count} media folders in {TimeSpan} (Library={LibraryId})", + totalSingleSeriesFiles, + multiSeriesFiles.Count, + totalMultiSeriesFiles, + mediaConfigs.Count, + timeSpent, + mediaConfigs[0].LibraryId + ); + } + + private async Task<ListResult<API.Models.File>> GetImportFolderFilesPage(int importFolderId, string importFolderSubPath, int page, SemaphoreSlim semaphore) + { + await semaphore.WaitAsync().ConfigureAwait(false); + return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); + } + + private async Task<LinkGenerationResult> GenerateStructure(CollectionType? collectionType, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) + { + var result = new LinkGenerationResult(); + var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VFS_Threads); + await Task.WhenAll(allFiles.Select(async (tuple) => { + await semaphore.WaitAsync().ConfigureAwait(false); + + try { + Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); + + var (sourceLocation, symbolicLinks, importedAt) = await GenerateLocationsForFile(collectionType, vfsPath, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + + // Skip any source files we weren't meant to have in the library. + if (string.IsNullOrEmpty(sourceLocation) || !importedAt.HasValue) + return; + + var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, importedAt.Value); + + // Combine the current results with the overall results. + lock (semaphore) { + result += subResult; + } + } + finally { + semaphore.Release(); + } + })) + .ConfigureAwait(false); + + return result; + } + + public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(CollectionType? collectionType, string vfsPath, string sourceLocation, string fileId, string seriesId) + { + var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); + if (season is null) + return (string.Empty, [], null); + + var isMovieSeason = season.Type is SeriesType.Movie; + var config = Plugin.Instance.Configuration; + var shouldAbort = collectionType switch { + CollectionType.tvshows => isMovieSeason && config.SeparateMovies, + CollectionType.movies => !isMovieSeason, + _ => false, + }; + if (shouldAbort) + return (string.Empty, [], null); + + var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false); + if (show is null) + return (string.Empty, [], null); + + var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); + var (episode, episodeXref, _) = (file?.EpisodeList ?? []).FirstOrDefault(); + if (file is null || episode is null) + return (string.Empty, [], null); + + if (season is null || episode is null) + return (string.Empty, [], null); + + var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; + var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); + var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); + + // For those **really** long names we have to cut if off at some point… + if (showName.Length >= NameCutOff) + showName = showName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + if (episodeName.Length >= NameCutOff) + episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + + var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode)); + var folders = new List<string>(); + var extrasFolders = file.ExtraType switch { + null => isExtra ? new string[] { "extras" } : null, + ExtraType.ThemeSong => ["theme-music"], + ExtraType.ThemeVideo => config.AddCreditsAsThemeVideos && config.AddCreditsAsSpecialFeatures + ? ["backdrops", "extras"] + : config.AddCreditsAsThemeVideos + ? ["backdrops"] + : config.AddCreditsAsSpecialFeatures + ? ["extras"] + : [], + ExtraType.Trailer => config.AddTrailers + ? ["trailers"] + : [], + ExtraType.BehindTheScenes => ["behind the scenes"], + ExtraType.DeletedScene => ["deleted scenes"], + ExtraType.Clip => ["clips"], + ExtraType.Interview => ["interviews"], + ExtraType.Scene => ["scenes"], + ExtraType.Sample => ["samples"], + _ => ["extras"], + }; + var filePartSuffix = (episodeXref.Percentage?.Group ?? 1) is not 1 + ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Group == episodeXref.Percentage!.Group).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" + : ""; + if (isMovieSeason && collectionType is not CollectionType.tvshows) { + if (extrasFolders != null) { + foreach (var extrasFolder in extrasFolders) + foreach (var episodeInfo in season.EpisodeList) + folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); + } + else { + folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episode.Id}]")); + episodeName = "Movie"; + } + } + else { + var isSpecial = show.IsSpecial(episode); + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); + var seasonFolder = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; + var showFolder = $"{showName} [{ShokoSeriesId.Name}={show.Id}]"; + if (extrasFolders != null) { + foreach (var extrasFolder in extrasFolders) { + folders.Add(Path.Join(vfsPath, showFolder, extrasFolder)); + + // Only place the extra within the season if we have a season number assigned to the episode. + if (seasonNumber is not 0) + folders.Add(Path.Join(vfsPath, showFolder, seasonFolder, extrasFolder)); + } + } + else { + folders.Add(Path.Join(vfsPath, showFolder, seasonFolder)); + episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}{filePartSuffix}"; + } + } + + var extraDetails = new List<string>(); + if (config.VFS_AddReleaseGroup) + extraDetails.Add( + file.Shoko.AniDBData is not null + ? !string.IsNullOrEmpty(file.Shoko.AniDBData.ReleaseGroup.Name) + ? file.Shoko.AniDBData.ReleaseGroup.Name + : !string.IsNullOrEmpty(file.Shoko.AniDBData.ReleaseGroup.ShortName) + ? file.Shoko.AniDBData.ReleaseGroup.ShortName + : $"Release group {file.Shoko.AniDBData.ReleaseGroup.Id}" + : "No Group" + ); + if (config.VFS_AddResolution && !string.IsNullOrEmpty(file.Shoko.Resolution)) + extraDetails.Add(file.Shoko.Resolution); + var fileName = $"{episodeName} {(extraDetails.Count is > 0 ? $"[{extraDetails.Join("] [")}] " : "")}[{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{Path.GetExtension(sourceLocation)}"; + var symbolicLinks = folders + .Select(folderPath => Path.Join(folderPath, fileName)) + .ToArray(); + + foreach (var symbolicLink in symbolicLinks) + ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id)); + return (sourceLocation, symbolicLinks, (file.Shoko.ImportedAt ?? file.Shoko.CreatedAt).ToLocalTime()); + } + + public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) + { + try { + var result = new LinkGenerationResult(); + var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; + var subtitleLinks = FindSubtitlesForPath(sourceLocation); + foreach (var symbolicLink in symbolicLinks) { + var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; + if (!Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); + + result.Paths.Add(symbolicLink); + if (!File.Exists(symbolicLink)) { + result.CreatedVideos++; + Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + // Mock the creation date to fake the "date added" order in Jellyfin. + File.SetCreationTime(symbolicLink, importedAt); + } + else { + var shouldFix = false; + try { + var nextTarget = File.ResolveLinkTarget(symbolicLink, false); + if (!string.Equals(sourceLocation, nextTarget?.FullName)) { + shouldFix = true; + + Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); + } + var date = File.GetCreationTime(symbolicLink).ToLocalTime(); + if (date != importedAt) { + shouldFix = true; + + Logger.LogWarning("Fixing broken symbolic link {Link} with incorrect date.", symbolicLink); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicLink); + shouldFix = true; + } + if (shouldFix) { + File.Delete(symbolicLink); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + // Mock the creation date to fake the "date added" order in Jellyfin. + File.SetCreationTime(symbolicLink, importedAt); + result.FixedVideos++; + } + else { + result.SkippedVideos++; + } + } + + if (subtitleLinks.Count > 0) { + var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); + foreach (var subtitleSource in subtitleLinks) { + var extName = subtitleSource[sourcePrefixLength..]; + var subtitleLink = Path.Join(symbolicDirectory, symbolicName + extName); + + result.Paths.Add(subtitleLink); + if (!File.Exists(subtitleLink)) { + result.CreatedSubtitles++; + Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } + else { + var shouldFix = false; + try { + var nextTarget = File.ResolveLinkTarget(subtitleLink, false); + if (!string.Equals(subtitleSource, nextTarget?.FullName)) { + shouldFix = true; + + Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", subtitleLink, subtitleSource, nextTarget?.FullName); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); + shouldFix = true; + } + if (shouldFix) { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + result.FixedSubtitles++; + } + else { + result.SkippedSubtitles++; + } + } + } + } + } + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "An error occurred while trying to generate {LinkCount} links for {SourceLocation}; {ErrorMessage}", symbolicLinks.Length, sourceLocation, ex.Message); + throw; + } + } + + private List<string> FindSubtitlesForPath(string sourcePath) + { + var externalPaths = new List<string>(); + var folderPath = Path.GetDirectoryName(sourcePath); + if (string.IsNullOrEmpty(folderPath) || !FileSystem.DirectoryExists(folderPath)) + return externalPaths; + + var files = FileSystem.GetFilePaths(folderPath) + .Except(new[] { sourcePath }) + .ToList(); + var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); + foreach (var file in files) { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); + if ( + fileNameWithoutExtension.Length >= sourcePrefix.Length && + sourcePrefix.Equals(fileNameWithoutExtension[..sourcePrefix.Length], StringComparison.OrdinalIgnoreCase) && + (fileNameWithoutExtension.Length == sourcePrefix.Length || NamingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[sourcePrefix.Length])) + ) { + var externalPathInfo = ExternalPathParser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); + if (externalPathInfo is not null && !string.IsNullOrEmpty(externalPathInfo.Path)) + externalPaths.Add(externalPathInfo.Path); + } + } + + return externalPaths; + } + + private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths) + { + Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); + var start = DateTime.Now; + var previousStep = start; + var result = new LinkGenerationResult(); + var searchFiles = NamingOptions.VideoFileExtensions.Concat(NamingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); + var toBeRemoved = FileSystem.GetFilePaths(directoryToClean, true) + .Select(path => (path, extName: Path.GetExtension(path))) + .Where(tuple => !string.IsNullOrEmpty(tuple.extName) && searchFiles.Contains(tuple.extName)) + .ExceptBy(allKnownPaths.ToHashSet(), tuple => tuple.path) + .ToList(); + + var nextStep = DateTime.Now; + Logger.LogDebug("Found {FileCount} files to remove in {DirectoryToClean} in {TimeSpent}", toBeRemoved.Count, directoryToClean, nextStep - previousStep); + previousStep = nextStep; + + foreach (var (location, extName) in toBeRemoved) { + if (extName is ".nfo") { + try { + Logger.LogTrace("Removing NFO file at {Path}", location); + File.Delete(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); + continue; + } + result.RemovedNfos++; + } + else if (NamingOptions.SubtitleFileExtensions.Contains(extName)) { + if (TryMoveSubtitleFile(allKnownPaths, location)) { + result.FixedSubtitles++; + continue; + } + + try { + Logger.LogTrace("Removing subtitle file at {Path}", location); + File.Delete(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); + continue; + } + result.RemovedSubtitles++; + } + else { + if (ShouldIgnoreVideo(vfsPath, location)) { + result.SkippedVideos++; + continue; + } + + try { + Logger.LogTrace("Removing video file at {Path}", location); + File.Delete(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); + continue; + } + result.RemovedVideos++; + } + } + + nextStep = DateTime.Now; + Logger.LogTrace("Removed {FileCount} files in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", result.Removed, directoryToClean, nextStep - previousStep, nextStep - start); + previousStep = nextStep; + + var cleaned = 0; + var directoriesToClean = toBeRemoved + .SelectMany(tuple => { + var path = Path.GetDirectoryName(tuple.path); + var paths = new List<(string path, int level)>(); + while (!string.IsNullOrEmpty(path)) { + var level = path == directoryToClean ? 0 : path[(directoryToClean.Length + 1)..].Split(Path.DirectorySeparatorChar).Length; + paths.Add((path, level)); + if (path == directoryToClean) + break; + path = Path.GetDirectoryName(path); + } + return paths; + }) + .DistinctBy(tuple => tuple.path) + .OrderByDescending(tuple => tuple.level) + .ThenBy(tuple => tuple.path) + .Select(tuple => tuple.path) + .ToList(); + + nextStep = DateTime.Now; + Logger.LogDebug("Found {DirectoryCount} directories to potentially clean in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", toBeRemoved.Count, directoryToClean, nextStep - previousStep, nextStep - start); + previousStep = nextStep; + + foreach (var directoryPath in directoriesToClean) { + if (Directory.Exists(directoryPath) && !Directory.EnumerateFileSystemEntries(directoryPath).Any()) { + Logger.LogTrace("Removing empty directory at {Path}", directoryPath); + Directory.Delete(directoryPath); + cleaned++; + } + } + + Logger.LogTrace("Cleaned {CleanedCount} directories in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", cleaned, directoryToClean, nextStep - previousStep, nextStep - start); + + return result; + } + + private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, string subtitlePath) + { + if (!TryGetIdsForPath(subtitlePath, out var seriesId, out var fileId)) + return false; + + var symbolicLink = allKnownPaths.FirstOrDefault(knownPath => TryGetIdsForPath(knownPath, out var knownSeriesId, out var knownFileId) && seriesId == knownSeriesId && fileId == knownFileId); + if (string.IsNullOrEmpty(symbolicLink)) + return false; + + var sourcePathWithoutExt = symbolicLink[..^Path.GetExtension(symbolicLink).Length]; + if (!subtitlePath.StartsWith(sourcePathWithoutExt)) + return false; + + var extName = subtitlePath[sourcePathWithoutExt.Length..]; + string? realTarget = null; + try { + realTarget = File.ResolveLinkTarget(symbolicLink, false)?.FullName; + } + catch { } + if (string.IsNullOrEmpty(realTarget)) + return false; + + var realSubtitlePath = realTarget[..^Path.GetExtension(realTarget).Length] + extName; + if (!File.Exists(realSubtitlePath)) + File.Move(subtitlePath, realSubtitlePath); + else + File.Delete(subtitlePath); + File.CreateSymbolicLink(subtitlePath, realSubtitlePath); + + return true; + } + + private static bool ShouldIgnoreVideo(string vfsPath, string path) + { + // Ignore the video if it's within one of the folders to potentially ignore _and_ it doesn't have any shoko ids set. + var parentDirectories = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).SkipLast(1).ToArray(); + return parentDirectories.Length > 1 && IgnoreFolderNames.Contains(parentDirectories.Last()) && !TryGetIdsForPath(path, out _, out _); + } + + public static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string? seriesId, [NotNullWhen(true)] out string? fileId) + { + var fileName = Path.GetFileNameWithoutExtension(path); + if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out fileId) || !int.TryParse(fileId, out _) || + !fileName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) { + seriesId = null; + fileId = null; + return false; + } + + return true; + } + + #endregion +} diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj new file mode 100644 index 00000000..88e66e47 --- /dev/null +++ b/Shokofin/Shokofin.csproj @@ -0,0 +1,33 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <OutputType>Library</OutputType> + <SignalRVersion>8.0.3</SignalRVersion> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="AsyncKeyedLock" Version="6.4.2" /> + <PackageReference Include="Jellyfin.Controller" Version="10.9.0" /> + <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="$(SignalRVersion)" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> + </ItemGroup> + + <Target Name="CopySignalRDLLsToOutputPath" AfterTargets="Build"> + <ItemGroup> + <BasePackage Include="$(NuGetPackageRoot)\microsoft.aspnetcore.signalr.client\$(SignalRVersion)\lib\$(TargetFramework)\Microsoft.AspNetCore.SignalR.Client.dll" /> + <CorePackage Include="$(NuGetPackageRoot)\microsoft.aspnetcore.signalr.client.core\$(SignalRVersion)\lib\$(TargetFramework)\Microsoft.AspNetCore.SignalR.Client.Core.dll" /> + <HttpPackage Include="$(NuGetPackageRoot)\microsoft.aspnetcore.http.connections.client\$(SignalRVersion)\lib\$(TargetFramework)\Microsoft.AspNetCore.Http.Connections.Client.dll" /> + </ItemGroup> + <Copy SourceFiles="@(BasePackage)" DestinationFolder="$(OutputPath)" /> + <Copy SourceFiles="@(CorePackage)" DestinationFolder="$(OutputPath)" /> + <Copy SourceFiles="@(HttpPackage)" DestinationFolder="$(OutputPath)" /> + </Target> + + <ItemGroup> + <None Remove="Configuration\configController.js" /> + <None Remove="Configuration\configPage.html" /> + <EmbeddedResource Include="Configuration\configController.js" /> + <EmbeddedResource Include="Configuration\configPage.html" /> + </ItemGroup> +</Project> diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs new file mode 100644 index 00000000..5af67540 --- /dev/null +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Data.Enums; +using Shokofin.Events.Interfaces; + +namespace Shokofin.SignalR.Models; + +public class EpisodeInfoUpdatedEventArgs : IMetadataUpdatedEventArgs +{ + /// <summary> + /// The update reason. + /// </summary> + [JsonInclude, JsonPropertyName("Reason")] + public UpdateReason Reason { get; set; } + + /// <summary> + /// The provider metadata source. + /// </summary> + [JsonInclude, JsonPropertyName("Source")] + public ProviderName ProviderName { get; set; } = ProviderName.None; + + /// <summary> + /// The provided metadata episode id. + /// </summary> + [JsonInclude, JsonPropertyName("EpisodeID")] + public int ProviderId { get; set; } + + /// <summary> + /// The provided metadata series id. + /// </summary> + [JsonInclude, JsonPropertyName("SeriesID")] + public int ProviderParentId { get; set; } + + /// <summary> + /// Shoko episode ids affected by this update. + /// </summary> + [JsonInclude, JsonPropertyName("ShokoEpisodeIDs")] + public List<int> EpisodeIds { get; set; } = new(); + + /// <summary> + /// Shoko series ids affected by this update. + /// </summary> + [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] + public List<int> SeriesIds { get; set; } = new(); + + /// <summary> + /// Shoko group ids affected by this update. + /// </summary> + [JsonInclude, JsonPropertyName("ShokoGroupIDs")] + public List<int> GroupIds { get; set; } = new(); + + #region IMetadataUpdatedEventArgs Impl. + + BaseItemKind IMetadataUpdatedEventArgs.Kind => BaseItemKind.Episode; + + int? IMetadataUpdatedEventArgs.ProviderParentId => ProviderParentId; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => EpisodeIds; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.GroupIds => GroupIds; + + #endregion +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs new file mode 100644 index 00000000..f2fb1a26 --- /dev/null +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Shokofin.Events.Interfaces; + +namespace Shokofin.SignalR.Models; + +public class FileEventArgs : IFileEventArgs +{ + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileID")] + public int FileId { get; set; } + + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileLocationID")] + public int? FileLocationId { get; set; } + + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } + + /// <summary> + /// The relative path with no leading slash and directory separators used on + /// the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("RelativePath")] + public string InternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } + + /// <inheritdoc/> + [JsonIgnore] + public bool HasCrossReferences { get; set; } = false; + + /// <inheritdoc/> + [JsonIgnore] + public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = new(); + +#pragma warning disable IDE0051 + /// <summary> + /// Current cross-references of episodes linked to this file. Only present + /// for setting the cross-references when deserializing JSON. + /// </summary> + [JsonInclude, JsonPropertyName("CrossReferences")] + public List<IFileEventArgs.FileCrossReference> CurrentCrossReferences { get => CrossReferences; set { HasCrossReferences = true; CrossReferences = value; } } + + /// <summary> + /// Legacy cross-references of episodes linked to this file. Only present + /// for setting the cross-references when deserializing JSON. + /// </summary> + [JsonInclude, JsonPropertyName("CrossRefs")] + public List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { get => CrossReferences; set { HasCrossReferences = true; CrossReferences = value; } } +#pragma warning restore IDE0051 +} diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs new file mode 100644 index 00000000..e97234c4 --- /dev/null +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Shokofin.Events.Interfaces; + +namespace Shokofin.SignalR.Models; + + +public class FileMovedEventArgs: FileEventArgs, IFileRelocationEventArgs +{ + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("PreviousImportFolderID")] + public int PreviousImportFolderId { get; set; } + + /// <summary> + /// The previous relative path with no leading slash and directory + /// separators used on the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("PreviousRelativePath")] + public string PreviousInternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? PreviousCachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string PreviousRelativePath + { + get + { + if (PreviousCachedPath != null) + return PreviousCachedPath; + var relativePath = PreviousInternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return PreviousCachedPath = relativePath; + } + } + + public class V0 : IFileRelocationEventArgs + { + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileID")] + public int FileId { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public int? FileLocationId => null; + + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("NewImportFolderID")] + public int ImportFolderId { get; set; } + + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("OldImportFolderID")] + public int PreviousImportFolderId { get; set; } + + /// <summary> + /// The relative path with no leading slash and directory separators used on + /// the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("NewRelativePath")] + public string InternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } + + /// <summary> + /// The previous relative path with no leading slash and directory + /// separators used on the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("OldRelativePath")] + public string PreviousInternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? PreviousCachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string PreviousRelativePath + { + get + { + if (PreviousCachedPath != null) + return PreviousCachedPath; + var relativePath = PreviousInternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return PreviousCachedPath = relativePath; + } + } + + /// <inheritdoc/> + [JsonIgnore] + public bool HasCrossReferences => false; + + /// <inheritdoc/> + [JsonIgnore] + public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); + } +} diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs new file mode 100644 index 00000000..e027d062 --- /dev/null +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Shokofin.Events.Interfaces; + +namespace Shokofin.SignalR.Models; + + +public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs +{ + /// <summary> + /// The current file name. + /// </summary> + [JsonInclude, JsonPropertyName("FileName")] + public string FileName { get; set; } = string.Empty; + + /// <summary> + /// The previous file name. + /// </summary> + [JsonInclude, JsonPropertyName("PreviousFileName")] + public string PreviousFileName { get; set; } = string.Empty; + + /// <inheritdoc/> + [JsonIgnore] + public int PreviousImportFolderId => ImportFolderId; + + /// <inheritdoc/> + [JsonIgnore] + public string PreviousRelativePath => RelativePath[..^FileName.Length] + PreviousFileName; + + public class V0 : IFileRelocationEventArgs + { + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileID")] + public int FileId { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public int? FileLocationId => null; + + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } + + /// <summary> + /// The relative path with no leading slash and directory separators used on + /// the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("RelativePath")] + public string InternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } + + /// <summary> + /// The current file name. + /// </summary> + [JsonInclude, JsonPropertyName("NewFileName")] + public string FileName { get; set; } = string.Empty; + + /// <summary> + /// The previous file name. + /// </summary> + [JsonInclude, JsonPropertyName("OldFileName")] + public string PreviousFileName { get; set; } = string.Empty; + + /// <inheritdoc/> + [JsonIgnore] + public int PreviousImportFolderId => ImportFolderId; + + /// <inheritdoc/> + [JsonIgnore] + public string PreviousRelativePath => RelativePath[..^FileName.Length] + PreviousFileName; + + /// <inheritdoc/> + [JsonIgnore] + public bool HasCrossReferences => false; + + /// <inheritdoc/> + [JsonIgnore] + public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); + } +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs new file mode 100644 index 00000000..5bd28159 --- /dev/null +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Data.Enums; +using Shokofin.Events.Interfaces; + +namespace Shokofin.SignalR.Models; + +public class SeriesInfoUpdatedEventArgs : IMetadataUpdatedEventArgs +{ + /// <summary> + /// The update reason. + /// </summary> + [JsonInclude, JsonPropertyName("Reason")] + public UpdateReason Reason { get; set; } + + /// <summary> + /// The provider metadata source. + /// </summary> + [JsonInclude, JsonPropertyName("Source")] + public ProviderName ProviderName { get; set; } = ProviderName.None; + + /// <summary> + /// The provided metadata series id. + /// </summary> + [JsonInclude, JsonPropertyName("SeriesID")] + public int ProviderId { get; set; } + + /// <summary> + /// Shoko series ids affected by this update. + /// </summary> + [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] + public List<int> SeriesIds { get; set; } = new(); + + /// <summary> + /// Shoko group ids affected by this update. + /// </summary> + [JsonInclude, JsonPropertyName("ShokoGroupIDs")] + public List<int> GroupIds { get; set; } = new(); + + #region IMetadataUpdatedEventArgs Impl. + + BaseItemKind IMetadataUpdatedEventArgs.Kind => BaseItemKind.Series; + + int? IMetadataUpdatedEventArgs.ProviderParentId => null; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => new List<int>(); + + IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.GroupIds => GroupIds; + + #endregion +} \ No newline at end of file diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs new file mode 100644 index 00000000..ef75619c --- /dev/null +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -0,0 +1,324 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shokofin.API.Models; +using Shokofin.Configuration; +using Shokofin.Events; +using Shokofin.Events.Interfaces; +using Shokofin.SignalR.Models; +using Shokofin.Utils; + +namespace Shokofin.SignalR; + +public class SignalRConnectionManager +{ + private static ComponentVersion? ServerVersion => + Plugin.Instance.Configuration.ServerVersion; + + private static readonly DateTime EventChangedDate = DateTime.Parse("2024-04-01T04:04:00.000Z"); + + private static bool UseOlderEvents => + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < EventChangedDate)); + + private const string HubUrl = "/signalr/aggregate?feeds=shoko"; + + private readonly ILogger<SignalRConnectionManager> Logger; + + private readonly EventDispatchService Events; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + private IDisposable? EventSubmitterLease = null; + + private HubConnection? Connection = null; + + private string CachedKey = string.Empty; + +#pragma warning disable CA1822 + public bool IsUsable => CanConnect(Plugin.Instance.Configuration); +#pragma warning restore CA1822 + + public bool IsActive => Connection != null; + + public HubConnectionState State => Connection == null ? HubConnectionState.Disconnected : Connection.State; + + public SignalRConnectionManager( + ILogger<SignalRConnectionManager> logger, + EventDispatchService events, + LibraryScanWatcher libraryScanWatcher + ) + { + Logger = logger; + Events = events; + LibraryScanWatcher = libraryScanWatcher; + } + + #region Connection + + private async Task ConnectAsync(PluginConfiguration config) + { + if (Connection != null || !CanConnect(config)) + return; + + var builder = new HubConnectionBuilder() + .WithUrl(config.Url + HubUrl, connectionOptions => + connectionOptions.AccessTokenProvider = () => Task.FromResult<string?>(config.ApiKey) + ) + .AddJsonProtocol(); + + if (config.SignalR_AutoReconnectInSeconds.Length > 0) + builder = builder.WithAutomaticReconnect(config.SignalR_AutoReconnectInSeconds.Select(seconds => TimeSpan.FromSeconds(seconds)).ToArray()); + + var connection = Connection = builder.Build(); + + connection.Closed += OnDisconnected; + connection.Reconnecting += OnReconnecting; + connection.Reconnected += OnReconnected; + + // Attach refresh events. + connection.On<EpisodeInfoUpdatedEventArgs>("ShokoEvent:EpisodeUpdated", OnInfoUpdated); + connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnInfoUpdated); + + // Attach file events. + connection.On<FileEventArgs>("ShokoEvent:FileMatched", OnFileMatched); + connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); + if (UseOlderEvents) { + connection.On<FileMovedEventArgs.V0>("ShokoEvent:FileMoved", OnFileRelocated); + connection.On<FileRenamedEventArgs.V0>("ShokoEvent:FileRenamed", OnFileRelocated); + } + else { + connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileRelocated); + connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRelocated); + } + + EventSubmitterLease = Events.RegisterEventSubmitter(); + try { + await connection.StartAsync().ConfigureAwait(false); + + Logger.LogInformation("Connected to Shoko Server."); + } + catch (Exception ex) { + Logger.LogError(ex, "Unable to connect to Shoko Server at this time. Please reconnect manually."); + await DisconnectAsync().ConfigureAwait(false); + } + } + + private Task OnReconnected(string? connectionId) + { + Logger.LogInformation("Reconnected to Shoko Server. (Connection={ConnectionId})", connectionId); + return Task.CompletedTask; + } + + private Task OnReconnecting(Exception? exception) + { + Logger.LogWarning(exception, "Disconnected from Shoko Server. Attempting to reconnect…"); + return Task.CompletedTask; + } + + private Task OnDisconnected(Exception? exception) + { + // Graceful disconnection. + if (exception == null) + Logger.LogInformation("Gracefully disconnected from Shoko Server."); + else + Logger.LogWarning(exception, "Abruptly disconnected from Shoko Server."); + return Task.CompletedTask; + } + + public async Task DisconnectAsync() + { + if (Connection == null) + return; + + var connection = Connection; + Connection = null; + + if (connection.State != HubConnectionState.Disconnected) + await connection.StopAsync(); + + await connection.DisposeAsync(); + + if (EventSubmitterLease is not null) { + EventSubmitterLease.Dispose(); + EventSubmitterLease = null; + } + } + + public Task ResetConnectionAsync() + => ResetConnectionAsync(Plugin.Instance.Configuration, true); + + private void ResetConnection(PluginConfiguration config, bool shouldConnect) + => ResetConnectionAsync(config, shouldConnect).ConfigureAwait(false).GetAwaiter().GetResult(); + + private async Task ResetConnectionAsync(PluginConfiguration config, bool shouldConnect) + { + await DisconnectAsync(); + if (shouldConnect) + await ConnectAsync(config); + } + + public async Task RunAsync() + { + var config = Plugin.Instance.Configuration; + CachedKey = ConstructKey(config); + Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; + + await ResetConnectionAsync(config, config.SignalR_AutoConnectEnabled); + } + + public async Task StopAsync() + { + Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + await DisconnectAsync(); + } + + private void OnConfigurationChanged(object? sender, PluginConfiguration config) + { + var currentKey = ConstructKey(config); + if (!string.Equals(currentKey, CachedKey)) + { + Logger.LogDebug("Detected change in SignalR configuration! (Config={Config})", currentKey); + CachedKey = currentKey; + ResetConnection(config, Connection != null); + } + } + + private static bool CanConnect(PluginConfiguration config) + => !string.IsNullOrEmpty(config.Url) && !string.IsNullOrEmpty(config.ApiKey) && config.ServerVersion != null; + + private static string ConstructKey(PluginConfiguration config) + => $"CanConnect={CanConnect(config)},AutoReconnect={config.SignalR_AutoReconnectInSeconds.Select(s => s.ToString()).Join(',')}"; + + #endregion + + #region Events + + #region File Events + + private void OnFileMatched(IFileEventArgs eventArgs) + { + Logger.LogDebug( + "File matched; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", + eventArgs.ImportFolderId, + eventArgs.RelativePath, + eventArgs.FileId, + eventArgs.FileLocationId, + eventArgs.HasCrossReferences + ); + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return; + } + + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Updated, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + } + + private void OnFileRelocated(IFileRelocationEventArgs eventArgs) + { + Logger.LogDebug( + "File relocated; {ImportFolderIdA} {PathA} → {ImportFolderIdB} {PathB} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", + eventArgs.PreviousImportFolderId, + eventArgs.PreviousRelativePath, + eventArgs.ImportFolderId, + eventArgs.RelativePath, + eventArgs.FileId, + eventArgs.FileLocationId, + eventArgs.HasCrossReferences + ); + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return; + } + + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.PreviousImportFolderId, eventArgs.PreviousRelativePath, eventArgs); + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Added, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + } + + private void OnFileDeleted(IFileEventArgs eventArgs) + { + Logger.LogDebug( + "File deleted; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", + eventArgs.ImportFolderId, + eventArgs.RelativePath, + eventArgs.FileId, + eventArgs.FileLocationId, + eventArgs.HasCrossReferences + ); + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return; + } + + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + } + + #endregion + + #region Refresh Events + + private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) + { + if (Plugin.Instance.Configuration.SignalR_EventSources.Contains(eventArgs.ProviderName)) { + Logger.LogTrace( + "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) skipped event with {UpdateReason}; provider not is not enabled in the plugin settings. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + eventArgs.ProviderName, + eventArgs.Kind, + eventArgs.ProviderId, + eventArgs.ProviderParentId, + eventArgs.Reason, + eventArgs.EpisodeIds, + eventArgs.SeriesIds, + eventArgs.GroupIds + ); + return; + } + + Logger.LogDebug( + "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) dispatched event with {UpdateReason}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + eventArgs.ProviderName, + eventArgs.Kind, + eventArgs.ProviderId, + eventArgs.ProviderParentId, + eventArgs.Reason, + eventArgs.EpisodeIds, + eventArgs.SeriesIds, + eventArgs.GroupIds + ); + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of refresh event. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + eventArgs.EpisodeIds, + eventArgs.SeriesIds, + eventArgs.GroupIds + ); + return; + } + + if (eventArgs.Kind is BaseItemKind.Episode or BaseItemKind.Series) + Events.AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); + } + + #endregion + + #endregion +} \ No newline at end of file diff --git a/Shokofin/SignalR/SignalREntryPoint.cs b/Shokofin/SignalR/SignalREntryPoint.cs new file mode 100644 index 00000000..9b4f9c22 --- /dev/null +++ b/Shokofin/SignalR/SignalREntryPoint.cs @@ -0,0 +1,19 @@ + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace Shokofin.SignalR; + +public class SignalREntryPoint : IHostedService +{ + private readonly SignalRConnectionManager ConnectionManager; + + public SignalREntryPoint(SignalRConnectionManager connectionManager) => ConnectionManager = connectionManager; + + public Task StopAsync(CancellationToken cancellationToken) + => ConnectionManager.StopAsync(); + + public Task StartAsync(CancellationToken cancellationToken) + => ConnectionManager.RunAsync(); +} diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs new file mode 100644 index 00000000..5494e58f --- /dev/null +++ b/Shokofin/StringExtensions.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.RegularExpressions; +using MediaBrowser.Common.Providers; + +namespace Shokofin; + +public static class StringExtensions +{ + public static string Replace(this string input, Regex regex, string replacement, int count, int startAt) + => regex.Replace(input, replacement, count, startAt); + + public static string Replace(this string input, Regex regex, MatchEvaluator evaluator, int count, int startAt) + => regex.Replace(input, evaluator, count, startAt); + + public static string Replace(this string input, Regex regex, MatchEvaluator evaluator, int count) + => regex.Replace(input, evaluator, count); + + public static string Replace(this string input, Regex regex, MatchEvaluator evaluator) + => regex.Replace(input, evaluator); + + public static string Replace(this string input, Regex regex, string replacement) + => regex.Replace(input, replacement); + + public static string Replace(this string input, Regex regex, string replacement, int count) + => regex.Replace(input, replacement, count); + + public static void Deconstruct(this IList<string> list, out string first) + { + first = list.Count > 0 ? list[0] : string.Empty; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second) + { + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second, out string third) + { + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; + third = list.Count > 2 ? list[2] : string.Empty; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second, out string third, out string forth) + { + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; + third = list.Count > 2 ? list[2] : string.Empty; + forth = list.Count > 3 ? list[3] : string.Empty; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second, out string third, out string forth, out string fifth) + { + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; + third = list.Count > 2 ? list[2] : string.Empty; + forth = list.Count > 3 ? list[3] : string.Empty; + fifth = list.Count > 4 ? list[4] : string.Empty; + } + + 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 Join(this IEnumerable<string> list, char separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + public static string Join(this IEnumerable<string> list, string? separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + public static string Join(this IEnumerable<char> list, char separator) + => string.Join(separator, list); + + public static string Join(this IEnumerable<char> list, string? separator) + => string.Join(separator, list); + + public static string Join(this IEnumerable<char> list, char separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + public static string Join(this IEnumerable<char> list, string? separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + private static char? IsAllowedCharacter(this char c) + => c == 32 || c > 47 && c < 58 || c > 64 && c < 91 || c > 96 && c < 123 ? c : '_'; + + public static string ForceASCII(this string value) + => value.Select(c => c.IsAllowedCharacter()).OfType<char>().Join(""); + + private static string CompactUnderscore(this string path) + => Regex.Replace(path, @"_{2,}", "_", RegexOptions.Singleline); + + public static string CompactWhitespaces(this string path) + => Regex.Replace(path, @"\s{2,}", " ", RegexOptions.Singleline); + + public static string ReplaceInvalidPathCharacters(this string path) + => path.ForceASCII().CompactUnderscore().CompactWhitespaces().Trim(); + + /// <summary> + /// Gets the attribute value for <paramref name="attribute"/> in <paramref name="text"/>. + /// </summary> + /// <remarks> + /// Borrowed and adapted from the following URL, since the extension is not exposed to the plugins. + /// https://github.com/jellyfin/jellyfin/blob/25abe479ebe54a341baa72fd07e7d37cefe21a20/Emby.Server.Implementations/Library/PathExtensions.cs#L19-L62 + /// </remarks> + /// <param name="text">The string to extract the attribute value from.</param> + /// <param name="attribute">The attribute name to extract.</param> + /// <returns>The extracted attribute value, or null.</returns> + /// <exception cref="ArgumentException"><paramref name="text" /> or <paramref name="attribute" /> is empty.</exception> + public static string? GetAttributeValue(this string text, string attribute) + { + if (text.Length == 0) + throw new ArgumentException("String can't be empty.", nameof(text)); + + if (attribute.Length == 0) + throw new ArgumentException("String can't be empty.", nameof(attribute)); + + // Must be at least 3 characters after the attribute =, ], any character, + // then we offset it by 1, because we want the index and not length. + var attributeIndex = text.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + var maxIndex = text.Length - attribute.Length - 2; + while (attributeIndex > -1 && attributeIndex < maxIndex) + { + var attributeEnd = attributeIndex + attribute.Length; + if ( + attributeIndex > 0 && + text[attributeIndex - 1] == '[' && + (text[attributeEnd] == '=' || text[attributeEnd] == '-') + ) { + // Must be at least 1 character before the closing bracket. + var closingIndex = text[attributeEnd..].IndexOf(']'); + if (closingIndex > 1) + return text[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + } + + text = text[attributeEnd..]; + attributeIndex = text.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + } + + // for IMDb we also accept pattern matching + if ( + attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) && + ProviderIdParsers.TryFindImdbId(text, out var imdbId) + ) + return imdbId.ToString(); + + return null; + } + + public static bool TryGetAttributeValue(this string text, string attribute, [NotNullWhen(true)] out string? value) + => !string.IsNullOrEmpty(value = GetAttributeValue(text, attribute)); +} \ No newline at end of file diff --git a/Shokofin/Sync/SyncDirection.cs b/Shokofin/Sync/SyncDirection.cs new file mode 100644 index 00000000..fc3882e1 --- /dev/null +++ b/Shokofin/Sync/SyncDirection.cs @@ -0,0 +1,24 @@ +using System; + +namespace Shokofin.Sync; + +/// <summary> +/// Determines if we should push or pull the data. +/// /// </summary> +[Flags] +public enum SyncDirection { + /// <summary> + /// Import data from Shoko. + /// </summary> + Import = 1, + /// <summary> + /// Export data to Shoko. + /// </summary> + Export = 2, + /// <summary> + /// Sync data with Shoko and only keep the latest data. + /// <br/> + /// This will conditionally import or export the data as needed. + /// </summary> + Sync = 3, +} diff --git a/Shokofin/Sync/SyncExtensions.cs b/Shokofin/Sync/SyncExtensions.cs new file mode 100644 index 00000000..936ea01d --- /dev/null +++ b/Shokofin/Sync/SyncExtensions.cs @@ -0,0 +1,43 @@ + +using System; +using MediaBrowser.Controller.Entities; +using Shokofin.API.Models; + +namespace Shokofin.Sync; + +public static class SyncExtensions +{ + public static File.UserStats ToFileUserStats(this UserItemData userData) + { + TimeSpan? resumePosition = new TimeSpan(userData.PlaybackPositionTicks); + if (Math.Floor(resumePosition.Value.TotalMilliseconds) == 0d) + resumePosition = null; + var lastUpdated = userData.LastPlayedDate ?? DateTime.Now; + return new File.UserStats + { + LastUpdatedAt = lastUpdated, + LastWatchedAt = userData.Played ? lastUpdated : null, + ResumePosition = resumePosition, + WatchedCount = userData.PlayCount, + }; + } + + public static UserItemData MergeWithFileUserStats(this UserItemData userData, File.UserStats userStats) + { + userData.Played = userStats.LastWatchedAt.HasValue; + userData.PlayCount = userStats.WatchedCount; + userData.PlaybackPositionTicks = userStats.ResumePosition?.Ticks ?? 0; + userData.LastPlayedDate = userStats.ResumePosition.HasValue ? userStats.LastUpdatedAt : userStats.LastWatchedAt ?? userStats.LastUpdatedAt; + return userData; + } + + public static UserItemData ToUserData(this File.UserStats userStats, Video video, Guid userId) + { + return new UserItemData + { + UserId = userId, + Key = video.GetUserDataKeys()[0], + LastPlayedDate = null, + }.MergeWithFileUserStats(userStats); + } +} \ No newline at end of file diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs new file mode 100644 index 00000000..0fe70faf --- /dev/null +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Configuration; + +using UserStats = Shokofin.API.Models.File.UserStats; + +namespace Shokofin.Sync; + +public class UserDataSyncManager +{ + + private readonly IUserDataManager UserDataManager; + + private readonly IUserManager UserManager; + + private readonly ILibraryManager LibraryManager; + + private readonly ISessionManager SessionManager; + + private readonly ILogger<UserDataSyncManager> Logger; + + private readonly ShokoAPIClient APIClient; + + private readonly IIdLookup Lookup; + + public UserDataSyncManager(IUserDataManager userDataManager, IUserManager userManager, ILibraryManager libraryManager, ISessionManager sessionManager, ILogger<UserDataSyncManager> logger, ShokoAPIClient apiClient, IIdLookup lookup) + { + UserDataManager = userDataManager; + UserManager = userManager; + LibraryManager = libraryManager; + SessionManager = sessionManager; + Logger = logger; + APIClient = apiClient; + Lookup = lookup; + + SessionManager.SessionStarted += OnSessionStarted; + SessionManager.SessionEnded += OnSessionEnded; + UserDataManager.UserDataSaved += OnUserDataSaved; + LibraryManager.ItemAdded += OnItemAddedOrUpdated; + LibraryManager.ItemUpdated += OnItemAddedOrUpdated; + } + + public void Dispose() + { + SessionManager.SessionStarted -= OnSessionStarted; + SessionManager.SessionEnded -= OnSessionEnded; + UserDataManager.UserDataSaved -= OnUserDataSaved; + LibraryManager.ItemAdded -= OnItemAddedOrUpdated; + LibraryManager.ItemUpdated -= OnItemAddedOrUpdated; + } + + private static bool TryGetUserConfiguration(Guid userId, out UserConfiguration? config) + { + config = Plugin.Instance.Configuration.UserList.FirstOrDefault(c => c.UserId == userId && c.EnableSynchronization); + return config != null; + } + + #region Export/Scrobble + + internal class SessionMetadata { + private readonly ILogger Logger; + + /// <summary> + /// The video Id. + /// </summary> + public Guid ItemId; + + /// <summary> + /// The shoko file id for the current item, if any. + /// </summary> + public string? FileId; + + /// <summary> + /// The jellyfin native watch session. + /// </summary> + public SessionInfo Session; + + /// <summary> + /// Current playback ticks. + /// </summary> + public long PlaybackTicks; + + /// <summary> + /// Playback ticks at the start of playback. Needed for the "start" event. + /// </summary> + public long InitialPlaybackTicks; + + /// <summary> + /// How many scrobble events we have done. Used to track when to sync + /// live progress back to shoko. + /// </summary> + public byte ScrobbleTicks; + + /// <summary> + /// Indicates that we've reacted to the pause event of the video + /// already. This is to track when to send pause/resume events. + /// </summary> + public bool IsPaused; + + /// <summary> + /// Indicates we've already sent the start event. + /// </summary> + public bool SentStartEvent; + + /// <summary> + /// The amount of events we have to skip before before we start sending + /// the events. + /// </summary> + public int SkipEventCount; + + public SessionMetadata(ILogger logger, SessionInfo sessionInfo) + { + Logger = logger; + ItemId = Guid.Empty; + FileId = null; + Session = sessionInfo; + PlaybackTicks = 0; + InitialPlaybackTicks = 0; + ScrobbleTicks = 0; + IsPaused = false; + SkipEventCount = 0; + } + + public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) + { + if (SkipEventCount == 0) + return true; + + if (!isPauseOrResumeEvent && SkipEventCount > 0) + SkipEventCount--; + + var shouldSend = SkipEventCount == 0; + if (!shouldSend) + Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); + + return shouldSend; + } + } + + private readonly ConcurrentDictionary<Guid, SessionMetadata> ActiveSessions = new(); + + public void OnSessionStarted(object? sender, SessionEventArgs e) + { + if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && (userConfig!.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { + var sessionMetadata = new SessionMetadata(Logger, e.SessionInfo); + ActiveSessions.TryAdd(e.SessionInfo.UserId, sessionMetadata); + } + foreach (var user in e.SessionInfo.AdditionalUsers) { + if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && (userConfig!.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { + var sessionMetadata = new SessionMetadata(Logger, e.SessionInfo); + ActiveSessions.TryAdd(user.UserId, sessionMetadata); + } + } + } + + public void OnSessionEnded(object? sender, SessionEventArgs e) + { + ActiveSessions.TryRemove(e.SessionInfo.UserId, out _); + foreach (var user in e.SessionInfo.AdditionalUsers) { + ActiveSessions.TryRemove(user.UserId, out _); + } + } + + public async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) + { + try { + + if (e == null || e.Item == null || Guid.Equals(e.UserId, Guid.Empty) || e.UserData == null) + return; + + if (e.SaveReason == UserDataSaveReason.UpdateUserRating) { + OnUserRatingSaved(sender, e); + return; + } + + if (!( + (e.Item is Movie || e.Item is Episode) && + TryGetUserConfiguration(e.UserId, out var userConfig) && + Lookup.TryGetFileIdFor(e.Item, out var fileId) && + Lookup.TryGetEpisodeIdFor(e.Item, out var episodeId) && + (userConfig!.SyncRestrictedVideos || e.Item.CustomRating != "XXX") + )) + return; + + var itemId = e.Item.Id; + var userData = e.UserData; + var config = Plugin.Instance.Configuration; + bool? success = null; + switch (e.SaveReason) { + case UserDataSaveReason.PlaybackStart: + case UserDataSaveReason.PlaybackProgress: { + // If a session can't be found or created then throw an error. + if (!ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata)) + return; + + // The active video changed, so send a start event. + if (sessionMetadata.ItemId != itemId) { + sessionMetadata.ItemId = e.Item.Id; + sessionMetadata.FileId = fileId; + sessionMetadata.PlaybackTicks = userData.PlaybackPositionTicks; + sessionMetadata.InitialPlaybackTicks = userData.PlaybackPositionTicks; + sessionMetadata.ScrobbleTicks = 0; + sessionMetadata.IsPaused = false; + sessionMetadata.SentStartEvent = false; + sessionMetadata.SkipEventCount = userConfig.SyncUserDataInitialSkipEventCount; + + Logger.LogInformation("Playback has started. (File={FileId})", fileId); + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) { + sessionMetadata.SentStartEvent = true; + success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.InitialPlaybackTicks, userConfig.Token).ConfigureAwait(false); + } + } + else { + var isPaused = sessionMetadata.Session.PlayState?.IsPaused ?? false; + var ticks = sessionMetadata.Session.PlayState?.PositionTicks ?? userData.PlaybackPositionTicks; + // We received an event, but the position didn't change, so the playback is most likely paused. + if (isPaused) { + if (sessionMetadata.IsPaused) + return; + + sessionMetadata.IsPaused = true; + + Logger.LogInformation("Playback was paused. (File={FileId})", fileId); + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback) + success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.PlaybackTicks, userConfig.Token).ConfigureAwait(false); + } + // The playback was resumed. + else if (sessionMetadata.IsPaused) { + sessionMetadata.PlaybackTicks = ticks; + sessionMetadata.ScrobbleTicks = 0; + sessionMetadata.IsPaused = false; + + Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback) + success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.PlaybackTicks, userConfig.Token).ConfigureAwait(false); + } + // Live scrobbling. + else { + var deltaTicks = Math.Abs(ticks - sessionMetadata.PlaybackTicks); + sessionMetadata.PlaybackTicks = ticks; + if (deltaTicks == 0 || deltaTicks < userConfig.SyncUserDataUnderPlaybackLiveThreshold && + ++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks) + return; + + var logLevel = userConfig.SyncUserDataUnderPlaybackLive ? LogLevel.Information : LogLevel.Debug; + Logger.Log(logLevel, "Playback is running. (File={FileId})", fileId); + sessionMetadata.ScrobbleTicks = 0; + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) { + if (!sessionMetadata.SentStartEvent) { + sessionMetadata.SentStartEvent = true; + success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.InitialPlaybackTicks, userConfig.Token).ConfigureAwait(false); + } + if (userConfig.SyncUserDataUnderPlaybackLive) + success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.PlaybackTicks, userConfig.Token).ConfigureAwait(false); + } + } + } + break; + } + case UserDataSaveReason.PlaybackFinished: { + if (!(userConfig.SyncUserDataAfterPlayback || userConfig.SyncUserDataUnderPlayback)) + return; + + var shouldSendEvent = true; + if (ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata) && sessionMetadata.ItemId == e.Item.Id) { + shouldSendEvent = sessionMetadata.ShouldSendEvent(true); + + sessionMetadata.ItemId = Guid.Empty; + sessionMetadata.FileId = null; + sessionMetadata.PlaybackTicks = 0; + sessionMetadata.InitialPlaybackTicks = 0; + sessionMetadata.ScrobbleTicks = 0; + sessionMetadata.IsPaused = false; + sessionMetadata.SentStartEvent = false; + sessionMetadata.SkipEventCount = -1; + } + + Logger.LogInformation("Playback has ended. (File={FileId})", fileId); + if (shouldSendEvent) + if (!userData.Played && userData.PlaybackPositionTicks > 0) + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); + else + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + break; + } + case UserDataSaveReason.TogglePlayed: + Logger.LogInformation("Scrobbled when toggled. (File={FileId})", fileId); + if (!userData.Played && userData.PlaybackPositionTicks > 0) + success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); + else + success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + break; + default: + success = null; + break; + } + if (success.HasValue) { + if (success.Value) { + Logger.LogInformation("Successfully synced watch state with Shoko. (File={FileId})", fileId); + } + else { + Logger.LogInformation("Failed to sync watch state with Shoko. (File={FileId})", fileId); + } + } + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { + if (TryGetUserConfiguration(e.UserId, out var userConfig)) + Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig!.UserId)?.Username, userConfig.UserId); + return; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {ErrorMessage}", ex.Message); + return; + } + } + + // Updates to favorite state and/or user data. + private void OnUserRatingSaved(object? sender, UserDataSaveEventArgs e) + { + if (!TryGetUserConfiguration(e.UserId, out var userConfig)) + return; + + var userData = e.UserData; + switch (e.Item) { + case Episode: + case Movie: { + if (e.Item is not Video video || !Lookup.TryGetEpisodeIdFor(video, out var episodeId)) + return; + + SyncVideo(video, userConfig!, userData, SyncDirection.Export, episodeId).ConfigureAwait(false); + break; + } + case Season season: { + if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + return; + + SyncSeason(season, userConfig!, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); + break; + } + case Series series: { + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; + + SyncSeries(series, userConfig!, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); + break; + } + } + } + + #endregion + #region Import/Sync + + public async Task ScanAndSync(SyncDirection direction, IProgress<double> progress, CancellationToken cancellationToken) + { + var enabledUsers = Plugin.Instance.Configuration.UserList.Where(c => c.EnableSynchronization).ToList(); + if (enabledUsers.Count == 0) { + progress.Report(100); + return; + } + + var videos = LibraryManager.GetItemList(new InternalItemsQuery { + MediaTypes = new[] { MediaType.Video }, + IsFolder = false, + Recursive = true, + DtoOptions = new DtoOptions(false) { + EnableImages = false + }, + SourceTypes = new SourceType[] { SourceType.Library }, + IsVirtualItem = false, + }) + .OfType<Video>() + .ToList(); + + var numComplete = 0; + var numTotal = videos.Count * enabledUsers.Count; + foreach (var video in videos) { + cancellationToken.ThrowIfCancellationRequested(); + + if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) + continue; + + foreach (var userConfig in enabledUsers) { + await SyncVideo(video, userConfig, direction, fileId, episodeId).ConfigureAwait(false); + + numComplete++; + double percent = numComplete; + percent /= numTotal; + + progress.Report(percent * 100); + } + } + progress.Report(100); + } + + public void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e) + { + if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) + return; + + switch (e.Item) { + case Video video: { + if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) + return; + + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; + + if (!userConfig.SyncUserDataOnImport) + continue; + + SyncVideo(video, userConfig, SyncDirection.Import, fileId, episodeId).ConfigureAwait(false); + } + break; + } + case Season season: { + if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + return; + + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; + + if (!userConfig.SyncUserDataOnImport) + continue; + + SyncSeason(season, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); + } + break; + } + case Series series: { + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; + + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; + + if (!userConfig.SyncUserDataOnImport) + continue; + + SyncSeries(series, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); + } + break; + } + } + + } + + #endregion + + private Task SyncSeries(Series series, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string seriesId) + { + // Try to load the user-data if it was not provided + userData ??= UserDataManager.GetUserData(userConfig.UserId, series); + // Create some new user-data if none exists. + userData ??= new UserItemData { + UserId = userConfig.UserId, + Key = series.GetUserDataKeys()[0], + }; + + Logger.LogDebug("TODO; {SyncDirection} user data for Series {SeriesName}. (Series={SeriesId})", direction.ToString(), series.Name, seriesId); + + return Task.CompletedTask; + } + + private Task SyncSeason(Season season, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string seriesId) + { + // Try to load the user-data if it was not provided + userData ??= UserDataManager.GetUserData(userConfig.UserId, season); + // Create some new user-data if none exists. + userData ??= new UserItemData { + UserId = userConfig.UserId, + Key = season.GetUserDataKeys()[0], + }; + + Logger.LogDebug("TODO; {SyncDirection} user data for Season {SeasonNumber} in Series {SeriesName}. (Series={SeriesId})", direction.ToString(), season.IndexNumber, season.SeriesName, seriesId); + + return Task.CompletedTask; + } + + private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string episodeId) + { + if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { + Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (Episode={EpisodeId})", direction.ToString(), video.Name, episodeId); + return Task.CompletedTask; + } + // Try to load the user-data if it was not provided + userData ??= UserDataManager.GetUserData(userConfig.UserId, video); + // Create some new user-data if none exists. + userData ??= new UserItemData { + UserId = userConfig.UserId, + Key = video.GetUserDataKeys()[0], + LastPlayedDate = null, + }; + + // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token); + // if (remoteUserData == null) + // return; + + Logger.LogDebug("TODO; {SyncDirection} user data for video {VideoName}. (Episode={EpisodeId})", direction.ToString(), video.Name, episodeId); + + return Task.CompletedTask; + } + + private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDirection direction, string fileId, string episodeId) + { + try { + if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { + Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId})", direction.ToString(), video.Name, fileId, episodeId); + return; + } + var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); + var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); + bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); + Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (User={UserId},File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, userConfig.UserId, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); + if (isInSync) + return; + + switch (direction) + { + case SyncDirection.Export: + // Abort since there are no local stats to export. + if (localUserStats == null) + break; + // Export the local stats if there is no remote stats or if the local stats are newer. + if (remoteUserStats == null) { + remoteUserStats = localUserStats.ToFileUserStats(); + // Don't sync if the local state is considered empty and there is no remote state. + if (remoteUserStats.IsEmpty) + break; + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + break; + case SyncDirection.Import: + // Abort since there are no remote stats to import. + if (remoteUserStats == null) + break; + // Create a new local stats entry if there is no local entry. + if (localUserStats == null) { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + // Else merge the remote stats into the local stats entry. + else if (!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value) { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + break; + default: + case SyncDirection.Sync: { + // Export if there is local stats but no remote stats. + if (localUserStats == null && remoteUserStats != null) + goto case SyncDirection.Import; + + // Try to import of there is no local stats ubt there are remote stats. + else if (remoteUserStats == null && localUserStats != null) + goto case SyncDirection.Export; + + // Abort if there are no local or remote stats. + else if (remoteUserStats == null && localUserStats == null) + break; + + // Try to import if we're unable to read the last played timestamp. + if (!localUserStats!.LastPlayedDate.HasValue) + goto case SyncDirection.Import; + + // Abort if the stats are in sync. + if (isInSync || localUserStats.LastPlayedDate.Value == remoteUserStats!.LastUpdatedAt) + break; + + // Export if the local state is fresher then the remote state. + if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + // Else import if the remote state is fresher then the local state. + else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + break; + } + } + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { + Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); + throw; + } + } + + /// <summary> + /// Checks if the local user data and the remote user stats are in sync. + /// </summary> + /// <param name="localUserData">The local user data</param> + /// <param name="remoteUserStats">The remote user stats.</param> + /// <returns>True if they are not in sync.</returns> + private static bool UserDataEqualsFileUserStats(UserItemData? localUserData, UserStats? remoteUserStats) + { + if (remoteUserStats == null && localUserData == null) + return true; + + if (localUserData == null) + return false; + + var localUserStats = localUserData.ToFileUserStats(); + if (remoteUserStats == null) + return localUserStats.IsEmpty; + + if (localUserStats.IsEmpty && remoteUserStats.IsEmpty) + return true; + + if (localUserStats.ResumePosition != remoteUserStats.ResumePosition) + return false; + + if (localUserStats.WatchedCount != remoteUserStats.WatchedCount) + return false; + + var played = remoteUserStats.LastWatchedAt.HasValue; + if (localUserData.Played != played) + return false; + + if (localUserStats.LastUpdatedAt != remoteUserStats.LastUpdatedAt) + return false; + + return true; + } +} diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs new file mode 100644 index 00000000..6cf4f437 --- /dev/null +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +/// <summary> +/// Cleanup any old VFS roots leftover from an outdated install or failed removal of the roots. +/// </summary> +public class CleanupVirtualRootTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Cleanup Virtual File System Roots"; + + /// <inheritdoc /> + public string Description => "Cleanup any old VFS roots leftover from an outdated install or failed removal of the roots."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoCleanupVirtualRoot"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly ILogger<CleanupVirtualRootTask> Logger; + + private readonly IFileSystem FileSystem; + + private readonly LibraryScanWatcher ScanWatcher; + + public CleanupVirtualRootTask(ILogger<CleanupVirtualRootTask> logger, IFileSystem fileSystem, LibraryScanWatcher scanWatcher) + { + Logger = logger; + FileSystem = fileSystem; + ScanWatcher = scanWatcher; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (ScanWatcher.IsScanRunning) + return Task.CompletedTask; + + var start = DateTime.Now; + var mediaFolders = Plugin.Instance.Configuration.MediaFolders.ToList() + .Select(config => config.LibraryId.ToString()) + .Distinct() + .ToList(); + var vfsRoots = FileSystem.GetDirectories(Plugin.Instance.VirtualRoot, false) + .ExceptBy(mediaFolders, directoryInfo => directoryInfo.Name) + .ToList(); + Logger.LogInformation("Found {RemoveCount} VFS roots to remove.", vfsRoots.Count); + foreach (var vfsRoot in vfsRoots) { + var folderStart = DateTime.Now; + Logger.LogInformation("Removing VFS root for {Id}.", vfsRoot.Name); + Directory.Delete(vfsRoot.FullName, true); + var perFolderDeltaTime = DateTime.Now - folderStart; + Logger.LogInformation("Removed VFS root for {Id} in {TimeSpan}.", vfsRoot.Name, perFolderDeltaTime); + } + + var deltaTime = DateTime.Now - start; + Logger.LogInformation("Removed {RemoveCount} VFS roots in {TimeSpan}.", vfsRoots.Count, deltaTime); + + return Task.CompletedTask; + } +} diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs new file mode 100644 index 00000000..dd8655e1 --- /dev/null +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.API; +using Shokofin.Resolvers; + +namespace Shokofin.Tasks; + +/// <summary> +/// Forcefully clear the plugin cache. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. +/// </summary> +public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Clear Plugin Cache"; + + /// <inheritdoc /> + public string Description => "Forcefully clear the plugin cache. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoClearPluginCache"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly ShokoAPIManager ApiManager; + + private readonly ShokoAPIClient ApiClient; + + private readonly VirtualFileSystemService VfsService; + + public ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, VirtualFileSystemService vfsService) + { + ApiManager = apiManager; + ApiClient = apiClient; + VfsService = vfsService; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + ApiClient.Clear(); + ApiManager.Clear(); + VfsService.Clear(); + return Task.CompletedTask; + } +} diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs new file mode 100644 index 00000000..24972b0c --- /dev/null +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.Sync; + +namespace Shokofin.Tasks; + +public class ExportUserDataTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Export User Data"; + + /// <inheritdoc /> + public string Description => "Export the user-data stored in Jellyfin to Shoko. Will not import user-data from Shoko."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoExportUserData"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly UserDataSyncManager _userSyncManager; + + public ExportUserDataTask(UserDataSyncManager userSyncManager) + { + _userSyncManager = userSyncManager; + } + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await _userSyncManager.ScanAndSync(SyncDirection.Export, progress, cancellationToken); + } +} diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs new file mode 100644 index 00000000..8c8095f2 --- /dev/null +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.Sync; + +namespace Shokofin.Tasks; + +public class ImportUserDataTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Import User Data"; + + /// <inheritdoc /> + public string Description => "Import the user-data stored in Shoko to Jellyfin. Will not export user-data to Shoko."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoImportUserData"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly UserDataSyncManager _userSyncManager; + + public ImportUserDataTask(UserDataSyncManager userSyncManager) + { + _userSyncManager = userSyncManager; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await _userSyncManager.ScanAndSync(SyncDirection.Import, progress, cancellationToken); + } +} diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs new file mode 100644 index 00000000..dbabfc9b --- /dev/null +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Merge Episodes"; + + /// <inheritdoc /> + public string Description => "Merge all episode entries with the same Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoMergeEpisodes"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly MergeVersionsManager VersionsManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + public MergeEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) + { + VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Episodes Task")) { + await VersionsManager.MergeAllEpisodes(progress, cancellationToken); + } + } +} diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs new file mode 100644 index 00000000..8fbbed23 --- /dev/null +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Merge Movies"; + + /// <inheritdoc /> + public string Description => "Merge all movie entries with the same Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoMergeMovies"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly MergeVersionsManager VersionsManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + public MergeMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) + { + VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { + await VersionsManager.MergeAllMovies(progress, cancellationToken); + } + } +} diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs new file mode 100644 index 00000000..caa668b2 --- /dev/null +++ b/Shokofin/Tasks/PostScanTask.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using Shokofin.API; +using Shokofin.Collections; +using Shokofin.MergeVersions; +using Shokofin.Resolvers; + +namespace Shokofin.Tasks; + +public class PostScanTask : ILibraryPostScanTask +{ + private readonly MergeVersionsManager VersionsManager; + + private readonly CollectionManager CollectionManager; + + public PostScanTask(MergeVersionsManager versionsManager, CollectionManager collectionManager) + { + VersionsManager = versionsManager; + CollectionManager = collectionManager; + } + + public async Task Run(IProgress<double> progress, CancellationToken token) + { + // Merge versions now if the setting is enabled. + if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) { + // Setup basic progress tracking + var baseProgress = 0d; + var simpleProgress = new Progress<double>(value => progress.Report(baseProgress + (value / 2d))); + + // Merge versions. + await VersionsManager.MergeAll(simpleProgress, token); + + // Reconstruct collections. + baseProgress = 50; + await CollectionManager.ReconstructCollections(simpleProgress, token); + + progress.Report(100d); + } + else { + // Reconstruct collections. + await CollectionManager.ReconstructCollections(progress, token); + } + } +} diff --git a/Shokofin/Tasks/ReconstructCollectionsTask.cs b/Shokofin/Tasks/ReconstructCollectionsTask.cs new file mode 100644 index 00000000..c641e29e --- /dev/null +++ b/Shokofin/Tasks/ReconstructCollectionsTask.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.Collections; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +/// <summary> +/// Reconstruct all Shoko collections outside a Library Scan. +/// </summary> +public class ReconstructCollectionsTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Reconstruct Collections"; + + /// <inheritdoc /> + public string Description => "Reconstruct all Shoko collections outside a Library Scan."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoReconstructCollections"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly CollectionManager CollectionManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + public ReconstructCollectionsTask(CollectionManager collectionManager, LibraryScanWatcher libraryScanWatcher) + { + CollectionManager = collectionManager; + LibraryScanWatcher = libraryScanWatcher; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Reconstruct Collections Task")) { + await CollectionManager.ReconstructCollections(progress, cancellationToken); + } + } +} diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs new file mode 100644 index 00000000..fe87c518 --- /dev/null +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +/// <summary +public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Split Episodes"; + + /// <inheritdoc /> + public string Description => "Split all episode entries with a Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoSplitEpisodes"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly MergeVersionsManager VersionsManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + public SplitEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) + { + VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { + await VersionsManager.SplitAllEpisodes(progress, cancellationToken); + } + } +} diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs new file mode 100644 index 00000000..d7edc722 --- /dev/null +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +/// <summary> +/// Class SplitMoviesTask. +/// </summary> +public class SplitMoviesTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Split Movies"; + + /// <inheritdoc /> + public string Description => "Split all movie entries with a Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoSplitMovies"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly MergeVersionsManager VersionsManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + public SplitMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) + { + VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { + await VersionsManager.SplitAllMovies(progress, cancellationToken); + } + } +} diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs new file mode 100644 index 00000000..1fb7e5ce --- /dev/null +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.Sync; + +namespace Shokofin.Tasks; + +public class SyncUserDataTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Sync User Data"; + + /// <inheritdoc /> + public string Description => "Synchronize the user-data stored in Jellyfin with the user-data stored in Shoko. Imports or exports data as needed."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoSyncUserData"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly UserDataSyncManager _userSyncManager; + + public SyncUserDataTask(UserDataSyncManager userSyncManager) + { + _userSyncManager = userSyncManager; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await _userSyncManager.ScanAndSync(SyncDirection.Sync, progress, cancellationToken); + } +} diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs new file mode 100644 index 00000000..b7a2675a --- /dev/null +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; + +namespace Shokofin.Tasks; + +/// <summary> +/// Responsible for updating the known version of the remote Shoko Server +/// instance at startup and set intervals. +/// </summary> +public class VersionCheckTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Check Server Version"; + + /// <inheritdoc /> + public string Description => "Responsible for updating the known version of the remote Shoko Server instance at startup and set intervals."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoVersionCheck"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly ILogger<VersionCheckTask> Logger; + + private readonly ILibraryManager LibraryManager; + + private readonly ShokoAPIClient ApiClient; + + public VersionCheckTask(ILogger<VersionCheckTask> logger, ILibraryManager libraryManager, ShokoAPIClient apiClient) + { + Logger = logger; + LibraryManager = libraryManager; + ApiClient = apiClient; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => new TaskTriggerInfo[1] { + new() { + Type = TaskTriggerInfo.TriggerStartup, + }, + }; + + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var updated = false; + var version = await ApiClient.GetVersion(); + if (version != null && ( + Plugin.Instance.Configuration.ServerVersion == null || + !string.Equals(version.ToString(), Plugin.Instance.Configuration.ServerVersion.ToString()) + )) { + Logger.LogInformation("Found new Shoko Server version; {version}", version); + Plugin.Instance.Configuration.ServerVersion = version; + updated = true; + } + + var mediaFolders = Plugin.Instance.Configuration.MediaFolders.ToList(); + var importFolderNameMap = await Task + .WhenAll( + mediaFolders + .Select(m => m.ImportFolderId) + .Distinct() + .Except(new int[1] { 0 }) + .Select(id => ApiClient.GetImportFolder(id)) + .ToList() + ) + .ContinueWith(task => task.Result.OfType<ImportFolder>().ToDictionary(i => i.Id, i => i.Name)) + .ConfigureAwait(false); + foreach (var mediaFolderConfig in mediaFolders) { + if (!importFolderNameMap.TryGetValue(mediaFolderConfig.ImportFolderId, out var importFolderName)) + importFolderName = null; + + if (mediaFolderConfig.LibraryId == Guid.Empty && LibraryManager.GetItemById(mediaFolderConfig.MediaFolderId) is Folder mediaFolder && + LibraryManager.GetVirtualFolders().FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path)) is { } library && + Guid.TryParse(library.ItemId, out var libraryId)) { + Logger.LogInformation("Found new library for media folder; {LibraryName} (Library={LibraryId},MediaFolder={MediaFolderPath})", library.Name, libraryId, mediaFolder.Path); + mediaFolderConfig.LibraryId = libraryId; + updated = true; + } + + if (!string.Equals(mediaFolderConfig.ImportFolderName, importFolderName)) { + Logger.LogInformation("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolderConfig.ImportFolderId); + mediaFolderConfig.ImportFolderName = importFolderName; + updated = true; + } + } + if (updated) { + Plugin.Instance.UpdateConfiguration(); + } + } +} \ No newline at end of file diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs new file mode 100644 index 00000000..384a8f85 --- /dev/null +++ b/Shokofin/Utils/ContentRating.cs @@ -0,0 +1,382 @@ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.Events.Interfaces; + +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; + +namespace Shokofin.Utils; + +public static class ContentRating +{ + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TvContentIndicatorsAttribute : Attribute + { + public TvContentIndicator[] Values { get; init; } + + public TvContentIndicatorsAttribute(params TvContentIndicator[] values) + { + Values = values; + } + } + + /// <summary> + /// Tv Ratings and Parental Controls + /// </summary> + /// <remarks> + /// Based on https://web.archive.org/web/20210720014648/https://www.tvguidelines.org/resources/TheRatings.pdf + /// </remarks> + public enum TvRating { + /// <summary> + /// No rating. + /// </summary> + None = 0, + + /// <summary> + /// Most parents would find this program suitable for all ages. Although + /// this rating does not signify a program designed specifically for + /// children, most parents may let younger children watch this program + /// unattended. It contains little or no violence, no strong language + /// and little or no sexual dialogue or situations. + /// </summary> + [Description("TV-G")] + TvG, + + /// <summary> + /// This program is designed to be appropriate for all children. Whether + /// animated or live-action, the themes and elements in this program are + /// specifically designed for a very young audience, including children + /// from ages 2-6. This program is not expected to frighten younger + /// children. + /// </summary> + [Description("TV-Y")] + TvY, + + /// <summary> + /// This program is designed for children age 7 and above. It may be + /// more appropriate for children who have acquired the developmental + /// skills needed to distinguish between make-believe and reality. + /// Themes and elements in this program may include mild fantasy + /// violence or comedic violence, or may frighten children under the + /// age of 7. Therefore, parents may wish to consider the suitability of + /// this program for their very young children. + /// + /// This program may contain one or more of the following: + /// - intense or combative fantasy violence (FV). + /// </summary> + [Description("TV-Y7")] + [TvContentIndicators(TvContentIndicator.FV)] + TvY7, + + /// <summary> + /// This program contains material that parents may find unsuitable for + /// younger children. Many parents may want to watch it with their + /// younger children. + /// + /// The theme itself may call for parental guidance and/or the program + /// may contain one or more of the following: + /// - some suggestive dialogue (D), + /// - infrequent coarse language (L), + /// - some sexual situations (S), or + /// - moderate violence (V). + /// </summary> + [Description("TV-PG")] + [TvContentIndicators(TvContentIndicator.D, TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + TvPG, + + /// <summary> + /// This program contains some material that many parents would find + /// unsuitable for children under 14 years of age. Parents are strongly + /// urged to exercise greater care in monitoring this program and are + /// cautioned against letting children under the age of 14 watch + /// unattended. + /// + /// This program may contain one or more of the following: + /// - intensely suggestive dialogue (D), + /// - strong coarse language (L), + /// - intense sexual situations (S), or + /// - intense violence (V). + /// </summary> + [Description("TV-14")] + [TvContentIndicators(TvContentIndicator.D, TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + Tv14, + + /// <summary> + /// This program is specifically designed to be viewed by adults and + /// therefore may be unsuitable for children under 17. + /// + /// This program may contain one or more of the following: + /// - strong coarse language (L), + /// - intense sexual situations (S), or + /// - intense violence (V). + /// </summary> + [Description("TV-MA")] + [TvContentIndicators(TvContentIndicator.D, TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + TvMA, + + /// <summary> + /// Porn. No, you didn't read that wrong. + /// </summary> + [Description("XXX")] + [TvContentIndicators(TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + XXX, + } + + /// <summary> + /// Available content indicators for the base <see cref="TvRating"/>. + /// </summary> + public enum TvContentIndicator { + /// <summary> + /// Intense or combative fantasy violence (FV), but only for <see cref="TvRating.TvPG"/>. + /// </summary> + FV = 1, + /// <summary> + /// Some or intense suggestive dialogue (D), depending on the base <see cref="TvRating"/>. + /// </summary> + D, + /// <summary> + /// infrequent or intense coarse language (L), depending on the base <see cref="TvRating"/>. + /// </summary> + L, + /// <summary> + /// Moderate or intense sexual situations (S), depending on the base <see cref="TvRating"/>. + /// </summary> + S, + /// <summary> + /// Moderate or intense violence, depending on the base <see cref="TvRating"/>. + /// </summary> + V, + } + + private static ProviderName[] GetOrderedProviders() + => Plugin.Instance.Configuration.ContentRatingOverride + ? Plugin.Instance.Configuration.ContentRatingOrder.Where((t) => Plugin.Instance.Configuration.ContentRatingList.Contains(t)).ToArray() + : new ProviderName[] { ProviderName.AniDB, ProviderName.TMDB }; + +#pragma warning disable IDE0060 + public static string? GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) +#pragma warning restore IDE0060 + { + // TODO: Add TMDB movie linked to episode content rating here. + foreach (var provider in GetOrderedProviders()) { + var title = provider switch { + ProviderName.AniDB => seasonInfo.AssumedContentRating, + // TODO: Add TMDB series content rating here. + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); + } + return null; + } + + public static string? GetSeasonContentRating(SeasonInfo seasonInfo) + { + foreach (var provider in GetOrderedProviders()) { + var title = provider switch { + ProviderName.AniDB => seasonInfo.AssumedContentRating, + // TODO: Add TMDB series content rating here. + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); + } + return null; + } + + public static string? GetShowContentRating(ShowInfo showInfo) + { + var (contentRating, contentIndicators) = showInfo.SeasonOrderDictionary.Values + .Select(seasonInfo => GetSeasonContentRating(seasonInfo)) + .Where(contentRating => !string.IsNullOrEmpty(contentRating)) + .Distinct() + .Select(text => TryConvertRatingFromText(text, out var cR, out var cI) ? (contentRating: cR, contentIndicators: cI ?? new()) : (contentRating: TvRating.None, contentIndicators: new())) + .Where(tuple => tuple.contentRating is not TvRating.None) + .GroupBy(tuple => tuple.contentRating) + .OrderByDescending(groupBy => groupBy.Key) + .Select(groupBy => (groupBy.Key, groupBy.SelectMany(tuple => tuple.contentIndicators).ToHashSet())) + .FirstOrDefault(); + return ConvertRatingToText(contentRating, contentIndicators); + } + + public static string? GetTagBasedContentRating(IReadOnlyDictionary<string, ResolvedTag> tags) + { + // User overridden content rating. + if (tags.TryGetValue("/custom user tags/target audience", out var tag)) { + var audience = tag.Children.Count == 1 ? tag.Children.Values.First() : null; + if (TryConvertRatingFromText(audience?.Name.ToLowerInvariant().Replace("-", ""), out var cR, out var cI)) + return ConvertRatingToText(cR, cI); + } + + // Base rating. + var contentRating = TvRating.None; + var contentIndicators = new HashSet<TvContentIndicator>(); + if (tags.TryGetValue("/target audience", out tag)) { + var audience = tag.Children.Count == 1 ? tag.Children.Values.First() : null; + contentRating = (audience?.Name.ToLowerInvariant()) switch { + "mina" => TvRating.TvG, + "kodomo" => TvRating.TvY, + "shoujo" => TvRating.TvY7, + "shounen" => TvRating.TvY7, + "josei" => TvRating.Tv14, + "seinen" => TvRating.Tv14, + "18 restricted" => TvRating.XXX, + _ => 0, + }; + } + + // "Upgrade" the content rating if it contains any of these tags. + if (contentRating is < TvRating.TvMA && tags.ContainsKey("/elements/ecchi/borderline porn")) + contentRating = TvRating.TvMA; + if (contentRating is < TvRating.Tv14 && ( + tags.ContainsKey("/elements/ecchi/Gainax bounce") || + tags.ContainsKey("/elements/ecchi/breast fondling") || + tags.ContainsKey("/elements/ecchi/paper clothes") || + tags.ContainsKey("/elements/ecchi/skimpy clothing") + )) + contentRating = TvRating.Tv14; + if (contentRating is < TvRating.TvPG && ( + tags.ContainsKey("/elements/sexual humour") || + tags.ContainsKey("/technical aspects/very bloody wound in low-pg series") + )) + contentRating = TvRating.TvPG; + if (tags.TryGetValue("/elements/ecchi", out tag)) { + if (contentRating is < TvRating.Tv14 && tag.Weight is >= TagWeight.Four) + contentRating = TvRating.Tv14; + else if (contentRating is < TvRating.TvPG && tag.Weight is >= TagWeight.Three) + contentRating = TvRating.TvPG; + else if (contentRating is < TvRating.TvY7 && tag.Weight is >= TagWeight.Two) + contentRating = TvRating.TvY7; + } + if (contentRating is < TvRating.Tv14 && tags.ContainsKey("/content indicators/sex")) + contentRating = TvRating.Tv14; + if (tags.TryGetValue("/content indicators/nudity", out tag)) { + if (contentRating is < TvRating.Tv14 && tag.Weight is >= TagWeight.Four) + contentRating = TvRating.Tv14; + else if (contentRating is < TvRating.TvPG && tag.Weight is >= TagWeight.Three) + contentRating = TvRating.TvPG; + else if (contentRating is < TvRating.TvY7 && tag.Weight is >= TagWeight.Two) + contentRating = TvRating.TvY7; + } + if (tags.TryGetValue("/content indicators/violence", out tag)) { + if (contentRating is > TvRating.TvG && contentRating is < TvRating.Tv14 && tag.Weight is >= TagWeight.Four) + contentRating = TvRating.Tv14; + if (contentRating is > TvRating.TvG && contentRating is < TvRating.TvY7 && tag.Weight is >= TagWeight.Two) + contentRating = TvRating.TvY7; + } + if (contentRating is > TvRating.TvG && contentRating is < TvRating.TvY7 && tags.ContainsKey("/content indicators/violence/gore")) + contentRating = TvRating.TvY7; + + // Content indicators. + if (tags.ContainsKey("/elements/sexual humour")) + contentIndicators.Add(TvContentIndicator.D); + if (tags.TryGetValue("/content indicators/sex", out tag)) { + if (tag.Weight is <= TagWeight.Two) + contentIndicators.Add(TvContentIndicator.D); + else + contentIndicators.Add(TvContentIndicator.S); + } + if (tags.TryGetValue("/content indicators/nudity", out tag)) { + if (tag.Weight >= TagWeight.Four) + contentIndicators.Add(TvContentIndicator.S); + } + if (tags.TryGetValue("/content indicators/violence", out tag)) { + if (tags.ContainsKey("/elements/speculative fiction/fantasy")) + contentIndicators.Add(TvContentIndicator.FV); + if (tag.Weight is >= TagWeight.Two) + contentIndicators.Add(TvContentIndicator.V); + } + + return ConvertRatingToText(contentRating, contentIndicators); + } + + private static bool TryConvertRatingFromText(string? value, out TvRating contentRating, [NotNullWhen(true)] out HashSet<TvContentIndicator>? contentIndicators) + { + // Return early if null or empty. + contentRating = TvRating.None; + if (string.IsNullOrEmpty(value)) { + contentIndicators = null; + return false; + } + + // Trim input, remove dashes and underscores, and remove optional prefix. + value = value.ToLowerInvariant().Trim().Replace("-", "").Replace("_", ""); + if (value.Length > 1 && value[0..1] == "tv") + value = value.Length > 2 ? value[2..] : string.Empty; + + // Parse rating. + var offset = 0; + if (value.Length > 0) { + contentRating = value[0] switch { + 'y' => TvRating.TvY, + 'g' => TvRating.TvG, + _ => TvRating.None, + }; + if (contentRating is not TvRating.None) + offset = 1; + } + if (contentRating is TvRating.None && value.Length > 1) { + contentRating = value[0..1] switch { + "y7" => TvRating.TvY7, + "pg" => TvRating.TvPG, + "14" => TvRating.Tv14, + "ma" => TvRating.TvMA, + _ => TvRating.None, + }; + if (contentRating is not TvRating.None) + offset = 2; + } + if (contentRating is TvRating.None && value.Length > 2) { + contentRating = value[0..2] switch { + "xxx" => TvRating.XXX, + _ => TvRating.None, + }; + if (contentRating is not TvRating.None) + offset = 3; + } + if (contentRating is TvRating.None) { + contentIndicators = null; + return false; + } + + // Parse indicators. + contentIndicators = new(); + if (value.Length <= offset) + return true; + foreach (var raw in value[offset..]) { + if (!Enum.TryParse<TvContentIndicator>(raw.ToString(), out var indicator)) { + contentRating = TvRating.None; + contentIndicators = null; + return false; + } + contentIndicators.Add(indicator); + } + + return true; + } + + internal static T[] GetCustomAttributes<T>(this System.Reflection.FieldInfo? fieldInfo, bool inherit = false) + => fieldInfo?.GetCustomAttributes(typeof(T), inherit) is T[] attributes ? attributes : Array.Empty<T>(); + + private static string? ConvertRatingToText(TvRating value, IEnumerable<TvContentIndicator>? contentIndicators) + { + var field = value.GetType().GetField(value.ToString())!; + var attributes = field.GetCustomAttributes<DescriptionAttribute>(); + if (attributes.Length is 0) + return null; + + var contentRating = attributes.First().Description; + var allowedIndicators = (field.GetCustomAttributes<TvContentIndicatorsAttribute>().FirstOrDefault()?.Values ?? Array.Empty<TvContentIndicator>()) + .Intersect(contentIndicators ?? Array.Empty<TvContentIndicator>()) + .ToList(); + if (allowedIndicators.Count is > 0) + contentRating += $"-{allowedIndicators.Select(cI => cI.ToString()).Join("")}"; + + return contentRating; + } +} \ No newline at end of file diff --git a/Shokofin/Utils/DisposableAction.cs b/Shokofin/Utils/DisposableAction.cs new file mode 100644 index 00000000..7a1fecbd --- /dev/null +++ b/Shokofin/Utils/DisposableAction.cs @@ -0,0 +1,17 @@ + +using System; + +namespace Shokofin.Utils; + +public class DisposableAction : IDisposable +{ + private readonly Action DisposeAction; + + public DisposableAction(Action disposeAction) + { + DisposeAction = disposeAction; + } + + public void Dispose() + => DisposeAction(); +} \ No newline at end of file diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs new file mode 100644 index 00000000..b6d00352 --- /dev/null +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -0,0 +1,221 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using AsyncKeyedLock; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Shokofin.Utils; + +sealed class GuardedMemoryCache : IDisposable, IMemoryCache +{ + private readonly MemoryCacheOptions CacheOptions; + + private readonly MemoryCacheEntryOptions? CacheEntryOptions; + + private readonly ILogger Logger; + + private IMemoryCache Cache; + + private static readonly AsyncKeyedLockOptions AsyncKeyedLockOptions = new() { MaxCount = 1, PoolSize = 50 }; + + private AsyncKeyedLocker<object> Semaphores = new(AsyncKeyedLockOptions); + + public GuardedMemoryCache(ILogger logger, MemoryCacheOptions options, MemoryCacheEntryOptions? cacheEntryOptions = null) + { + Logger = logger; + CacheOptions = options; + CacheEntryOptions = cacheEntryOptions; + Cache = new MemoryCache(CacheOptions); + } + + public void Clear() + { + Logger.LogDebug("Clearing cache…"); + var cache = Cache; + Cache = new MemoryCache(CacheOptions); + Semaphores.Dispose(); + Semaphores = new(AsyncKeyedLockOptions); + cache.Dispose(); + } + + public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (TryGetValue<TItem>(key, out var value)) { + foundAction(value); + return value; + } + + try { + using (Semaphores.Lock(key)) { + if (TryGetValue(key, out value)) { + foundAction(value); + return value; + } + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = createFactory(); + entry.Value = value; + return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); + + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } + + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); + foundAction(value); + return value; + } + + throw; + } + } + + public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (TryGetValue<TItem>(key, out var value)) { + foundAction(value); + return value; + } + + try { + using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { + if (TryGetValue(key, out value)) { + foundAction(value); + return value; + } + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = await createFactory().ConfigureAwait(false); + entry.Value = value; + return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); + + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } + + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); + foundAction(value); + return value; + } + + throw; + } + } + + public TItem GetOrCreate<TItem>(object key, Func<TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (TryGetValue<TItem>(key, out var value)) + return value; + + try { + using (Semaphores.Lock(key)) { + if (TryGetValue(key, out value)) + return value; + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = createFactory(); + entry.Value = value; + return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); + + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } + + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); + return value; + } + + throw; + } + } + + public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (TryGetValue<TItem>(key, out var value)) + return value; + + try { + using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { + if (TryGetValue(key, out value)) + return value; + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = await createFactory().ConfigureAwait(false); + entry.Value = value; + return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); + + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } + + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); + return value; + } + + throw; + } + } + + public void Dispose() + { + Semaphores.Dispose(); + Cache.Dispose(); + } + + public ICacheEntry CreateEntry(object key) + => Cache.CreateEntry(key); + + public void Remove(object key) + => Cache.Remove(key); + + public bool TryGetValue(object key, [NotNullWhen(true)] out object? value) + => Cache.TryGetValue(key, out value); + + public bool TryGetValue<TItem>(object key, [NotNullWhen(true)] out TItem? value) + => Cache.TryGetValue(key, out value); + + public TItem? Set<TItem>(object key, [NotNullIfNotNull(nameof(value))] TItem? value, MemoryCacheEntryOptions? createOptions = null) + => Cache.Set(key, value, createOptions ?? CacheEntryOptions); +} \ No newline at end of file diff --git a/Shokofin/Utils/LibraryScanWatcher.cs b/Shokofin/Utils/LibraryScanWatcher.cs new file mode 100644 index 00000000..20dd529d --- /dev/null +++ b/Shokofin/Utils/LibraryScanWatcher.cs @@ -0,0 +1,47 @@ +using System; +using MediaBrowser.Controller.Library; + +namespace Shokofin.Utils; + +public class LibraryScanWatcher +{ + private readonly ILibraryManager LibraryManager; + + private readonly PropertyWatcher<bool> Watcher; + + private Guid? TrackerId = null; + + public bool IsScanRunning => Watcher.Value; + + public event EventHandler<bool>? ValueChanged; + + public LibraryScanWatcher(ILibraryManager libraryManager) + { + LibraryManager = libraryManager; + Watcher = new(() => LibraryManager.IsScanRunning); + Watcher.StartMonitoring(Plugin.Instance.Configuration.LibraryScanReactionTimeInSeconds); + Watcher.ValueChanged += OnLibraryScanRunningChanged; + } + + ~LibraryScanWatcher() + { + Watcher.StopMonitoring(); + Watcher.ValueChanged -= OnLibraryScanRunningChanged; + } + + private void OnLibraryScanRunningChanged(object? sender, bool isScanRunning) + { + if (isScanRunning) { + if (!TrackerId.HasValue) { + TrackerId = Plugin.Instance.Tracker.Add("Library Scan Watcher"); + } + } + else { + if (TrackerId.HasValue) { + Plugin.Instance.Tracker.Remove(TrackerId.Value); + TrackerId = null; + } + } + ValueChanged?.Invoke(sender, isScanRunning); + } +} \ No newline at end of file diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs new file mode 100644 index 00000000..f76c30b4 --- /dev/null +++ b/Shokofin/Utils/Ordering.cs @@ -0,0 +1,305 @@ +using System; +using System.Linq; +using Shokofin.API.Info; +using Shokofin.API.Models; + +using ExtraType = MediaBrowser.Model.Entities.ExtraType; + +namespace Shokofin.Utils; + +public class Ordering +{ + /// <summary> + /// Library filtering mode. + /// </summary> + public enum LibraryFilteringMode { + /// <summary> + /// Will use either <see cref="Strict"/> or <see cref="Lax"/> depending + /// on which metadata providers are enabled for the library. + /// </summary> + Auto = 0, + /// <summary> + /// Will only allow files/folders that are recognized and it knows + /// should be part of the library. + /// </summary> + Strict = 1, + /// <summary> + /// Will permit files/folders that are not recognized to exist in the + /// library, but will filter out anything it knows should not be part of + /// the library. + /// </summary> + Lax = 2, + } + + /// <summary> + /// Helps determine what the user wants to group into collections + /// (AKA "box-sets"). + /// </summary> + public enum CollectionCreationType { + /// <summary> + /// No grouping. All series will have their own entry. + /// </summary> + None = 0, + + /// <summary> + /// Group movies into collections based on Shoko's series. + /// </summary> + Movies = 1, + + /// <summary> + /// Group both movies and shows into collections based on Shoko's + /// groups. + /// </summary> + Shared = 2, + } + + /// <summary> + /// Season or movie ordering when grouping series/box-sets using Shoko's groups. + /// </summary> + public enum OrderType { + /// <summary> + /// Let Shoko decide the order. + /// </summary> + Default = 0, + + /// <summary> + /// Order seasons by release date. + /// </summary> + ReleaseDate = 1, + + /// <summary> + /// Order seasons based on the chronological order of relations. + /// </summary> + Chronological = 2, + + /// <summary> + /// Order seasons based on the chronological order of only direct relations. + /// </summary> + ChronologicalIgnoreIndirect = 3, + } + + public enum SpecialOrderType { + /// <summary> + /// Use the default for the type. + /// </summary> + Default = 0, + + /// <summary> + /// Always exclude the specials from the season. + /// </summary> + Excluded = 1, + + /// <summary> + /// Always place the specials after the normal episodes in the season. + /// </summary> + AfterSeason = 2, + + /// <summary> + /// Use a mix of <see cref="InBetweenSeasonByOtherData" /> and <see cref="InBetweenSeasonByAirDate" />. + /// </summary> + InBetweenSeasonMixed = 3, + + /// <summary> + /// Place the specials in-between normal episodes based on the time the episodes aired. + /// </summary> + InBetweenSeasonByAirDate = 4, + + /// <summary> + /// Place the specials in-between normal episodes based upon the data from TvDB or TMDB. + /// </summary> + InBetweenSeasonByOtherData = 5, + } + + /// <summary> + /// Get index number for an episode in a series. + /// </summary> + /// <returns>Absolute index.</returns> + public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) + { + var index = 0; + var offset = 0; + if (seasonInfo.IsExtraEpisode(episodeInfo)) { + var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); + if (seasonIndex == -1) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); + index = seasonInfo.ExtrasList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); + if (index == -1) + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); + offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.ExtrasList.Count); + return offset + index + 1; + } + + if (showInfo.IsSpecial(episodeInfo)) { + var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); + if (seasonIndex == -1) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); + index = seasonInfo.SpecialsList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); + if (index == -1) + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); + offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + return offset + index + 1; + } + + // All normal episodes will find their index in here. + index = seasonInfo.EpisodeList.FindIndex(ep => ep.Id == episodeInfo.Id); + if (index == -1) + index = seasonInfo.AlternateEpisodesList.FindIndex(ep => ep.Id == episodeInfo.Id); + + // If we still cannot find the episode for whatever reason, then bail. I don't fudging know why, but I know it's not the plugin's fault. + if (index == -1) + throw new IndexOutOfRangeException($"Unable to find index to use for \"{episodeInfo.Shoko.Name}\". (Episode={episodeInfo.Id},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds})"); + + return index + 1; + } + + public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) + { + var order = Plugin.Instance.Configuration.SpecialsPlacement; + + // Return early if we want to exclude them from the normal seasons. + if (order == SpecialOrderType.Excluded) { + // Check if this should go in the specials season. + return (null, null, null, showInfo.IsSpecial(episodeInfo)); + } + + // Abort if episode is not a TvDB special or AniDB special + if (!showInfo.IsSpecial(episodeInfo)) + return (null, null, null, false); + + int? episodeNumber = null; + int seasonNumber = GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + int? airsBeforeEpisodeNumber = null; + int? airsBeforeSeasonNumber = null; + int? airsAfterSeasonNumber = null; + switch (order) { + default: + airsAfterSeasonNumber = seasonNumber; + break; + case SpecialOrderType.InBetweenSeasonByAirDate: + byAirDate: + // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. + episodeNumber = null; + if (seasonInfo.SpecialsAnchors.TryGetValue(episodeInfo, out var previousEpisode)) + episodeNumber = GetEpisodeNumber(showInfo, seasonInfo, previousEpisode); + + if (episodeNumber.HasValue && episodeNumber.Value < seasonInfo.EpisodeList.Count) { + airsBeforeEpisodeNumber = episodeNumber.Value + 1; + airsBeforeSeasonNumber = seasonNumber; + } + else { + airsAfterSeasonNumber = seasonNumber; + } + break; + case SpecialOrderType.InBetweenSeasonMixed: + case SpecialOrderType.InBetweenSeasonByOtherData: + // We need to have TvDB/TMDB data in the first place to do this method. + if (episodeInfo.TvDB == null) { + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirDate; + break; + } + + episodeNumber = episodeInfo.TvDB.AirsBeforeEpisode; + if (!episodeNumber.HasValue) { + if (episodeInfo.TvDB.AirsBeforeSeason.HasValue) { + airsBeforeSeasonNumber = seasonNumber; + break; + } + + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirDate; + airsAfterSeasonNumber = seasonNumber; + break; + } + + var nextEpisode = seasonInfo.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.SeasonNumber == seasonNumber && e.TvDB.EpisodeNumber == episodeNumber); + if (nextEpisode != null) { + airsBeforeEpisodeNumber = GetEpisodeNumber(showInfo, seasonInfo, nextEpisode); + airsBeforeSeasonNumber = seasonNumber; + break; + } + + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirDate; + break; + } + + return (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, true); + } + + /// <summary> + /// Get season number for an episode in a series. + /// </summary> + /// <param name="showInfo"></param> + /// <param name="seasonInfo"></param> + /// <param name="episodeInfo"></param> + /// <returns></returns> + public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) + { + if (!showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var seasonNumber)) + return 0; + + if (seasonInfo.AlternateEpisodesList.Any(ep => ep.Id == episodeInfo.Id)) + return seasonNumber + 1; + + return seasonNumber; + } + + /// <summary> + /// Get the extra type for an episode. + /// </summary> + /// <param name="episode"></param> + /// <returns></returns> + public static ExtraType? GetExtraType(Episode.AniDB episode) + { + switch (episode.Type) + { + case EpisodeType.Normal: + return null; + case EpisodeType.ThemeSong: + case EpisodeType.OpeningSong: + case EpisodeType.EndingSong: + return ExtraType.ThemeVideo; + case EpisodeType.Trailer: + return ExtraType.Trailer; + case EpisodeType.Other: { + var title = Text.GetTitlesForLanguage(episode.Titles, false, "en"); + if (string.IsNullOrEmpty(title)) + return null; + // Interview + if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Interview; + // Cinema/theatrical intro/outro + if ( + (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) || title.StartsWith("theatrical ", System.StringComparison.OrdinalIgnoreCase)) && + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase)) + ) + return ExtraType.Clip; + return null; + } + case EpisodeType.Special: { + var title = Text.GetTitlesForLanguage(episode.Titles, false, "en"); + if (string.IsNullOrEmpty(title)) + return null; + // Interview + if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Interview; + // Cinema/theatrical intro/outro + if ( + (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) || title.StartsWith("theatrical ", System.StringComparison.OrdinalIgnoreCase)) && + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase)) + ) + return ExtraType.Clip; + // Behind the Scenes + if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + if (title.Contains("music in", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + if (title.Contains("advance screening", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + if (title.Contains("premiere", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + return null; + } + default: + return ExtraType.Unknown; + } + } +} diff --git a/Shokofin/Utils/PropertyWatcher.cs b/Shokofin/Utils/PropertyWatcher.cs new file mode 100644 index 00000000..019580bb --- /dev/null +++ b/Shokofin/Utils/PropertyWatcher.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; + +namespace Shokofin.Utils; + +public class PropertyWatcher<T> +{ + private readonly Func<T> _valueGetter; + + private bool _continueMonitoring; + + public T Value { get; private set; } + + public event EventHandler<T>? ValueChanged; + + public PropertyWatcher(Func<T> valueGetter) + { + _valueGetter = valueGetter; + Value = _valueGetter(); + } + + public void StartMonitoring(int delayInSeconds) + { + var delayInMilliseconds = delayInSeconds * 1000; + _continueMonitoring = true; + Value = _valueGetter(); + Task.Run(async () => { + while (_continueMonitoring) { + await Task.Delay(delayInMilliseconds); + CheckForChange(); + } + }); + } + + public void StopMonitoring() + { + _continueMonitoring = false; + } + + private void CheckForChange() + { + var currentValue = _valueGetter()!; + if (!Value!.Equals(currentValue)) { + ValueChanged?.Invoke(null, currentValue); + Value = currentValue; + } + } +} diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs new file mode 100644 index 00000000..b748c5c4 --- /dev/null +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shokofin.API.Info; +using Shokofin.API.Models; + +namespace Shokofin.Utils; + +public class SeriesInfoRelationComparer : IComparer<SeasonInfo> +{ + private static readonly Dictionary<RelationType, int> RelationPriority = new() { + { RelationType.Prequel, 1 }, + { RelationType.MainStory, 2 }, + { RelationType.FullStory, 3 }, + + { RelationType.AlternativeVersion, 21 }, + { RelationType.SameSetting, 22 }, + { RelationType.AlternativeSetting, 23 }, + + { RelationType.SideStory, 41 }, + { RelationType.Summary, 42 }, + { RelationType.Sequel, 43 }, + + { RelationType.SharedCharacters, 99 }, + }; + + public int Compare(SeasonInfo? a, SeasonInfo? b) + { + // Check for `null` since `IComparer<T>` expects `T` to be nullable. + if (a == null && b == null) + return 0; + if (a == null) + return 1; + if (b == null) + return -1; + + // Check for direct relations. + var directRelationComparison = CompareDirectRelations(a, b); + if (directRelationComparison != 0) + return directRelationComparison; + + // Check for indirect relations. + if (Plugin.Instance.Configuration.SeasonOrdering != Ordering.OrderType.ChronologicalIgnoreIndirect) + { + var indirectRelationComparison = CompareIndirectRelations(a, b); + if (indirectRelationComparison != 0) + return indirectRelationComparison; + } + + // Fallback to checking the air dates if they're not indirectly related + // or if they have the same relations. + return CompareAirDates(a.AniDB.AirDate, b.AniDB.AirDate); + } + + private static int CompareDirectRelations(SeasonInfo a, SeasonInfo b) + { + // We check from both sides because one of the entries may be outdated, + // so the relation may only present on one of the entries. + if (a.RelationMap.TryGetValue(b.Id, out var relationType)) + if (relationType == RelationType.Prequel || relationType == RelationType.MainStory) + return 1; + else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) + return -1; + + if (b.RelationMap.TryGetValue(a.Id, out relationType)) + if (relationType == RelationType.Prequel || relationType == RelationType.MainStory) + return -1; + else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) + return 1; + + // The entries are not considered to be directly related. + return 0; + } + + private static int CompareIndirectRelations(SeasonInfo a, SeasonInfo b) + { + var xRelations = a.Relations + .Where(r => RelationPriority.ContainsKey(r.Type)) + .Select(r => r.Type) + .OrderBy(r => RelationPriority[r]) + .ToList(); + var yRelations = b.Relations + .Where(r => RelationPriority.ContainsKey(r.Type)) + .Select(r => r.Type) + .OrderBy(r => RelationPriority[r]) + .ToList(); + for (int i = 0; i < Math.Max(xRelations.Count, yRelations.Count); i++) { + // The first entry have overall less relations, so it comes after the second entry. + if (i >= xRelations.Count) + return 1; + // The second entry have overall less relations, so it comes after the first entry. + else if (i >= yRelations.Count) + return -1; + + // Compare the relation priority to see which have a higher priority. + var xRelationType = xRelations[i]; + var xRelationPriority = RelationPriority[xRelationType]; + var yRelationType = yRelations[i]; + var yRelationPriority = RelationPriority[yRelationType]; + var relationPriorityComparison = xRelationPriority.CompareTo(yRelationPriority); + if (relationPriorityComparison != 0) + return relationPriorityComparison; + } + + // The entries are not considered to be indirectly related, or they have + // the same relations. + return 0; + } + + private static int CompareAirDates(DateTime? a, DateTime? b) + { + return a.HasValue ? b.HasValue ? DateTime.Compare(a.Value, b.Value) : 1 : b.HasValue ? -1 : 0; + } +} diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs new file mode 100644 index 00000000..778fa185 --- /dev/null +++ b/Shokofin/Utils/TagFilter.cs @@ -0,0 +1,557 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.Events.Interfaces; + +namespace Shokofin.Utils; + +public static class TagFilter +{ + /// <summary> + /// Include only the children of the selected tags. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceIncludeAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceIncludeAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// Include only the selected tags, but not their children. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceIncludeOnlyAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceIncludeOnlyAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// Exclude the selected tags and all their children. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceExcludeOnlyAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceExcludeOnlyAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// Exclude the selected tags, but don't exclude their children. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceExcludeAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceExcludeAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// All available tag sources to use. + /// </summary> + [Flags] + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TagSource { + /// <summary> + /// The content indicators branch is intended to be a less geographically specific + /// tool than the `age rating` used by convention, for warning about things that + /// might cause offense. Obviously there is still a degree of subjectivity + /// involved, but hopefully it will prove useful for parents with delicate + /// children, or children with delicate parents. + /// </summary> + [TagSourceInclude("/content indicators")] + ContentIndicators = 1 << 0, + + /// <summary> + /// Central structural elements in the anime. + /// </summary> + [TagSourceInclude("/dynamic")] + [TagSourceExclude("/dynamic/cast", "/dynamic/ending")] + Dynamic = 1 << 1, + + /// <summary> + /// Cast related structural elements in the anime. + /// </summary> + [TagSourceInclude("/dynamic/cast")] + DynamicCast = 1 << 2, + + /// <summary> + /// Ending related structural elements in the anime. + /// </summary> + [TagSourceInclude("/dynamic/ending")] + DynamicEnding = 1 << 3, + + // 4 is reserved for story telling if we add it as a separate source. + + /// <summary> + /// Next to <see cref="Themes"/> setting the backdrop for the protagonists in the + /// anime, there are the more detailed plot elements that centre on character + /// interactions: "What do characters do to each other or what is done to them?". + /// Is it violent action, an awe-inspiring adventure in a foreign place, the + /// gripping life of a detective, a slapstick comedy, an ecchi harem anime, + /// a sci-fi epic, or some fantasy traveling adventure, etc.. + /// </summary> + [TagSourceInclude("/elements/speculative fiction", "/elements")] + [TagSourceExclude("/elements/pornography", "/elements/sexual abuse", "/elements/tropes", "/elements/motifs")] + [TagSourceExcludeOnly("/elements/speculative fiction")] + Elements = 1 << 5, + + /// <summary> + /// Anime clearly marked as "Restricted 18" material centring on all variations of + /// adult sex, some of which can be considered as quite perverse. To a certain + /// extent, some of the elements can be seen on late night TV animations. Sexual + /// abuse is the act of one person forcing sexual activities upon another. Sexual + /// abuse includes not only physical coercion and sexual assault, especially rape, + /// but also psychological abuse, such as verbal sexual behavior or stalking, + /// including emotional manipulation. + /// </summary> + [TagSourceInclude("/elements/pornography", "/elements/sexual abuse")] + ElementsPornographyAndSexualAbuse = 1 << 6, + + /// <summary> + /// A trope is a commonly recurring literary and rhetorical devices, motifs or + /// clichés in creative works. + /// </summary> + [TagSourceInclude("/elements/tropes", "/elements/motifs")] + ElementsTropesAndMotifs = 1 << 7, + + /// <summary> + /// For non-porn anime, the fetish must be a major element of the show; incidental + /// appearances of the fetish is not sufficient for a fetish tag. Please do not + /// add fetish tags to anime that do not pander to the fetish in question in any + /// meaningful way. For example, there's some ecchi in Shinseiki Evangelion, but + /// the fact you get to see Asuka's panties is not sufficient to warrant applying + /// the school girl fetish tag. Most porn anime play out the fetish, making tag + /// application fairly straightforward. + /// </summary> + [TagSourceInclude("/fetishes/breasts", "/fetishes")] + [TagSourceExcludeOnly("/fetishes/breasts")] + Fetishes = 1 << 8, + + /// <summary> + /// Origin production locations. + /// </summary> + [TagSourceInclude("/origin")] + [TagSourceExcludeOnly("/origin/development hell", "/origin/fan-made", "/origin/remake")] + OriginProduction = 1 << 9, + + /// <summary> + /// Origin development information. + /// </summary> + [TagSourceIncludeOnly("/origin/development hell", "/origin/fan-made", "/origin/remake")] + OriginDevelopment = 1 << 10, + + /// <summary> + /// The places the anime takes place in. Includes more specific places such as a + /// country on Earth, as well as more general places such as a dystopia or a + /// mirror world. + /// </summary> + [TagSourceInclude("/setting/place")] + [TagSourceExcludeOnly("/settings/place/Earth")] + SettingPlace = 1 << 11, + + /// <summary> + /// This placeholder lists different epochs in human history and more vague but + /// important timelines such as the future, the present and the past. + /// </summary> + [TagSourceInclude("/setting/time")] + [TagSourceExclude("/setting/time/season")] + SettingTimePeriod = 1 << 12, + + /// <summary> + /// In temperate and sub-polar regions, four calendar-based seasons (with their + /// adjectives) are generally recognized: + /// - spring (vernal), + /// - summer (estival), + /// - autumn/fall (autumnal), and + /// - winter (hibernal). + /// </summary> + [TagSourceInclude("/setting/time/season")] + SettingTimeSeason = 1 << 13, + + /// <summary> + /// What the anime is based on! This is given as the original work credit in the + /// OP. Mostly of academic interest, but a useful bit of info, hinting at the + /// possible depth of story. + /// </summary> + /// <remarks> + /// This is not sourced from the tags, but rather from the dedicated method. + /// </remarks> + SourceMaterial = 1 << 14, + + /// <summary> + /// Anime, like everything else in the modern world, is targeted towards specific + /// audiences, both implicitly by the creators and overtly by the marketing. + /// </summary> + [TagSourceInclude("/target audience")] + TargetAudience = 1 << 15, + + /// <summary> + /// It may sometimes be useful to know about technical aspects of a show, such as + /// information about its broadcasting or censorship. Such information can be + /// found here. + /// </summary> + [TagSourceInclude("/technical aspects")] + [TagSourceExclude("/technical aspects/adapted into other media", "/technical aspects/awards", "/technical aspects/multi-anime projects")] + TechnicalAspects = 1 << 16, + + /// <summary> + /// This anime is a new original work, and it has been adapted into other media + /// formats. + /// + /// In exceedingly rare instances, a specific episode of a new original work anime + /// can also be adapted. + /// </summary> + [TagSourceInclude("/technical aspects/adapted into other media")] + TechnicalAspectsAdaptions = 1 << 17, + + /// <summary> + /// Awards won by the anime. + /// </summary> + [TagSourceInclude("/technical aspects/awards")] + TechnicalAspectsAwards = 1 << 18, + + /// <summary> + /// Many anime are created as part of larger projects encompassing many shows + /// without direct relation to one another. Normally, there is a specific idea in + /// mind: for example, the Young Animator Training Project aims to stimulate the + /// on-the-job training of next-generation professionals of the anime industry, + /// whereas the World Masterpiece Theatre aims to animate classical stories from + /// all over the world. + /// </summary> + [TagSourceInclude("/technical aspects/multi-anime projects")] + TechnicalAspectsMultiAnimeProjects = 1 << 19, + + /// <summary> + /// Themes describe the very central elements important to the anime stories. They + /// set the backdrop against which the protagonists must face their challenges. + /// Be it school life, present daily life, military action, cyberpunk, law and + /// order detective work, sports, or the underworld. These are only but a few of + /// the more typical backgrounds for anime plots. Add to that a conspiracy setting + /// with a possible tragic outcome, the themes span most of the imaginable subject + /// matter relevant to the anime. + /// </summary> + [TagSourceInclude("/themes")] + [TagSourceExclude("/themes/death", "/themes/tales")] + [TagSourceExcludeOnly("/themes/body and host", "/themes/family life", "/themes/money")] + Themes = 1 << 20, + + // 21 to 23 are reserved for the above exclusions if we decide to branch them off + // into their own source. + + /// <summary> + /// Death is the state of no longer being alive or the process of ceasing to be + /// alive. As Emiya Shirou once said it; "People die when they're killed." + /// </summary> + [TagSourceInclude("/themes/death")] + ThemesDeath = 1 << 24, + + /// <summary> + /// Tales are stories told time and again and passed down from generation to + /// generation, and some of those show up in anime not just once or twice, but + /// several times. + /// </summary> + [TagSourceInclude("/themes/tales")] + ThemesTales = 1 << 25, + + /// <summary> + /// Everything under the ungrouped tag. + /// </summary> + [TagSourceInclude("/ungrouped")] + Ungrouped = 1 << 26, + + /// <summary> + /// Everything under the unsorted tag. + /// </summary> + [TagSourceInclude("/unsorted")] + [TagSourceExclude("/unsorted/old animetags", "/unsorted/ending tags that need merging", "/unsorted/character related tags which need deleting or merging")] + Unsorted = 1 << 27, + + /// <summary> + /// Custom user tags. + /// </summary> + [TagSourceInclude("/custom user tags")] + CustomTags = 1 << 30, + } + + [Flags] + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TagIncludeFilter { + Parent = 1, + Child = 2, + Abstract = 4, + Weightless = 8, + Weighted = 16, + GlobalSpoiler = 32, + LocalSpoiler = 64, + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TagWeight { + Weightless = 0, + One = 100, + Two = 200, + Three = 300, + Four = 400, + Five = 500, + Six = 600, + } + + private static ProviderName[] GetOrderedProductionLocationProviders() + => Plugin.Instance.Configuration.ProductionLocationOverride + ? Plugin.Instance.Configuration.ProductionLocationOrder.Where((t) => Plugin.Instance.Configuration.ProductionLocationList.Contains(t)).ToArray() + : new[] { ProviderName.AniDB, ProviderName.TMDB }; + +#pragma warning disable IDE0060 + public static IReadOnlyList<string> GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) +#pragma warning restore IDE0060 + { + // TODO: Add TMDB movie linked to episode content rating here. + foreach (var provider in GetOrderedProductionLocationProviders()) { + var title = provider switch { + ProviderName.AniDB => seasonInfo.ProductionLocations, + // TODO: Add TMDB series content rating here. + _ => Array.Empty<string>(), + }; + if (title.Count > 0) + return title; + } + return Array.Empty<string>(); + } + + public static IReadOnlyList<string> GetSeasonContentRating(SeasonInfo seasonInfo) + { + foreach (var provider in GetOrderedProductionLocationProviders()) { + var title = provider switch { + ProviderName.AniDB => seasonInfo.ProductionLocations, + // TODO: Add TMDB series content rating here. + _ => Array.Empty<string>(), + }; + if (title.Count > 0) + return title; + } + return Array.Empty<string>(); + } + + public static IReadOnlyList<string> GetShowContentRating(ShowInfo showInfo) + { + foreach (var provider in GetOrderedProductionLocationProviders()) { + var title = provider switch { + ProviderName.AniDB => showInfo.ProductionLocations, + // TODO: Add TMDB series content rating here. + _ => Array.Empty<string>(), + }; + if (title.Count > 0) + return title; + } + return Array.Empty<string>(); + } + + public static string[] FilterTags(IReadOnlyDictionary<string, ResolvedTag> tags) + { + var config = Plugin.Instance.Configuration; + if (!config.TagOverride) + return FilterInternal( + tags, + TagSource.ContentIndicators | TagSource.Dynamic | TagSource.DynamicCast | TagSource.DynamicEnding | TagSource.Elements | + TagSource.ElementsPornographyAndSexualAbuse | TagSource.ElementsTropesAndMotifs | TagSource.Fetishes | + TagSource.OriginProduction | TagSource.OriginDevelopment | TagSource.SourceMaterial | TagSource.SettingPlace | + TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.TargetAudience | TagSource.TechnicalAspects | + TagSource.TechnicalAspectsAdaptions | TagSource.TechnicalAspectsAwards | TagSource.TechnicalAspectsMultiAnimeProjects | + TagSource.Themes | TagSource.ThemesDeath | TagSource.ThemesTales | TagSource.CustomTags, + TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted, + TagWeight.Weightless, + 0 + ); + return FilterInternal(tags, config.TagSources, config.TagIncludeFilters, config.TagMinimumWeight, config.TagMaximumDepth); + } + + public static string[] FilterGenres(IReadOnlyDictionary<string, ResolvedTag> tags) + { + var config = Plugin.Instance.Configuration; + if (!config.GenreOverride) + return FilterInternal( + tags, + TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.Elements, + TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted, + TagWeight.Four, + 1 + ); + return FilterInternal(tags, config.GenreSources, config.GenreIncludeFilters, config.GenreMinimumWeight, config.GenreMaximumDepth); + } + + private static readonly HashSet<TagSource> AllFlagsToUse = Enum.GetValues<TagSource>().Except(new[] { TagSource.CustomTags }).ToHashSet(); + + private static readonly HashSet<TagSource> AllFlagsToUseForCustomTags = AllFlagsToUse.Except(new[] { TagSource.SourceMaterial, TagSource.TargetAudience }).ToHashSet(); + + private static string[] FilterInternal(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight = TagWeight.Weightless, int maxDepth = 0) + { + var tagSet = new List<string>(); + foreach (var flag in AllFlagsToUse.Where(flag => source.HasFlag(flag))) + tagSet.AddRange(GetTagsFromSource(tags, flag, includeFilter, minWeight, maxDepth)); + + if (source.HasFlag(TagSource.CustomTags) && tags.TryGetValue("/custom user tags", out var customTags)) { + var count = tagSet.Count; + tagSet.AddRange(customTags.Children.Values.Where(tag => !tag.IsParent).Select(SelectTagName)); + count = tagSet.Count - count; + + // If we have any children that weren't added above, then run the additional checks on them. + if (customTags.RecursiveNamespacedChildren.Count != count) + foreach (var flag in AllFlagsToUseForCustomTags.Where(flag => source.HasFlag(flag))) + tagSet.AddRange(GetTagsFromSource(customTags.RecursiveNamespacedChildren, flag, includeFilter, minWeight, maxDepth)); + } + + return tagSet + .Distinct() + .OrderBy(a => a) + .ToArray(); + } + + private static HashSet<string> GetTagsFromSource(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight, int maxDepth) + { + if (source is TagSource.SourceMaterial) + return new() { GetSourceMaterial(tags) }; + + var tagSet = new HashSet<string>(); + var exceptTags = new List<ResolvedTag>(); + var includeTags = new List<KeyValuePair<string, ResolvedTag>>(); + var field = source.GetType().GetField(source.ToString())!; + var includeAttributes = field.GetCustomAttributes<TagSourceIncludeAttribute>(); + if (includeAttributes.Length is 1) + foreach (var tagName in includeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.AddRange(tag.RecursiveNamespacedChildren); + + var includeOnlyAttributes = field.GetCustomAttributes<TagSourceIncludeOnlyAttribute>(); + if (includeOnlyAttributes.Length is 1) + foreach (var tagName in includeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.Add(KeyValuePair.Create($"/{tag.Name}", tag)); + + var excludeAttributes = field.GetCustomAttributes<TagSourceExcludeAttribute>(); + if (excludeAttributes.Length is 1) + foreach (var tagName in excludeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.AddRange(tag.RecursiveNamespacedChildren.Values.Append(tag)); + + var excludeOnlyAttributes = field.GetCustomAttributes<TagSourceExcludeOnlyAttribute>(); + if (excludeOnlyAttributes.Length is 1) + foreach (var tagName in excludeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.Add(tag); + + includeTags = includeTags + .DistinctBy(pair => $"{pair.Value.Source}:{pair.Value.Id}") + .ExceptBy(exceptTags, pair => pair.Value) + .ToList(); + foreach (var (relativeName, tag) in includeTags) { + var depth = relativeName[1..].Split('/').Length; + if (maxDepth > 0 && depth > maxDepth) + continue; + if (tag.IsLocalSpoiler && !includeFilter.HasFlag(TagIncludeFilter.LocalSpoiler)) + continue; + if (tag.IsGlobalSpoiler && !includeFilter.HasFlag(TagIncludeFilter.GlobalSpoiler)) + continue; + if (tag.IsAbstract && !includeFilter.HasFlag(TagIncludeFilter.Abstract)) + continue; + if (tag.IsWeightless ? !includeFilter.HasFlag(TagIncludeFilter.Weightless) : !includeFilter.HasFlag(TagIncludeFilter.Weighted)) + continue; + if (tag.IsParent ? !includeFilter.HasFlag(TagIncludeFilter.Parent) : !includeFilter.HasFlag(TagIncludeFilter.Child)) + continue; + if (minWeight is > TagWeight.Weightless && !tag.IsWeightless && tag.Weight < minWeight) + continue; + tagSet.Add(SelectTagName(tag)); + } + + return tagSet; + } + + private static string GetSourceMaterial(IReadOnlyDictionary<string, ResolvedTag> tags) + { + if (!tags.TryGetValue("/source material", out var sourceMaterial) || sourceMaterial.Children.ContainsKey("Original Work")) + return "Original Work"; + + var firstSource = sourceMaterial.Children.Keys.OrderBy(material => material).FirstOrDefault()?.ToLowerInvariant(); + return firstSource switch { + "american derived" => "Adapted From Western Media", + "manga" => "Adapted From A Manga", + "manhua" => "Adapted From A Manhua", + "manhwa" => "Adapted from a Manhwa", + "movie" => "Adapted From A Live-Action Movie", + "novel" => "Adapted From A Novel", + "game" => sourceMaterial.Children[firstSource]!.Children.Keys.OrderBy(material => material).FirstOrDefault()?.ToLowerInvariant() switch { + "erotic game" => "Adapted From An Eroge", + "visual novel" => "Adapted From A Visual Novel", + _ => "Adapted From A Video Game", + }, + "television programme" => sourceMaterial.Children[firstSource]!.Children.Keys.OrderBy(material => material).FirstOrDefault()?.ToLowerInvariant() switch { + "korean drama" => "Adapted From A Korean Drama", + _ => "Adapted From A Live-Action Show", + }, + "radio programme" => "Radio Programme", + "western animated cartoon" => "Adapted From Western Media", + "western comics" => "Adapted From Western Media", + _ => "Original Work", + }; + } + + public static string[] GetProductionCountriesFromTags(IReadOnlyDictionary<string, ResolvedTag> tags) + { + if (!tags.TryGetValue("/origin", out var origin)) + return Array.Empty<string>(); + + var productionCountries = new List<string>(); + foreach (var childTag in origin.Children.Keys) { + productionCountries.AddRange(childTag.ToLowerInvariant() switch { + "american-japanese co-production" => new string[] {"Japan", "United States of America" }, + "chinese production" => new string[] {"China" }, + "french-chinese co-production" => new string[] {"France", "China" }, + "french-japanese co-production" => new string[] {"Japan", "France" }, + "indo-japanese co-production" => new string[] {"Japan", "India" }, + "japanese production" => new string[] {"Japan" }, + "korean-japanese co-production" => new string[] {"Japan", "Republic of Korea" }, + "north korean production" => new string[] {"Democratic People's Republic of Korea" }, + "polish-japanese co-production" => new string[] {"Japan", "Poland" }, + "russian-japanese co-production" => new string[] {"Japan", "Russia" }, + "saudi arabian-japanese co-production" => new string[] {"Japan", "Saudi Arabia" }, + "italian-japanese co-production" => new string[] {"Japan", "Italy" }, + "singaporean production" => new string[] {"Singapore" }, + "sino-japanese co-production" => new string[] {"Japan", "China" }, + "south korea production" => new string[] {"Republic of Korea" }, + "taiwanese production" => new string[] {"Taiwan" }, + "thai production" => new string[] {"Thailand" }, + _ => Array.Empty<string>(), + }); + } + return productionCountries + .Distinct() + .ToArray(); + } + + private static string SelectTagName(ResolvedTag tag) + => System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.DisplayName); + + private static bool HasAnyFlags(this Enum value, params Enum[] candidates) + => candidates.Any(value.HasFlag); +} \ No newline at end of file diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs new file mode 100644 index 00000000..eccaf328 --- /dev/null +++ b/Shokofin/Utils/Text.cs @@ -0,0 +1,439 @@ +using Shokofin.API.Info; +using Shokofin.API.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Shokofin.Utils; + +public static class Text +{ + private static readonly HashSet<char> PunctuationMarks = new() { + // Common punctuation marks + '.', // period + ',', // comma + ';', // semicolon + ':', // colon + '!', // exclamation point + '?', // question mark + ')', // right parenthesis + ']', // right bracket + '}', // right brace + '"', // double quote + '\'', // single quote + ',', // Chinese comma + '、', // Chinese enumeration comma + '!', // Chinese exclamation point + '?', // Chinese question mark + '“', // Chinese double quote + '”', // Chinese double quote + '‘', // Chinese single quote + '’', // Chinese single quote + '】', // Chinese right bracket + '》', // Chinese right angle bracket + ')', // Chinese right parenthesis + '・', // Japanese middle dot + + // Less common punctuation marks + '‽', // interrobang + '❞', // double question mark + '❝', // double exclamation mark + '⁇', // question mark variation + '⁈', // exclamation mark variation + '❕', // white exclamation mark + '❔', // white question mark + '⁉', // exclamation mark + '※', // reference mark + '⟩', // right angle bracket + '❯', // right angle bracket + '❭', // right angle bracket + '〉', // right angle bracket + '⌉', // right angle bracket + '⌋', // right angle bracket + '⦄', // right angle bracket + '⦆', // right angle bracket + '⦈', // right angle bracket + '⦊', // right angle bracket + '⦌', // right angle bracket + '⦎', // right angle bracket + }; + + private static readonly HashSet<string> IgnoredSubTitles = new(StringComparer.InvariantCultureIgnoreCase) { + "Complete Movie", + "Music Video", + "OAD", + "OVA", + "Short Movie", + "Special", + "TV Special", + "Web", + }; + + /// <summary> + /// Determines which provider to use to provide the descriptions. + /// </summary> + public enum DescriptionProvider { + /// <summary> + /// Provide the Shoko Group description for the show, if the show is + /// constructed using Shoko's groups feature. + /// </summary> + Shoko = 1, + + /// <summary> + /// Provide the description from AniDB. + /// </summary> + AniDB = 2, + + /// <summary> + /// Provide the description from TvDB. + /// </summary> + TvDB = 3, + + /// <summary> + /// Provide the description from TMDB. + /// </summary> + TMDB = 4 + } + + /// <summary> + /// Determines which provider and method to use to look-up the title. + /// </summary> + public enum TitleProvider { + /// <summary> + /// Let Shoko decide what to display. + /// </summary> + Shoko_Default = 1, + + /// <summary> + /// Use the default title as provided by AniDB. + /// </summary> + AniDB_Default = 2, + + /// <summary> + /// Use the selected metadata language for the library as provided by + /// AniDB. + /// </summary> + AniDB_LibraryLanguage = 3, + + /// <summary> + /// Use the title in the origin language as provided by AniDB. + /// </summary> + AniDB_CountryOfOrigin = 4, + + /// <summary> + /// Use the default title as provided by TMDB. + /// </summary> + TMDB_Default = 5, + + /// <summary> + /// Use the selected metadata language for the library as provided by + /// TMDB. + /// </summary> + TMDB_LibraryLanguage = 6, + + /// <summary> + /// Use the title in the origin language as provided by TMDB. + /// </summary> + TMDB_CountryOfOrigin = 7, + } + + /// <summary> + /// Determines which type of title to look-up. + /// </summary> + public enum TitleProviderType { + /// <summary> + /// The main title used for metadata entries. + /// </summary> + Main = 0, + + /// <summary> + /// The secondary title used for metadata entries. + /// </summary> + Alternate = 1, + } + + public static string GetDescription(ShowInfo show) + => GetDescriptionByDict(new() { + {DescriptionProvider.Shoko, show.Shoko?.Description}, + {DescriptionProvider.AniDB, show.DefaultSeason.AniDB.Description}, + {DescriptionProvider.TvDB, show.DefaultSeason.TvDB?.Description}, + }); + + public static string GetDescription(SeasonInfo season) + => GetDescriptionByDict(new() { + {DescriptionProvider.AniDB, season.AniDB.Description}, + {DescriptionProvider.TvDB, season.TvDB?.Description}, + }); + + public static string GetDescription(EpisodeInfo episode) + => GetDescriptionByDict(new() { + {DescriptionProvider.AniDB, episode.AniDB.Description}, + {DescriptionProvider.TvDB, episode.TvDB?.Description}, + }); + + public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) + => JoinText(episodeList.Select(episode => GetDescription(episode))) ?? string.Empty; + + /// <summary> + /// Returns a list of the description providers to check, and in what order + /// </summary> + private static DescriptionProvider[] GetOrderedDescriptionProviders() + => Plugin.Instance.Configuration.DescriptionSourceOverride + ? Plugin.Instance.Configuration.DescriptionSourceOrder.Where((t) => Plugin.Instance.Configuration.DescriptionSourceList.Contains(t)).ToArray() + : new[] { DescriptionProvider.Shoko, DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB }; + + private static string GetDescriptionByDict(Dictionary<DescriptionProvider, string?> descriptions) + { + foreach (var provider in GetOrderedDescriptionProviders()) + { + var overview = provider switch + { + DescriptionProvider.Shoko => + descriptions.TryGetValue(DescriptionProvider.Shoko, out var desc) ? SanitizeAnidbDescription(desc ?? string.Empty) : null, + DescriptionProvider.AniDB => + descriptions.TryGetValue(DescriptionProvider.AniDB, out var desc) ? SanitizeAnidbDescription(desc ?? string.Empty) : null, + DescriptionProvider.TvDB => + descriptions.TryGetValue(DescriptionProvider.TvDB, out var desc) ? desc : null, + _ => null + }; + if (!string.IsNullOrEmpty(overview)) + return overview; + } + return string.Empty; + } + + /// <summary> + /// Sanitize the AniDB entry description to something usable by Jellyfin. + /// </summary> + /// <remarks> + /// Based on ShokoMetadata's summary sanitizer which in turn is based on HAMA's summary sanitizer. + /// </remarks> + /// <param name="summary">The raw AniDB description.</param> + /// <returns>The sanitized AniDB description.</returns> + public static string SanitizeAnidbDescription(string summary) + { + if (string.IsNullOrWhiteSpace(summary)) + return string.Empty; + + var config = Plugin.Instance.Configuration; + if (config.SynopsisCleanLinks) + summary = summary.Replace(SynopsisCleanLinks, match => $"[{match.Groups[2].Value}]({match.Groups[1].Value})"); + + if (config.SynopsisCleanMiscLines) + summary = summary.Replace(SynopsisCleanMiscLines, string.Empty); + + if (config.SynopsisRemoveSummary) + summary = summary + .Replace(SynopsisRemoveSummary1, match => $"**{match.Groups[1].Value}**: ") + .Replace(SynopsisRemoveSummary2, string.Empty); + + if (config.SynopsisCleanMultiEmptyLines) + summary = summary + .Replace(SynopsisConvertNewLines, "\n") + .Replace(SynopsisCleanMultiEmptyLines, "\n"); + + return summary.Trim(); + } + + private static readonly Regex SynopsisCleanLinks = new(@"(https?:\/\/\w+.\w+(?:\/?\w+)?) \[([^\]]+)\]", RegexOptions.Compiled); + + private static readonly Regex SynopsisCleanMiscLines = new(@"^(\*|--|~)\s*", RegexOptions.Multiline | RegexOptions.Compiled); + + private static readonly Regex SynopsisRemoveSummary1 = new(@"\b(Note|Summary):\s*", RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex SynopsisRemoveSummary2 = new(@"\bSource: [^ ]+", RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex SynopsisConvertNewLines = new(@"\r\n|\r", RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex SynopsisCleanMultiEmptyLines = new(@"\n{2,}", RegexOptions.Singleline | RegexOptions.Compiled); + + public static string? JoinText(IEnumerable<string?> textList) + { + var filteredList = textList + .Where(title => !string.IsNullOrWhiteSpace(title)) + .Select(title => title!.Trim()) + // We distinct the list because some episode entries contain the **exact** same description. + .Distinct() + .ToList(); + + if (filteredList.Count == 0) + return null; + + var index = 1; + var outputText = filteredList[0]; + while (index < filteredList.Count) { + var lastChar = outputText[^1]; + outputText += PunctuationMarks.Contains(lastChar) ? " " : ". "; + outputText += filteredList[index++]; + } + + if (filteredList.Count > 1) + outputText = outputText.TrimEnd(); + + return outputText; + } + + public static (string?, string?) GetEpisodeTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) + => ( + GetEpisodeTitleByType(episodeInfo, seasonInfo, TitleProviderType.Main, metadataLanguage), + GetEpisodeTitleByType(episodeInfo, seasonInfo, TitleProviderType.Alternate, metadataLanguage) + ); + + public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, string? metadataLanguage) + => GetSeasonTitles(seasonInfo, 0, metadataLanguage); + + public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, int baseSeasonOffset, string? metadataLanguage) + { + var displayTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, TitleProviderType.Main, metadataLanguage); + var alternateTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, TitleProviderType.Alternate, metadataLanguage); + if (baseSeasonOffset > 0) { + string type = string.Empty; + switch (baseSeasonOffset) { + default: + break; + case 1: + type = "Alternate Version"; + break; + } + if (!string.IsNullOrEmpty(type)) { + displayTitle += $" ({type})"; + alternateTitle += $" ({type})"; + } + } + return (displayTitle, alternateTitle); + } + + public static (string?, string?) GetShowTitles(ShowInfo showInfo, string? metadataLanguage) + => ( + GetSeriesTitleByType(showInfo.DefaultSeason, showInfo.Name, TitleProviderType.Main, metadataLanguage), + GetSeriesTitleByType(showInfo.DefaultSeason, showInfo.Name, TitleProviderType.Alternate, metadataLanguage) + ); + + public static (string?, string?) GetMovieTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) + => ( + GetMovieTitleByType(episodeInfo, seasonInfo, TitleProviderType.Main, metadataLanguage), + GetMovieTitleByType(episodeInfo, seasonInfo, TitleProviderType.Alternate, metadataLanguage) + ); + + /// <summary> + /// Returns a list of the providers to check, and in what order + /// </summary> + private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType titleType) + => titleType switch { + TitleProviderType.Main => + Plugin.Instance.Configuration.TitleMainOverride + ? Plugin.Instance.Configuration.TitleMainOrder.Where((t) => Plugin.Instance.Configuration.TitleMainList.Contains(t)).ToArray() + : new[] { TitleProvider.Shoko_Default }, + TitleProviderType.Alternate => + Plugin.Instance.Configuration.TitleAlternateOverride + ? Plugin.Instance.Configuration.TitleAlternateOrder.Where((t) => Plugin.Instance.Configuration.TitleAlternateList.Contains(t)).ToArray() + : new[] { TitleProvider.AniDB_CountryOfOrigin, TitleProvider.TMDB_CountryOfOrigin }, + _ => Array.Empty<TitleProvider>(), + }; + + private static string? GetMovieTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string? metadataLanguage) + { + var mainTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, type, metadataLanguage); + var subTitle = GetEpisodeTitleByType(episodeInfo, seasonInfo, type, metadataLanguage); + + if (!(string.IsNullOrEmpty(subTitle) || IgnoredSubTitles.Contains(subTitle))) + return $"{mainTitle}: {subTitle}".Trim(); + return mainTitle?.Trim(); + } + + private static string? GetEpisodeTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string? metadataLanguage) + { + foreach (var provider in GetOrderedTitleProvidersByType(type)) { + var title = provider switch { + TitleProvider.Shoko_Default => + episodeInfo.Shoko.Name, + TitleProvider.AniDB_Default => + episodeInfo.AniDB.Titles.FirstOrDefault(title => title.LanguageCode == "en")?.Value, + TitleProvider.AniDB_LibraryLanguage => + GetTitlesForLanguage(episodeInfo.AniDB.Titles, false, metadataLanguage), + TitleProvider.AniDB_CountryOfOrigin => + GetTitlesForLanguage(episodeInfo.AniDB.Titles, false, GuessOriginLanguage(GetMainLanguage(seasonInfo.AniDB.Titles))), + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); + } + return null; + } + + private static string? GetSeriesTitleByType(SeasonInfo seasonInfo, string defaultName, TitleProviderType type, string? metadataLanguage) + { + foreach (var provider in GetOrderedTitleProvidersByType(type)) { + var title = provider switch { + TitleProvider.Shoko_Default => + defaultName, + TitleProvider.AniDB_Default => + seasonInfo.AniDB.Titles.FirstOrDefault(title => title.Type == TitleType.Main)?.Value, + TitleProvider.AniDB_LibraryLanguage => + GetTitlesForLanguage(seasonInfo.AniDB.Titles, true, metadataLanguage), + TitleProvider.AniDB_CountryOfOrigin => + GetTitlesForLanguage(seasonInfo.AniDB.Titles, true, GuessOriginLanguage(GetMainLanguage(seasonInfo.AniDB.Titles))), + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); + } + return null; + } + + /// <summary> + /// Get the first title available for the language, optionally using types + /// to filter the list in addition to the metadata languages provided. + /// </summary> + /// <param name="titles">Title list to search.</param> + /// <param name="usingTypes">Search using titles</param> + /// <param name="metadataLanguages">The metadata languages to search for.</param> + /// <returns>The first found title in any of the provided metadata languages, or null.</returns> + public static string? GetTitlesForLanguage(List<Title> titles, bool usingTypes, params string?[] metadataLanguages) + { + foreach (var lang in metadataLanguages) { + if (string.IsNullOrEmpty(lang)) + continue; + + var titleList = titles.Where(t => t.LanguageCode == lang).ToList(); + if (titleList.Count == 0) + continue; + + if (usingTypes) { + var title = titleList.FirstOrDefault(t => t.Type == TitleType.Official)?.Value; + if (string.IsNullOrEmpty(title) && Plugin.Instance.Configuration.TitleAllowAny) + title = titleList.FirstOrDefault()?.Value; + if (title != null) + return title; + } + else { + var title = titles.FirstOrDefault()?.Value; + if (title != null) + return title; + } + } + return null; + } + + /// <summary> + /// Get the main title language from the title list. + /// </summary> + /// <param name="titles">Title list.</param> + /// <returns>The main title language code.</returns> + private static string GetMainLanguage(IEnumerable<Title> titles) + => titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode ?? titles.FirstOrDefault()?.LanguageCode ?? "x-other"; + + /// <summary> + /// Guess the origin language based on the main title language. + /// </summary> + /// <param name="langCode">The main title language code.</param> + /// <returns>The list of origin language codes to try and use.</returns> + private static string[] GuessOriginLanguage(string langCode) + => langCode switch { + "x-other" => new string[] { "ja" }, + "x-jat" => new string[] { "ja" }, + "x-zht" => new string[] { "zn-hans", "zn-hant", "zn-c-mcm", "zn" }, + _ => new string[] { langCode }, + }; +} diff --git a/Shokofin/Utils/UsageTracker.cs b/Shokofin/Utils/UsageTracker.cs new file mode 100644 index 00000000..3cf0a3b3 --- /dev/null +++ b/Shokofin/Utils/UsageTracker.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Concurrent; +using System.Timers; +using Microsoft.Extensions.Logging; + +namespace Shokofin.Utils; + +public class UsageTracker +{ + private readonly ILogger<UsageTracker> Logger; + + private readonly object LockObj = new(); + + private readonly Timer StalledTimer; + + public TimeSpan Timeout { get; private set; } + + public ConcurrentDictionary<Guid, string> CurrentTrackers { get; private init; } = new(); + + public event EventHandler? Stalled; + + public UsageTracker(ILogger<UsageTracker> logger, TimeSpan timeout) + { + Logger = logger; + Timeout = timeout; + StalledTimer = new(timeout.TotalMilliseconds) { + AutoReset = false, + Enabled = false, + }; + StalledTimer.Elapsed += OnTimerElapsed; + } + + ~UsageTracker() { + StalledTimer.Elapsed -= OnTimerElapsed; + StalledTimer.Dispose(); + } + + public void UpdateTimeout(TimeSpan timeout) + { + if (Timeout == timeout) + return; + + lock (LockObj) { + if (Timeout == timeout) + return; + + Logger.LogTrace("Timeout changed. (Previous={PreviousTimeout},Next={NextTimeout})", Timeout, timeout); + var timerRunning = StalledTimer.Enabled; + if (timerRunning) + StalledTimer.Stop(); + + Timeout = timeout; + StalledTimer.Interval = timeout.TotalMilliseconds; + + if (timerRunning) + StalledTimer.Start(); + } + } + + private void OnTimerElapsed(object? sender, ElapsedEventArgs eventArgs) + { + Logger.LogDebug("Dispatching stalled event."); + Stalled?.Invoke(this, new()); + } + + public IDisposable Enter(string name) + { + var trackerId = Add(name); + return new DisposableAction(() => Remove(trackerId)); + } + + public Guid Add(string name) + { + Guid trackerId = Guid.NewGuid(); + while (!CurrentTrackers.TryAdd(trackerId, name)) + trackerId = Guid.NewGuid(); + Logger.LogTrace("Added tracker to {Name}. (Id={TrackerId})", name, trackerId); + if (StalledTimer.Enabled) { + lock (LockObj) { + if (StalledTimer.Enabled) { + Logger.LogTrace("Stopping timer."); + StalledTimer.Stop(); + } + } + } + return trackerId; + } + + public void Remove(Guid trackerId) + { + if (CurrentTrackers.TryRemove(trackerId, out var name)) { + Logger.LogTrace("Removed tracker from {Name}. (Id={TrackerId})", name, trackerId); + if (CurrentTrackers.IsEmpty && !StalledTimer.Enabled) { + lock (LockObj) { + if (CurrentTrackers.IsEmpty && !StalledTimer.Enabled) { + Logger.LogTrace("Starting timer."); + StalledTimer.Start(); + } + } + } + } + } +} \ No newline at end of file diff --git a/Shokofin/Web/ImageHostUrl.cs b/Shokofin/Web/ImageHostUrl.cs new file mode 100644 index 00000000..edd93d7e --- /dev/null +++ b/Shokofin/Web/ImageHostUrl.cs @@ -0,0 +1,38 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Shokofin.Web; + +/// <summary> +/// Responsible for tracking the base url we need for the next set of images +/// to-be presented to a client. +/// </summary> +public class ImageHostUrl : IAsyncActionFilter +{ + /// <summary> + /// The current image host url base to use. + /// </summary> + public static string Value { get; private set; } = "http://localhost:8096/"; + + private readonly object LockObj = new(); + + private static Regex RemoteImagesRegex = new(@"/Items/(?<itemId>[0-9a-fA-F]{32})/RemoteImages$", RegexOptions.Compiled); + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var request = context.HttpContext.Request; + var uriBuilder = new UriBuilder(request.Scheme, request.Host.Host, request.Host.Port ?? (request.Scheme == "https" ? 443 : 80), $"{request.PathBase}{request.Path}", request.QueryString.HasValue ? request.QueryString.Value : null); + var result = RemoteImagesRegex.Match(uriBuilder.Path); + if (result.Success) { + uriBuilder.Path = result.Length == uriBuilder.Path.Length ? "/" : uriBuilder.Path[..^result.Length] + "/"; + uriBuilder.Query = ""; + var uri = uriBuilder.ToString(); + lock (LockObj) + if (!string.Equals(uri, Value)) + Value = uri; + } + await next(); + } +} diff --git a/Shokofin/Web/ShokoApiController.cs b/Shokofin/Web/ShokoApiController.cs new file mode 100644 index 00000000..98a70e38 --- /dev/null +++ b/Shokofin/Web/ShokoApiController.cs @@ -0,0 +1,122 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Net.Http; +using System.Net.Mime; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; + +namespace Shokofin.Web; + +/// <summary> +/// Shoko API Host Web Controller. +/// </summary> +[ApiController] +[Route("Plugin/Shokofin/Host")] +[Produces(MediaTypeNames.Application.Json)] +public class ShokoApiController : ControllerBase +{ + private readonly ILogger<ShokoApiController> Logger; + + private readonly ShokoAPIClient APIClient; + + /// <summary> + /// Initializes a new instance of the <see cref="ShokoApiController"/> class. + /// </summary> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + public ShokoApiController(ILogger<ShokoApiController> logger, ShokoAPIClient apiClient) + { + Logger = logger; + APIClient = apiClient; + } + + /// <summary> + /// Try to get the version of the server. + /// </summary> + /// <returns></returns> + [HttpGet("Version")] + public async Task<ActionResult<ComponentVersion>> GetVersionAsync() + { + try { + Logger.LogDebug("Trying to get version from the remote Shoko server."); + var version = await APIClient.GetVersion().ConfigureAwait(false); + if (version == null) { + Logger.LogDebug("Failed to get version from the remote Shoko server."); + return StatusCode(StatusCodes.Status502BadGateway); + } + + Logger.LogDebug("Successfully got version {Version} from the remote Shoko server. (Channel={Channel},Commit={Commit})", version.Version, version.ReleaseChannel, version.Commit?[0..7]); + return version; + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to get version from the remote Shoko server. Exception; {ex}", ex.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + [HttpPost("GetApiKey")] + public async Task<ActionResult<ApiKey>> GetApiKeyAsync([FromBody] ApiLoginRequest body) + { + try { + Logger.LogDebug("Trying to create an API-key for user {Username}.", body.Username); + var apiKey = await APIClient.GetApiKey(body.Username, body.Password, body.UserKey).ConfigureAwait(false); + if (apiKey == null) { + Logger.LogDebug("Failed to create an API-key for user {Username} — invalid credentials received.", body.Username); + return StatusCode(StatusCodes.Status401Unauthorized); + } + + Logger.LogDebug("Successfully created an API-key for user {Username}.", body.Username); + return apiKey; + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to create an API-key for user {Username} — unable to complete the request.", body.Username); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } + + /// <summary> + /// Simple forward to grab the image from Shoko Server. + /// </summary> + [ResponseCache(Duration = 3600 /* 1 hour in seconds */)] + [ProducesResponseType(typeof(FileStreamResult), 200)] + [ProducesResponseType(404)] + [HttpGet("Image/{ImageSource}/{ImageType}/{ImageId}")] + [HttpHead("Image/{ImageSource}/{ImageType}/{ImageId}")] + public async Task<ActionResult> GetImageAsync([FromRoute] ImageSource imageSource, [FromRoute] ImageType imageType, [FromRoute, Range(1, int.MaxValue)] int imageId + ) + { + var response = await APIClient.GetImageAsync(imageSource, imageType, imageId); + if (response.StatusCode is System.Net.HttpStatusCode.NotFound) + return NotFound(); + if (response.StatusCode is not System.Net.HttpStatusCode.OK) + return StatusCode((int)response.StatusCode); + var stream = await response.Content.ReadAsStreamAsync(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/ocelot-stream"; + return File(stream, contentType); + } +} + +public class ApiLoginRequest +{ + /// <summary> + /// The username to submit to shoko. + /// </summary> + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + /// <summary> + /// The password to submit to shoko. + /// </summary> + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + + /// <summary> + /// If this is a user key. + /// </summary> + [JsonPropertyName("userKey")] + public bool UserKey { get; set; } = false; +} \ No newline at end of file diff --git a/Shokofin/Web/SignalRApiController.cs b/Shokofin/Web/SignalRApiController.cs new file mode 100644 index 00000000..4ae5636b --- /dev/null +++ b/Shokofin/Web/SignalRApiController.cs @@ -0,0 +1,99 @@ +using System; +using System.Net.Mime; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; +using Shokofin.SignalR; + +namespace Shokofin.Web; + +/// <summary> +/// Shoko SignalR Control Web Controller. +/// </summary> +[ApiController] +[Route("Plugin/Shokofin/SignalR")] +[Produces(MediaTypeNames.Application.Json)] +public class SignalRApiController : ControllerBase +{ + private readonly ILogger<SignalRApiController> Logger; + + private readonly SignalRConnectionManager ConnectionManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SignalRApiController"/> class. + /// </summary> + public SignalRApiController(ILogger<SignalRApiController> logger, SignalRConnectionManager connectionManager) + { + Logger = logger; + ConnectionManager = connectionManager; + } + + /// <summary> + /// Get the current status of the connection to Shoko Server. + /// </summary> + [HttpGet("Status")] + public ShokoSignalRStatus GetStatus() + { + return new() + { + IsUsable = ConnectionManager.IsUsable, + IsActive = ConnectionManager.IsActive, + State = ConnectionManager.State, + }; + } + + /// <summary> + /// Connect or reconnect to Shoko Server. + /// </summary> + [HttpPost("Connect")] + public async Task<ActionResult> ConnectAsync() + { + try { + await ConnectionManager.ResetConnectionAsync(); + return Ok(); + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to connect to server."); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + /// <summary> + /// Disconnect from Shoko Server. + /// </summary> + [HttpPost("Disconnect")] + public async Task<ActionResult> DisconnectAsync() + { + try { + await ConnectionManager.DisconnectAsync(); + return Ok(); + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to disconnect from server."); + return StatusCode(StatusCodes.Status500InternalServerError); + + } + } +} + +public class ShokoSignalRStatus +{ + /// <summary> + /// Determines if we can establish a connection to the server. + /// </summary> + public bool IsUsable { get; set; } + + /// <summary> + /// Determines if the connection manager is currently active. + /// </summary> + public bool IsActive { get; set; } + + /// <summary> + /// The current state of the connection. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public HubConnectionState State { get; set; } +} \ No newline at end of file diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..896ddd40 --- /dev/null +++ b/build.yaml @@ -0,0 +1,24 @@ +name: "Shoko" +guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" +imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png +targetAbi: "10.9.0.0" +owner: "ShokoAnime" +overview: "Manage your anime from Jellyfin using metadata from Shoko" +description: > + A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with + [Shoko Server](https://shokoanime.com/downloads/shoko-server/). + + ## Read this before installing + + **This plugin requires that you have already set up and are using Shoko Server**, + and that the files you intend to include in Jellyfin are **indexed** (and + optionally managed) by Shoko Server. **Otherwise, the plugin won't be able to + provide metadata for your files**, since there is no metadata to find for them. + +category: "Metadata" +artifacts: +- "Shokofin.dll" +- "Microsoft.AspNetCore.SignalR.Client.dll" +- "Microsoft.AspNetCore.SignalR.Client.Core.dll" +- "Microsoft.AspNetCore.Http.Connections.Client.dll" +changelog: "" diff --git a/build_plugin.py b/build_plugin.py new file mode 100644 index 00000000..4bde7113 --- /dev/null +++ b/build_plugin.py @@ -0,0 +1,71 @@ +import os +import json +import yaml +import argparse +import re + +def extract_target_framework(csproj_path): + with open(csproj_path, "r") as file: + content = file.read() + target_framework_match = re.compile(r"<TargetFramework>(.*?)<\/TargetFramework>", re.IGNORECASE).search(content) + target_frameworks_match = re.compile(r"<TargetFrameworks>(.*?)<\/TargetFrameworks>", re.IGNORECASE).search(content) + if target_framework_match: + return target_framework_match.group(1) + elif target_frameworks_match: + return target_frameworks_match.group(1).split(";")[0] # Return the first framework + else: + return None + +parser = argparse.ArgumentParser() +parser.add_argument("--repo", required=True) +parser.add_argument("--version", required=True) +parser.add_argument("--tag", required=True) +parser.add_argument("--prerelease", default=True) +opts = parser.parse_args() + +framework = extract_target_framework("./Shokofin/Shokofin.csproj") +version = opts.version +tag = opts.tag +prerelease = bool(opts.prerelease) + +artifact_dir = os.path.join(os.getcwd(), "artifacts") +if not os.path.exists(artifact_dir): + os.mkdir(artifact_dir) + +jellyfin_repo_file="./manifest.json" +jellyfin_repo_url=f"https://github.com/{opts.repo}/releases/download" + +# Add changelog to the build yaml before we generate the release. +build_file = "./build.yaml" + +with open(build_file, "r") as file: + data = yaml.safe_load(file) + +if "changelog" in data: + if "CHANGELOG" in os.environ: + data["changelog"] = os.environ["CHANGELOG"].strip() + else: + data["changelog"] = "" + +with open(build_file, "w") as file: + yaml.dump(data, file, sort_keys=False) + +zipfile=os.popen("jprm --verbosity=debug plugin build \".\" --output=\"%s\" --version=\"%s\" --dotnet-framework=\"%s\"" % (artifact_dir, version, framework)).read().strip() + +jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{tag}/shoko_{version}.zip" + +os.system("jprm repo add --plugin-url=%s %s %s" % (jellyfin_plugin_release_url, jellyfin_repo_file, zipfile)) + +# Compact the unstable manifest after building, so it only contains the last 5 versions. +if prerelease: + with open(jellyfin_repo_file, "r") as file: + data = json.load(file) + + for item in data: + if "versions" in item and len(item["versions"]) > 5: + item["versions"] = item["versions"][:5] + + with open(jellyfin_repo_file, "w") as file: + json.dump(data, file, indent=4) + +print(version) diff --git a/manifest.json b/manifest.json new file mode 100644 index 00000000..faea52e0 --- /dev/null +++ b/manifest.json @@ -0,0 +1,12 @@ +[ + { + "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", + "name": "Shoko", + "description": "Stub. manifest. Use a manifest from the metadata branch instead or learn more about how to set up the plugin at our docs site; https://docs.shokoanime.com/shokofin/install/", + "owner": "ShokoAnime", + "category": "Metadata", + "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png", + "versions": [ + ] + } +] \ No newline at end of file