Repository: ShokoAnime/Shokofin Branch: dev Commit: e9d362945367 Files: 201 Total size: 1.5 MB Directory structure: gitextract_n8jd1_7_/ ├── .config/ │ └── dotnet-tools.json ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ └── features.yml │ └── workflows/ │ ├── changelog.jq │ ├── git-log-json.mjs │ ├── release-daily.yml │ ├── release.yml │ └── release_draft.jq ├── .gitignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── LICENSE ├── README.md ├── Shokofin/ │ ├── API/ │ │ ├── Converters/ │ │ │ └── JsonAutoStringConverter.cs │ │ ├── IdPrefix.cs │ │ ├── Info/ │ │ │ ├── AniDB/ │ │ │ │ ├── AnidbAnimeInfo.cs │ │ │ │ └── AnidbEpisodeInfo.cs │ │ │ ├── CollectionInfo.cs │ │ │ ├── EpisodeInfo.cs │ │ │ ├── FileInfo.cs │ │ │ ├── IBaseItemInfo.cs │ │ │ ├── IExtendedItemInfo.cs │ │ │ ├── SeasonInfo.cs │ │ │ ├── Shoko/ │ │ │ │ ├── ShokoEpisodeInfo.cs │ │ │ │ └── ShokoSeriesInfo.cs │ │ │ ├── ShowInfo.cs │ │ │ └── TMDB/ │ │ │ ├── TmdbEpisodeInfo.cs │ │ │ ├── TmdbMovieInfo.cs │ │ │ ├── TmdbSeasonInfo.cs │ │ │ └── TmdbShowInfo.cs │ │ ├── Models/ │ │ │ ├── AniDB/ │ │ │ │ ├── AnidbAnime.cs │ │ │ │ └── AnidbEpisode.cs │ │ │ ├── ApiException.cs │ │ │ ├── ApiKey.cs │ │ │ ├── ComponentVersion.cs │ │ │ ├── ContentRating.cs │ │ │ ├── CrossReference.cs │ │ │ ├── EpisodeType.cs │ │ │ ├── File.cs │ │ │ ├── IDs.cs │ │ │ ├── Image.cs │ │ │ ├── Images.cs │ │ │ ├── ListResult.cs │ │ │ ├── ManagedFolder.cs │ │ │ ├── Rating.cs │ │ │ ├── Relation.cs │ │ │ ├── ReleaseGroup.cs │ │ │ ├── ReleaseInfo.cs │ │ │ ├── ReleaseSource.cs │ │ │ ├── Role.cs │ │ │ ├── SeriesType.cs │ │ │ ├── Shoko/ │ │ │ │ ├── ShokoEpisode.cs │ │ │ │ ├── ShokoGroup.cs │ │ │ │ └── ShokoSeries.cs │ │ │ ├── Studio.cs │ │ │ ├── TMDB/ │ │ │ │ ├── AlternateOrderingType.cs │ │ │ │ ├── ITmdbEntity.cs │ │ │ │ ├── ITmdbParentEntity.cs │ │ │ │ ├── TmdbEpisode.cs │ │ │ │ ├── TmdbEpisodeCrossReference.cs │ │ │ │ ├── TmdbMovie.cs │ │ │ │ ├── TmdbMovieCollection.cs │ │ │ │ ├── TmdbMovieCrossReference.cs │ │ │ │ ├── TmdbSeason.cs │ │ │ │ └── TmdbShow.cs │ │ │ ├── Tag.cs │ │ │ ├── Text.cs │ │ │ ├── Title.cs │ │ │ ├── TitleType.cs │ │ │ ├── YearlySeason.cs │ │ │ └── YearlySeasonName.cs │ │ ├── ShokoApiClient.cs │ │ ├── ShokoApiManager.cs │ │ └── ShokoIdLookup.cs │ ├── Collections/ │ │ └── CollectionManager.cs │ ├── Configuration/ │ │ ├── AllDescriptionsConfiguration.cs │ │ ├── AllImagesConfiguration.cs │ │ ├── AllTitlesConfiguration.cs │ │ ├── DebugConfiguration.cs │ │ ├── DescriptionConfiguration.cs │ │ ├── Enums/ │ │ │ ├── ImageLanguageType.cs │ │ │ ├── MetadataRefreshField.cs │ │ │ ├── SeasonMergingBehavior.cs │ │ │ ├── SeriesEpisodeConversion.cs │ │ │ ├── SeriesStructureType.cs │ │ │ └── VirtualRootLocation.cs │ │ ├── ImageConfiguration.cs │ │ ├── LegacyMediaFolderConfiguration.cs │ │ ├── LibraryConfiguration.cs │ │ ├── MediaFolderConfiguration.cs │ │ ├── MetadataRefreshConfiguration.cs │ │ ├── Models/ │ │ │ ├── LibraryConfigurationChangedEventArgs.cs │ │ │ └── MediaFolderConfigurationChangedEventArgs.cs │ │ ├── PluginConfiguration.cs │ │ ├── SeriesConfiguration.cs │ │ ├── Services/ │ │ │ ├── MediaFolderConfigurationService.cs │ │ │ └── SeriesConfigurationService.cs │ │ ├── TitleConfiguration.cs │ │ ├── TitlesConfiguration.cs │ │ └── UserConfiguration.cs │ ├── Events/ │ │ ├── EventDispatchService.cs │ │ ├── Interfaces/ │ │ │ ├── IFileEventArgs.cs │ │ │ ├── IFileRelocationEventArgs.cs │ │ │ ├── IMetadataUpdatedEventArgs.cs │ │ │ ├── IReleaseSavedEventArgs.cs │ │ │ ├── ProviderName.cs │ │ │ └── UpdateReason.cs │ │ ├── MetadataRefreshService.cs │ │ └── Stub/ │ │ └── FileEventArgsStub.cs │ ├── Extensions/ │ │ ├── CollectionTypeExtensions.cs │ │ ├── EnumerableExtensions.cs │ │ ├── EpisodeTypeExtensions.cs │ │ ├── ListExtensions.cs │ │ ├── MediaFolderConfigurationExtensions.cs │ │ ├── StringExtensions.cs │ │ └── SyncExtensions.cs │ ├── ExternalIds/ │ │ ├── AnidbAnimeId.cs │ │ ├── AnidbCreatorId.cs │ │ ├── AnidbEpisodeId.cs │ │ ├── ProviderNames.cs │ │ ├── ProviderUrls.cs │ │ ├── ShokoExternalUrlHandler.cs │ │ └── ShokoInternalId.cs │ ├── MergeVersions/ │ │ ├── MergeVersionManager.cs │ │ └── MergeVersionSortSelector.cs │ ├── Pages/ │ │ ├── Dummy.html │ │ ├── Scripts/ │ │ │ ├── Common.js │ │ │ ├── Dummy.js │ │ │ ├── Settings.js │ │ │ └── jsconfig.json │ │ └── Settings.html │ ├── Plugin.cs │ ├── PluginServiceRegistrator.cs │ ├── Providers/ │ │ ├── BoxSetProvider.cs │ │ ├── CustomBoxSetProvider.cs │ │ ├── CustomEpisodeProvider.cs │ │ ├── CustomMovieProvider.cs │ │ ├── CustomSeasonProvider.cs │ │ ├── CustomSeriesProvider.cs │ │ ├── EpisodeProvider.cs │ │ ├── ImageProvider.cs │ │ ├── MovieProvider.cs │ │ ├── SeasonProvider.cs │ │ ├── SeriesProvider.cs │ │ ├── TrailerProvider.cs │ │ └── VideoProvider.cs │ ├── Resolvers/ │ │ ├── Models/ │ │ │ ├── LinkGenerationResult.cs │ │ │ └── ShokoWatcher.cs │ │ ├── ShokoIgnoreRule.cs │ │ ├── ShokoLibraryMonitor.cs │ │ ├── ShokoResolver.cs │ │ └── VirtualFileSystemService.cs │ ├── Shokofin.csproj │ ├── SignalR/ │ │ ├── Models/ │ │ │ ├── EpisodeInfoUpdatedEventArgs.cs │ │ │ ├── FileEventArgs.cs │ │ │ ├── FileMovedEventArgs.cs │ │ │ ├── FileRenamedEventArgs.cs │ │ │ ├── MovieInfoUpdatedEventArgs.cs │ │ │ ├── ReleaseSavedEventArgs.cs │ │ │ └── SeriesInfoUpdatedEventArgs.cs │ │ ├── SignalRConnectionManager.cs │ │ ├── SignalREntryPoint.cs │ │ └── SignalRRetryPolicy.cs │ ├── Sync/ │ │ ├── SyncDirection.cs │ │ └── UserDataSyncManager.cs │ ├── Tasks/ │ │ ├── AutoRefreshMetadataTask.cs │ │ ├── CleanupVirtualRootTask.cs │ │ ├── ClearPluginCacheTask.cs │ │ ├── ExportUserDataTask.cs │ │ ├── ImportUserDataTask.cs │ │ ├── MergeEpisodesTask.cs │ │ ├── MergeMoviesTask.cs │ │ ├── PostScanTask.cs │ │ ├── ReconstructCollectionsTask.cs │ │ ├── SplitEpisodesTask.cs │ │ ├── SplitMoviesTask.cs │ │ ├── SyncUserDataTask.cs │ │ └── VersionCheckTask.cs │ ├── Utils/ │ │ ├── ContentRating.cs │ │ ├── DisposableAction.cs │ │ ├── GuardedMemoryCache.cs │ │ ├── IgnorePatterns.cs │ │ ├── ImageUtility.cs │ │ ├── LibraryScanWatcher.cs │ │ ├── Ordering.cs │ │ ├── PropertyWatcher.cs │ │ ├── SeriesInfoRelationComparer.cs │ │ ├── TagFilter.cs │ │ ├── TextUtility.cs │ │ └── UsageTracker.cs │ └── Web/ │ ├── ImageHostUrl.cs │ ├── Models/ │ │ ├── SimpleSeries.cs │ │ └── VfsLibraryPreview.cs │ ├── ShokofinHostController.cs │ ├── ShokofinSignalRController.cs │ ├── ShokofinUtilityController.cs │ └── VfsActionFilter.cs ├── Shokofin.sln ├── build.yaml ├── build_plugin.py └── manifest.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .config/dotnet-tools.json ================================================ { "version": 1, "isRoot": true, "tools": { "dotnet-ef": { "version": "9.0.9", "commands": [ "dotnet-ef" ] } } } ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ name: Shokofin Bug Report 101 description: Report any bugs here! labels: [] projects: [] assignees: [] 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. ================================================ FILE: .github/ISSUE_TEMPLATE/features.yml ================================================ name: Shokofin Feature Request 101 description: Request your features here! labels: [] projects: [] assignees: [] 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. ================================================ FILE: .github/workflows/changelog.jq ================================================ reduce .[] as $commit ( ""; . + "### `\($commit.type)`: \($commit.subject). (\($commit.commit)) @\($commit.author.github) (date: \($commit.author.date), TZ: \($commit.author.timeZone))" + if $commit.isSkipCI then " (_Skip CI_)" else "" end + if $commit.body != null and $commit.body != "" then "\n\n\($commit.body | gsub("\n"; "\n"))" else "" end + "\n\n" ) ================================================ FILE: .github/workflows/git-log-json.mjs ================================================ #! /bin/env node import { dirname, join } from "node:path"; import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import process from "node: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 EndingMarkers = new Set([ ".", ",", "!", "?", ]); 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 mappingUrl = import.meta.url.startsWith("file:") ? join(dirname(import.meta.url.slice(5)), "email-to-github.json") : null; const emailToGithubMapping = mappingUrl && existsSync(mappingUrl) ? JSON.parse(readFileSync(mappingUrl, "utf-8")) : {}; 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(); } } } } // Add file-level changes to each commit for (const commitId of commitOrder) { try { const fileStatusOutput = execSync(`git diff --name-status ${commitId}^ ${commitId}`).toString(); const lineChangesOutput = execSync(`git diff --numstat ${commitId}^ ${commitId}`).toString(); const files = []; const fileStatusLines = fileStatusOutput.split(/\r\n|\r|\n/g).filter(a => a); const lineChangesLines = lineChangesOutput.split(/\r\n|\r|\n/g).filter(a => a); for (const [index, line] of fileStatusLines.entries()) { const [rawStatus, path] = line.split(/\t/); const status = rawStatus === "M" ? "modified" : rawStatus === "A" ? "added" : rawStatus === "D" ? "deleted" : rawStatus === "R" ? "renamed" : "untracked"; const lineChangeParts = lineChangesLines[index].split(/\t/); const addedLines = parseInt(lineChangeParts[0] || "0", 10); const removedLines = parseInt(lineChangeParts[1] || "0", 10); files.push({ path, status, addedLines, removedLines, }); } commits[commitId].files = files; } catch (error) { commits[commitId].files = []; } } // 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.reverse() .map((commitId) => commits[commitId]) .map(({ commit, parents, tree, subject, body, author_name, author_email, author_date, committer_name, committer_email, committer_date, files }) => ({ commit, parents, tree, subject: /^\s*\w+\s*: ?/i.test(subject) ? subject.split(":").slice(1).join(":").trim() : subject.trim(), type: /^\s*\w+\s*: ?/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, github: emailToGithubMapping[author_email] || null, 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, github: emailToGithubMapping[committer_email] || null, date: new Date(committer_date).toISOString(), timeZone: committer_date.substring(19) === "Z" ? "+00:00" : committer_date.substring(19), }, files, })) .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)), type: commit.type == "feature" ? "feat" : commit.type === "refacor" ? "refactor" : commit.type == "mics" ? "misc" : commit.type, })) .map((commit) => ({ ...commit, subject: ((subject) => { subject = (/[a-z]/.test(subject[0]) ? subject[0].toUpperCase() + subject.slice(1) : subject).trim(); if (subject.length > 0 && EndingMarkers.has(subject[subject.length - 1])) subject = subject.slice(0, subject.length - 1); return subject; })(commit.subject), })) .filter((commit) => !(commit.type === "misc" && (commit.subject === "update unstable manifest" || commit.subject === "Update repo manifest" || commit.subject === "Update unstable repo manifest"))) .map((commit, index) => ({ ...commit, simple_type: ["misc", "refactor"].includes(commit.type) ? "change" : commit.type === "chore" ? "repo" : commit.type, index, })); process.stdout.write(JSON.stringify(commitsList, null, 2)); ================================================ FILE: .github/workflows/release-daily.yml ================================================ 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: 9.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 if: contains(env.DISCORD_WEBHOOK, 'https://') env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: webhook-url: ${{ env.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 }} ================================================ FILE: .github/workflows/release.yml ================================================ 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: 9.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 ================================================ FILE: .github/workflows/release_draft.jq ================================================ group_by(.simple_type) | sort_by(.[0].simple_type | ({ "feat": 0, "change": 1, "fix": 2, "repo": 3}[.] // 99)) | reduce .[] as $group ( ""; reduce $group[] as $commit ( . + "## `\($group.[0].simple_type)`\n\n"; . + "- \($commit.subject). (\($commit.commit)) by @\($commit.author.github) (`index: \($commit.index)`)" + if $commit.isSkipCI then " (_Skip CI_)" else "" end + if $commit.body != null and $commit.body != "" then "\n\n \($commit.body | gsub("\n"; "\n "))" else "" end + "\n\n" ) ) ================================================ FILE: .gitignore ================================================ # 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 .github/workflows/email-to-github.json ================================================ FILE: .vscode/extensions.json ================================================ { "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": [] } ================================================ FILE: .vscode/settings.json ================================================ { "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", "serilog", "shoko", "shokofin", "shoujo", "shounen", "signalr", "tmdb", "trickplay", "tvshow", "tvshows", "viewshow", "webui", "whitespaces" ] } ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================ # Shokofin A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/) 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 provide 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/. ## Documentation Head over to our [documentation site](https://docs.shokoanime.com/jellyfin/installing-shokofin) for documentation that is not pure source-code. ## Install There are multiple ways to install the plugin, but the recommended way is to use the official Jellyfin repository. 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` | | `4.0.0` — `4.1.1` | `10.9` | `4.2.2` | | `4.2.0` — `4.2.2` | `10.9` | `4.2.2` — `5.0.0` | | `5.0.0` | `10.10` | `5.0.0` | | `5.0.1` — `5.0.4` | `10.10` | `5.0.0` — `5.1.0` | | `5.0.5` — `5.0.6` | `10.11` | `5.1.0` | | `6.0.0` | `10.11` | `5.2.0` — `5.2.5` | | `6.0.1` — `6.0.3` | `10.10` — `10.11` | `5.2.0` — `5.3.0` | | `6.0.4` — `6.0.5` | `10.10` — `10.11` | `5.2.0` — `5.3.2` | | `dev` | `10.10` — `10.11` | `dev` | ### Official Repository #### Jellyfin 10.11 1. **Access Plugin Repositories:** - Go to `Dashboard` -> `Plugins` -> `Manage Repositories` -> `New Repository`. 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 back to the catalog in the plugins section of the dashboard, filter to `All` or `Available` plugins, then refresh the browser page to reload the plugin list. - Find and install `Shoko` from the list, optionally by filtering the list by the `Anime` category. 4. **Restart Jellyfin:** - Restart your server to apply the changes. #### Jellyfin 10.10 1. **Access Plugin Repositories:** - Go to `Dashboard` -> `Plugins` -> `Catalog` -> `⚙ Gear icon`. 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 section of the dashboard. - Find and install `Shoko` from the `Anime` section. ### 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/net9.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._ ================================================ FILE: Shokofin/API/Converters/JsonAutoStringConverter.cs ================================================ using System; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; namespace Shokofin.API.Converters; /// /// Automatically converts JSON values to a string. /// public class JsonAutoStringConverter : JsonConverter { public override bool CanConvert(Type typeToConvert) => typeof(string) == typeToConvert; public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.Number) { if (reader.TryGetInt64(out var number)) return number.ToString(CultureInfo.InvariantCulture); if (reader.TryGetDouble(out var doubleNumber)) return doubleNumber.ToString(CultureInfo.InvariantCulture); } if (reader.TokenType is JsonTokenType.String) return reader.GetString(); using var document = JsonDocument.ParseValue(ref reader); return document.RootElement.Clone().ToString(); } public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) => writer.WriteStringValue(value); } ================================================ FILE: Shokofin/API/IdPrefix.cs ================================================ namespace Shokofin.API; internal struct IdPrefix { internal const char TmdbShow = 't'; internal const char TmdbMovie = 'T'; internal const char TmdbMovieCollection = 'Ʇ'; } ================================================ FILE: Shokofin/API/Info/AniDB/AnidbAnimeInfo.cs ================================================ namespace Shokofin.API.Info.AniDB; public class AnidbAnimeInfo { public required string AnidbAnimeId { get; init; } } ================================================ FILE: Shokofin/API/Info/AniDB/AnidbEpisodeInfo.cs ================================================ using Shokofin.API.Models; using Shokofin.Extensions; namespace Shokofin.API.Info.AniDB; public class AnidbEpisodeInfo { public required string AnidbEpisodeId { get; init; } public required string AnidbAnimeId { get; init; } public required int EpisodeNumber { get; init; } public required EpisodeType Type { get; init; } public string GetEpisodeNumberText() => Type.ToShortString() + EpisodeNumber; } ================================================ FILE: Shokofin/API/Info/CollectionInfo.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using Shokofin.API.Models; using Shokofin.API.Models.Shoko; using Shokofin.Utils; namespace Shokofin.API.Info; public class CollectionInfo(ShokoGroup group, string? mainSeasonId, List shows, List subCollections) : IBaseItemInfo { /// /// Collection Identifier. /// public string Id { get; init; } = group.Id; /// /// Parent Collection Identifier, if any. /// public string? ParentId { get; init; } = group.IDs.ParentGroup?.ToString(); /// /// Top Level Collection Identifier. Will refer to itself if it's a top level collection. /// public string TopLevelId { get; init; } = group.IDs.TopLevelGroup.ToString(); /// /// Main show's main season identifier. /// public string? MainSeasonId { get; init; } = mainSeasonId; /// /// True if the collection is a top level collection. /// public bool IsTopLevel { get; init; } = group.IDs.TopLevelGroup == group.IDs.Shoko; /// /// Collection Name. /// public string Title { get; init; } = group.Name; public IReadOnlyList Titles { get; init; } = []; /// <summary> /// Collection Description. /// </summary> public string Overview { get; init; } = group.Description; public IReadOnlyList<Text> Overviews { get; init; } = []; public IReadOnlyList<string> Notes { get; init; } = []; public string? OriginalLanguageCode => null; public DateTime CreatedAt { get; init; } public DateTime LastUpdatedAt { get; init; } /// <summary> /// Number of files across all shows and movies in the collection and all sub-collections. /// </summary> public int FileCount { get; init; } = group.Sizes.Files; /// <summary> /// Shows in the collection and not in any sub-collections. /// </summary> public IReadOnlyList<ShowInfo> Shows { get; init; } = shows .Where(showInfo => !showInfo.IsMovieCollection) .ToList(); /// <summary> /// Movies in the collection and not in any sub-collections. /// </summary> public IReadOnlyList<ShowInfo> Movies { get; init; } = shows .Where(showInfo => showInfo.IsMovieCollection) .ToList(); /// <summary> /// Sub-collections of the collection. /// </summary> public IReadOnlyList<CollectionInfo> SubCollections { get; init; } = subCollections; public CollectionInfo(ShokoGroup group, ShokoSeries series, string? mainSeasonId, List<ShowInfo> shows, List<CollectionInfo> subCollections) : this(group, mainSeasonId, shows, subCollections) { Title = series.Name; Titles = series.AniDB.Titles; Overview = series.Description == series.AniDB.Description ? TextUtility.SanitizeAnidbDescription(series.Description) : series.Description; Overviews = [ new() { IsDefault = true, IsPreferred = true, LanguageCode = "en", Source = "AniDB", Value = TextUtility.SanitizeAnidbDescription(series.AniDB.Description, out var notes), }, ]; Notes = notes; CreatedAt = group.CreatedAt; LastUpdatedAt = group.LastUpdatedAt; } } ================================================ FILE: Shokofin/API/Info/EpisodeInfo.cs ================================================ 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.Model.Entities; using Shokofin.API.Info.AniDB; using Shokofin.API.Info.Shoko; using Shokofin.API.Info.TMDB; using Shokofin.API.Models; using Shokofin.API.Models.Shoko; using Shokofin.API.Models.TMDB; using Shokofin.Events.Interfaces; using Shokofin.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; namespace Shokofin.API.Info; public class EpisodeInfo : IExtendedItemInfo { private static readonly HashSet<string> _invalidPhrases = [ "seisaku iinkai", // Production Committee ]; private readonly ShokoApiClient _client; public string Id { get; init; } public string InternalId => ShokoInternalId.EpisodeNamespace + Id; public string SeasonId { get; init; } public EpisodeType Type { get; init; } public bool IsHidden { get; init; } public bool IsMainEntry { get; init; } public bool IsStandalone { get; init; } public int? SeasonNumber { get; init; } public int EpisodeNumber { get; init; } public string Title { get; init; } public IReadOnlyList<Title> Titles { get; init; } public string? Overview { get; init; } public IReadOnlyList<Text> Overviews { get; init; } public IReadOnlyList<string> Notes { get; init; } = []; public string? OriginalLanguageCode { get; init; } public ExtraType? ExtraType { get; init; } public TimeSpan? Runtime { get; init; } public DateTime? AiredAt { get; init; } public DateTime CreatedAt { get; init; } public DateTime LastUpdatedAt { get; init; } public Rating CommunityRating { get; init; } public IReadOnlyList<string> Genres { get; init; } public IReadOnlyList<string> Tags { get; init; } public IReadOnlyList<string> Studios { get; init; } public IReadOnlyDictionary<ProviderName, IReadOnlyList<string>> ProductionLocations { get; init; } public IReadOnlyList<Models.ContentRating> ContentRatings { get; init; } public IReadOnlyList<PersonInfo> Staff { get; init; } public List<CrossReference.EpisodeCrossReferenceIDs> CrossReferences { get; init; } public bool IsAvailable => CrossReferences.Count is > 0; #region Shoko Episode Metadata public ShokoEpisodeInfo[] ShokoEpisodes { get; init; } #endregion #region Anidb Episode Metadata public string? AnidbEpisodeId => AnidbEpisodes.FirstOrDefault()?.AnidbEpisodeId; public AnidbEpisodeInfo[] AnidbEpisodes { get; init; } #endregion #region TMDB Movie Metadata public string? TmdbMovieId => TmdbMovies.FirstOrDefault()?.TmdbMovieId; public TmdbMovieInfo[] TmdbMovies { get; init; } #endregion #region TMDB Episode Metadata public string? TmdbEpisodeId => TmdbEpisodes.FirstOrDefault()?.TmdbEpisodeId; public string? TvdbEpisodeId => TmdbEpisodes.FirstOrDefault()?.TvdbEpisodeId; public TmdbEpisodeInfo[] TmdbEpisodes { get; init; } #endregion public EpisodeInfo( ShokoApiClient client, ShokoEpisode episode, IReadOnlyList<Role> cast, List<string> genres, List<string> tags, string[] productionLocations, string? anidbContentRating, TmdbMovieInfo[] tmdbMovies, TmdbEpisodeInfo[] tmdbEpisodes, ITmdbEntity? tmdbEntity = null, ITmdbParentEntity? tmdbParentEntity = null ) { var contentRatings = new List<Models.ContentRating>(); var productionLocationDict = new Dictionary<ProviderName, IReadOnlyList<string>>(); var tmdbMovie = tmdbEntity as TmdbMovie; var tmdbEpisode = tmdbEntity as TmdbEpisode; var isMainEntry = episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode is "en")?.Value is { } mainAnidbTitle && TextUtility.IgnoredSubTitles.Contains(mainAnidbTitle); if (!string.IsNullOrEmpty(anidbContentRating)) contentRatings.Add(new() { Rating = anidbContentRating, Country = "US", Language = "en", Source = "AniDB", }); if (productionLocations.Length > 0) productionLocationDict[ProviderName.AniDB] = productionLocations; _client = client; Id = episode.Id; SeasonId = episode.IDs.ParentSeries.ToString(); Type = episode.AniDB.Type; IsHidden = episode.IsHidden; IsMainEntry = isMainEntry; IsStandalone = tmdbMovie is not null || isMainEntry; SeasonNumber = null; EpisodeNumber = episode.AniDB.EpisodeNumber; ExtraType = Ordering.GetExtraType(episode.AniDB); Title = episode.Name; Titles = [ ..episode.AniDB.Titles, ..(tmdbEntity?.Titles ?? []), ]; var notes = (IReadOnlyList<string>)[]; Overview = episode.Description == episode.AniDB.Description ? TextUtility.SanitizeAnidbDescription(episode.Description) : episode.Description; Overviews = [ ..(!string.IsNullOrEmpty(episode.AniDB.Description) ? [ new() { IsDefault = true, IsPreferred = string.Equals(episode.Description, episode.AniDB.Description), LanguageCode = "en", Source = "AniDB", Value = TextUtility.SanitizeAnidbDescription(episode.AniDB.Description, out notes), }, ] : Array.Empty<Text>()), ..(tmdbEntity?.Overviews ?? []), ]; Notes = notes; Studios = []; if (tmdbMovie is not null) { Runtime = tmdbMovie.Runtime ?? episode.AniDB.Duration; AiredAt = tmdbMovie.ReleasedAt?.ToDateTime(TimeOnly.Parse("00:00:00", CultureInfo.InvariantCulture), DateTimeKind.Local); CommunityRating = tmdbMovie.UserRating; Staff = tmdbMovie.Cast.Concat(tmdbMovie.Crew) .GroupBy(role => (role.Type, role.Staff.Id)) .Select(roles => RoleToPersonInfo(roles.ToList(), MetadataProvider.Tmdb.ToString())) .WhereNotNull() .ToArray(); if (Staff.Count is 0) Staff = cast .GroupBy(role => (role.Type, role.Staff.Id)) .Select(roles => RoleToPersonInfo(roles.ToList(), ProviderNames.Anidb)) .WhereNotNull() .ToArray(); productionLocationDict[ProviderName.TMDB] = tmdbMovie.ProductionCountries.Values.ToArray(); contentRatings.AddRange(tmdbMovie.ContentRatings); Studios = tmdbMovie.Studios.Select(r => r.Name).Distinct().Order().ToArray(); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) tags.AddRange(tmdbMovie.Keywords); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbGenres)) tags.AddRange(tmdbMovie.Genres); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) genres.AddRange(tmdbMovie.Keywords); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbGenres)) genres.AddRange(tmdbMovie.Genres); } else if (tmdbEpisode is not null) { Runtime = tmdbEpisode.Runtime ?? episode.AniDB.Duration; AiredAt = tmdbEpisode.AiredAt?.ToDateTime(TimeOnly.Parse("00:00:00", CultureInfo.InvariantCulture), DateTimeKind.Local); CommunityRating = tmdbEpisode.UserRating; Staff = tmdbEpisode.Cast.Concat(tmdbEpisode.Crew) .GroupBy(role => (role.Type, role.Staff.Id)) .Select(roles => RoleToPersonInfo(roles.ToList(), MetadataProvider.Tmdb.ToString())) .WhereNotNull() .ToArray(); if (Staff.Count is 0) Staff = cast .GroupBy(role => (role.Type, role.Staff.Id)) .Select(roles => RoleToPersonInfo(roles.ToList(), ProviderNames.Anidb)) .WhereNotNull() .ToArray(); if (tmdbParentEntity is not null) { productionLocationDict[ProviderName.TMDB] = tmdbParentEntity.ProductionCountries.Values.ToArray(); contentRatings.AddRange(tmdbParentEntity.ContentRatings); Studios = tmdbParentEntity.Studios.Select(r => r.Name).Distinct().Order().ToArray(); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) tags.AddRange(tmdbParentEntity.Keywords); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbGenres)) tags.AddRange(tmdbParentEntity.Genres); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) genres.AddRange(tmdbParentEntity.Keywords); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbGenres)) genres.AddRange(tmdbParentEntity.Genres); } } else { var onlyAnimationWorks = Plugin.Instance.Configuration.Metadata_StudioOnlyAnimationWorks; Runtime = episode.AniDB.Duration; AiredAt = episode.AniDB.AirDate; CommunityRating = episode.AniDB.Rating; Staff = cast .GroupBy(role => (role.Type, role.Staff.Id)) .Select(roles => RoleToPersonInfo(roles.ToList(), ProviderNames.Anidb)) .WhereNotNull() .ToArray(); Studios = cast .Where(role => !string.IsNullOrEmpty(role.Staff.Name) && role.Type is CreatorRoleType.Studio && role.Staff.Type is null or "Company" && !_invalidPhrases.Any(p => role.Staff.Name.Contains(p, StringComparison.OrdinalIgnoreCase)) && (!onlyAnimationWorks || role.Name is "Animation Works") ) .Select(r => r.Staff.Name) .Distinct() .Order() .ToArray(); } CreatedAt = episode.CreatedAt; LastUpdatedAt = episode.LastUpdatedAt; Genres = genres.Distinct().Order().ToArray(); Tags = tags.Distinct().Order().ToArray(); ProductionLocations = productionLocationDict; ContentRatings = contentRatings.Distinct().ToList(); CrossReferences = episode.CrossReferences; ShokoEpisodes = [episode.ToInfo()]; AnidbEpisodes = [episode.AniDB.ToInfo()]; TmdbMovies = tmdbMovies; TmdbEpisodes = tmdbEpisodes; } public EpisodeInfo(ShokoApiClient client, TmdbEpisode tmdbEpisode, TmdbShow tmdbShow, ShokoEpisodeInfo[] shokoEpisodes, AnidbEpisodeInfo[] anidbEpisodes) { var tags = new List<string>(); var genres = new List<string>(); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) tags.AddRange(tmdbShow.Keywords); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbGenres)) tags.AddRange(tmdbShow.Genres); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) genres.AddRange(tmdbShow.Keywords); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbGenres)) genres.AddRange(tmdbShow.Genres); _client = client; Id = IdPrefix.TmdbShow + tmdbEpisode.Id.ToString(); SeasonId = IdPrefix.TmdbShow + tmdbEpisode.SeasonId; Type = tmdbEpisode.SeasonNumber is 0 ? EpisodeType.Special : EpisodeType.Episode; IsHidden = false; IsMainEntry = false; IsStandalone = false; SeasonNumber = tmdbEpisode.SeasonNumber; EpisodeNumber = tmdbEpisode.EpisodeNumber; Title = tmdbEpisode.Title; Titles = tmdbEpisode.Titles; Overview = tmdbEpisode.Overview; Overviews = tmdbEpisode.Overviews; OriginalLanguageCode = tmdbShow.OriginalLanguage; ExtraType = null; Runtime = tmdbEpisode.Runtime; AiredAt = tmdbEpisode.AiredAt?.ToDateTime(TimeOnly.Parse("00:00:00", CultureInfo.InvariantCulture), DateTimeKind.Local); CreatedAt = tmdbEpisode.CreatedAt; LastUpdatedAt = tmdbEpisode.LastUpdatedAt; CommunityRating = tmdbEpisode.UserRating; Genres = genres.Distinct().Order().ToList(); Tags = tags.Distinct().Order().ToList(); Studios = tmdbShow.Studios.Select(r => r.Name).Distinct().Order().ToArray(); ProductionLocations = new Dictionary<ProviderName, IReadOnlyList<string>>() { { ProviderName.TMDB, tmdbShow.ProductionCountries.Values.ToArray() }, }; ContentRatings = tmdbShow.ContentRatings; Staff = tmdbEpisode.Cast.Concat(tmdbEpisode.Crew) .GroupBy(role => (role.Type, role.Staff.Id)) .Select(roles => RoleToPersonInfo(roles.ToList(), MetadataProvider.Tmdb.ToString())) .WhereNotNull() .ToArray(); CrossReferences = tmdbEpisode.FileCrossReferences .SelectMany(a => a.Episodes) .DistinctBy(a => (a.ED2K, a.FileSize)) .ToList(); ShokoEpisodes = shokoEpisodes; AnidbEpisodes = anidbEpisodes; TmdbEpisodes = [tmdbEpisode.ToInfo()]; TmdbMovies = []; } public EpisodeInfo(ShokoApiClient client, TmdbMovie tmdbMovie, ShokoEpisodeInfo[] shokoEpisodes, AnidbEpisodeInfo[] anidbEpisodes) { var tags = new List<string>(); var genres = new List<string>(); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) tags.AddRange(tmdbMovie.Keywords); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbGenres)) tags.AddRange(tmdbMovie.Genres); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) genres.AddRange(tmdbMovie.Keywords); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbGenres)) genres.AddRange(tmdbMovie.Genres); _client = client; Id = IdPrefix.TmdbMovie + tmdbMovie.Id.ToString(); SeasonId = tmdbMovie.CollectionId.HasValue && Plugin.Instance.Configuration.SeparateMovies && Plugin.Instance.Configuration.CollectionGrouping is Ordering.CollectionCreationType.Movies ? IdPrefix.TmdbMovieCollection + tmdbMovie.CollectionId.Value.ToString() : IdPrefix.TmdbMovie + tmdbMovie.Id.ToString(); Type = EpisodeType.Episode; IsHidden = false; IsMainEntry = false; IsStandalone = true; SeasonNumber = null; EpisodeNumber = 1; Title = tmdbMovie.Title; Titles = tmdbMovie.Titles; Overview = tmdbMovie.Overview; Overviews = tmdbMovie.Overviews; OriginalLanguageCode = tmdbMovie.OriginalLanguage; ExtraType = null; Runtime = tmdbMovie.Runtime; AiredAt = tmdbMovie.ReleasedAt?.ToDateTime(TimeOnly.Parse("00:00:00", CultureInfo.InvariantCulture), DateTimeKind.Local); CreatedAt = tmdbMovie.CreatedAt; LastUpdatedAt = tmdbMovie.LastUpdatedAt; CommunityRating = tmdbMovie.UserRating; Genres = genres.Distinct().Order().ToList(); Tags = tags.Distinct().Order().ToList(); Studios = tmdbMovie.Studios.Select(r => r.Name).Distinct().Order().ToArray(); ProductionLocations = new Dictionary<ProviderName, IReadOnlyList<string>>() { { ProviderName.TMDB, tmdbMovie.ProductionCountries.Values.ToArray() }, }; ContentRatings = tmdbMovie.ContentRatings; Staff = tmdbMovie.Cast.Concat(tmdbMovie.Crew) .GroupBy(role => (role.Type, role.Staff.Id)) .Select(roles => RoleToPersonInfo(roles.ToList(), MetadataProvider.Tmdb.ToString())) .WhereNotNull() .ToArray(); CrossReferences = tmdbMovie.FileCrossReferences .SelectMany(a => a.Episodes) .DistinctBy(a => (a.ED2K, a.FileSize)) .ToList(); ShokoEpisodes = shokoEpisodes; AnidbEpisodes = anidbEpisodes; TmdbEpisodes = []; TmdbMovies = [tmdbMovie.ToInfo()]; } public async Task<EpisodeImages> GetImages(CancellationToken cancellationToken) => Id[0] switch { IdPrefix.TmdbShow => await _client.GetImagesForTmdbEpisode(Id[1..], cancellationToken).ConfigureAwait(false), IdPrefix.TmdbMovie => await _client.GetImagesForTmdbMovie(Id[1..], cancellationToken).ConfigureAwait(false), _ => await _client.GetImagesForShokoEpisode(Id, cancellationToken).ConfigureAwait(false), } ?? new(); private static string? GetImagePath(Image image) => image != null && image.IsAvailable ? image.ToURLString(internalUrl: true) : null; private static PersonInfo? RoleToPersonInfo(IReadOnlyList<Role> roles, string roleProvider) => string.IsNullOrWhiteSpace(roles[0].Staff.Name) ? null : roles[0].Type switch { CreatorRoleType.Director => new PersonInfo { Type = PersonKind.Director, Name = roles[0].Staff.Name, Role = roles[0].Name, ImageUrl = GetImagePath(roles[0].Staff.Image), ProviderIds = new() { { roleProvider, roles[0].Staff.Id!.Value.ToString() }, }, }, CreatorRoleType.Producer => new PersonInfo { Type = PersonKind.Producer, Name = roles[0].Staff.Name, Role = roles[0].Name, ImageUrl = GetImagePath(roles[0].Staff.Image), ProviderIds = new() { { roleProvider, roles[0].Staff.Id!.Value.ToString() }, }, }, CreatorRoleType.Music => new PersonInfo { Type = PersonKind.Lyricist, Name = roles[0].Staff.Name, Role = roles[0].Name, ImageUrl = GetImagePath(roles[0].Staff.Image), ProviderIds = new() { { roleProvider, roles[0].Staff.Id!.Value.ToString() }, }, }, CreatorRoleType.SourceWork => new PersonInfo { Type = PersonKind.Writer, Name = roles[0].Staff.Name, Role = roles[0].Name, ImageUrl = GetImagePath(roles[0].Staff.Image), ProviderIds = new() { { roleProvider, roles[0].Staff.Id!.Value.ToString() }, }, }, CreatorRoleType.SeriesComposer => new PersonInfo { Type = PersonKind.Composer, Name = roles[0].Staff.Name, ImageUrl = GetImagePath(roles[0].Staff.Image), ProviderIds = new() { { roleProvider, roles[0].Staff.Id!.Value.ToString() }, }, }, CreatorRoleType.Actor => new PersonInfo { Type = PersonKind.Actor, Name = roles[0].Staff.Name, Role = roles.Select(role => role?.Character?.Name ?? string.Empty).Where(role => !string.IsNullOrEmpty(role)).Distinct().Order().Join(" / "), ImageUrl = GetImagePath(roles[0].Staff.Image), ProviderIds = new() { { roleProvider, roles[0].Staff.Id!.Value.ToString() }, }, }, _ => null, }; } ================================================ FILE: Shokofin/API/Info/FileInfo.cs ================================================ using System.Collections.Generic; using System.Linq; using Shokofin.API.Models; using Shokofin.ExternalIds; namespace Shokofin.API.Info; public class FileInfo(File file, string seriesId, IReadOnlyList<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)> episodeList) { public string Id { get; init; } = file.Id.ToString(); private string? _internalId; public string InternalId => _internalId ??= ShokoInternalId.FileNamespace + Id + $"?seriesId={SeriesId}&episodeIds={string.Join(",", EpisodeList.Select(tuple => tuple.Id))}&seasonId={EpisodeList.FirstOrDefault(tuple => tuple.Episode.SeasonId != null).Episode?.SeasonId ?? string.Empty}"; public string SeriesId { get; init; } = seriesId; public MediaBrowser.Model.Entities.ExtraType? ExtraType { get; init; } = episodeList.FirstOrDefault(tuple => tuple.Episode.ExtraType != null).Episode?.ExtraType; public File Shoko { get; init; } = file; public IReadOnlyList<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)> EpisodeList { get; init; } = episodeList; } ================================================ FILE: Shokofin/API/Info/IBaseItemInfo.cs ================================================ using System; using System.Collections.Generic; using Shokofin.API.Models; namespace Shokofin.API.Info; /// <summary> /// Information about a base item. /// </summary> public interface IBaseItemInfo { /// <summary> /// Unique identifier for the base item. /// </summary> string Id { get; } /// <summary> /// Preferred title according to title settings on the server for the base item type. /// </summary> string Title { get; } /// <summary> /// List of all available titles for the base item. /// </summary> IReadOnlyList<Title> Titles { get; } /// <summary> /// Preferred overview according to description settings on the server. /// </summary> string? Overview { get; } /// <summary> /// List of all available overviews for the base item. /// </summary> IReadOnlyList<Text> Overviews { get; } /// <summary> /// Notes. /// </summary> IReadOnlyList<string> Notes { get => []; } /// <summary> /// Original language code for the base item if available. /// </summary> string? OriginalLanguageCode { get; } /// <summary> /// Date and time the base item was created. /// </summary> DateTime CreatedAt { get; } /// <summary> /// Date and time the base item was last updated. /// </summary> DateTime LastUpdatedAt { get; } } ================================================ FILE: Shokofin/API/Info/IExtendedItemInfo.cs ================================================ using System.Collections.Generic; using MediaBrowser.Controller.Entities; using Shokofin.API.Models; using Shokofin.Events.Interfaces; namespace Shokofin.API.Info; public interface IExtendedItemInfo : IBaseItemInfo { bool IsAvailable { get; } IReadOnlyList<string> Tags { get; } IReadOnlyList<string> Genres { get; } IReadOnlyList<string> Studios { get; } IReadOnlyDictionary<ProviderName, IReadOnlyList<string>> ProductionLocations { get; } IReadOnlyList<ContentRating> ContentRatings { get; } IReadOnlyList<PersonInfo> Staff { get; } } ================================================ FILE: Shokofin/API/Info/SeasonInfo.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Shokofin.API.Info.AniDB; using Shokofin.API.Info.Shoko; using Shokofin.API.Info.TMDB; using Shokofin.API.Models; using Shokofin.API.Models.Shoko; using Shokofin.API.Models.TMDB; using Shokofin.Configuration; using Shokofin.Events.Interfaces; using Shokofin.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; using ContentRating = Shokofin.API.Models.ContentRating; using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; namespace Shokofin.API.Info; public class SeasonInfo : IExtendedItemInfo { private readonly ShokoApiClient _client; public string Id { get; init; } public string InternalId => ShokoInternalId.SeriesNamespace + Id; public IReadOnlyList<string> ExtraIds { get; init; } public string? TopLevelShokoGroupId { get; init; } public SeriesType Type { get; init; } public SeriesStructureType StructureType { get; init; } public Ordering.OrderType SeasonOrdering { get; init; } public Ordering.SpecialOrderType SpecialsPlacement { get; init; } public bool IsMultiEntry { get; init; } public bool IsRestricted { get; init; } public string Title { get; init; } public IReadOnlyList<Title> Titles { get; init; } public string? Overview { get; init; } public IReadOnlyList<Text> Overviews { get; init; } public IReadOnlyList<string> Notes { get; init; } = []; public string? OriginalLanguageCode { get; init; } public Rating CommunityRating { get; init; } /// <summary> /// First premiere date of the season. /// </summary> public DateTime? PremiereDate { get; init; } /// <summary> /// Ended date of the season. /// </summary> public DateTime? EndDate { get; init; } public DateTime CreatedAt { get; init; } public DateTime LastUpdatedAt { get; init; } private bool? _isAvailable = null; public bool IsAvailable => _isAvailable ??= EpisodeList.Any(e => e.IsAvailable) || AlternateEpisodesList.Any(e => e.IsAvailable); public IReadOnlyList<string> Genres { get; init; } public IReadOnlyList<string> Tags { get; init; } public IReadOnlyList<string> Studios { get; init; } public IReadOnlyDictionary<ProviderName, IReadOnlyList<string>> ProductionLocations { get; init; } public IReadOnlyList<ContentRating> ContentRatings { get; init; } /// <summary> /// The inferred days of the week this series airs on. /// </summary> /// <value>Each weekday</value> public IReadOnlyList<DayOfWeek> DaysOfWeek { get; init; } /// <summary> /// The yearly seasons this series belongs to. /// </summary> public IReadOnlyList<YearlySeason> YearlySeasons { get; init; } /// <summary> /// All staff for the season across all episodes. /// </summary> public IReadOnlyList<PersonInfo> Staff { get; init; } /// <summary> /// A pre-filtered list of normal episodes that belong to this series. /// /// Ordered by AniDb air-date. /// </summary> public IReadOnlyList<EpisodeInfo> EpisodeList { get; init; } /// <summary> /// A pre-filtered list of "unknown" episodes that belong to this series. /// /// Ordered by AniDb air-date. /// </summary> public IReadOnlyList<EpisodeInfo> AlternateEpisodesList { get; init; } /// <summary> /// A pre-filtered list of "extra" videos that belong to this series. /// /// Ordered by AniDb air-date. /// </summary> public IReadOnlyList<EpisodeInfo> ExtrasList { get; init; } /// <summary> /// A pre-filtered list of special episodes without an ExtraType /// attached. /// /// Ordered by AniDb episode number. /// </summary> public IReadOnlyList<EpisodeInfo> SpecialsList { get; init; } /// <summary> /// A list of special episodes that come before normal episodes. /// </summary> public IReadOnlySet<string> SpecialsBeforeEpisodes { get; init; } /// <summary> /// A dictionary holding mappings for the previous normal episode for every special episode in a series. /// </summary> public IReadOnlyDictionary<string, EpisodeInfo> SpecialsAnchors { get; init; } /// <summary> /// Related series data available in Shoko. /// </summary> public IReadOnlyList<Relation> Relations { get; init; } /// <summary> /// Map of related series with type. /// </summary> public IReadOnlyDictionary<string, RelationType> RelationMap { get; init; } #region Shoko Series Metadata /// <summary> /// The main Shoko series ID for the season info. /// </summary> public string? ShokoSeriesId => ShokoSeries.FirstOrDefault()?.ShokoSeriesId; /// <summary> /// The main Shoko group ID for the season info. /// </summary> public string? ShokoGroupId => ShokoSeries.FirstOrDefault()?.ShokoGroupId; /// <summary> /// All Shoko series linked to the season info. /// </summary> public ShokoSeriesInfo[] ShokoSeries { get; init; } #endregion #region AniDB Anime Metadata /// <summary> /// The main AniDB anime ID for the season info. /// </summary> public string? AnidbAnimeId => AnidbAnime.FirstOrDefault()?.AnidbAnimeId; /// <summary> /// All AniDB anime linked to the season info. /// </summary> public AnidbAnimeInfo[] AnidbAnime { get; init; } #endregion #region TMDB Season Metadata /// <summary> /// All TMDB seasons linked to the season info. /// </summary> public TmdbSeasonInfo[] TmdbSeasons { get; init; } #endregion #region TMDB Movie Metadata /// <summary> /// All TMDB movies linked to the season info. /// </summary> public TmdbMovieInfo[] TmdbMovies { get; init; } #endregion public SeasonInfo( ShokoApiClient client, ShokoSeries series, IEnumerable<string> extraIds, List<EpisodeInfo> episodes, IReadOnlyList<Relation> relations, ITmdbEntity? tmdbEntity, IReadOnlyDictionary<string, SeriesConfiguration> seriesConfigurationMap, TmdbSeasonInfo[] tmdbSeasons ) { var seasonId = series.Id; 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 specialsBeforeEpisodes = new HashSet<string>(); var specialsAnchorDictionary = new Dictionary<string, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); var altEpisodesList = new List<EpisodeInfo>(); var seasonIdOrder = new string[] { seasonId }.Concat(extraIds).ToList(); // Order the episodes by date. episodes = episodes .OrderBy(episode => !episode.AiredAt.HasValue) .ThenBy(episode => episode.AiredAt) .ThenBy(e => seasonIdOrder.IndexOf(e.SeasonId)) .ThenBy(episode => episode.Type) .ThenBy(episode => episode.EpisodeNumber) .ToList(); // Iterate over the episodes once and store some values for later use. int index = 0; int lastNormalEpisode = -1; foreach (var episode in episodes) { if (episode.IsHidden) continue; var seriesConfiguration = seriesConfigurationMap[episode.SeasonId]; var episodeType = episode.Type is EpisodeType.Episode && seriesConfiguration.EpisodeConversion is SeriesEpisodeConversion.EpisodesAsSpecials ? EpisodeType.Special : episode.Type; switch (episodeType) { case EpisodeType.Episode: 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 (episodeType is EpisodeType.Special && seriesConfiguration.EpisodeConversion is SeriesEpisodeConversion.SpecialsAsEpisodes) { episodesList.Add(episode); lastNormalEpisode = index; } else if (episodeType is EpisodeType.Special) { specialsList.Add(episode); if (lastNormalEpisode == -1) { specialsBeforeEpisodes.Add(episode.Id); } else { var previousEpisode = episodes .GetRange(lastNormalEpisode, index - lastNormalEpisode) .FirstOrDefault(e => e.Type is EpisodeType.Episode && seriesConfiguration.EpisodeConversion is not SeriesEpisodeConversion.EpisodesAsSpecials); if (previousEpisode != null) specialsAnchorDictionary[episode.Id] = 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. if (!seriesConfigurationMap[seasonId].OrderByAirdate) { episodesList = episodesList .OrderBy(e => seasonIdOrder.IndexOf(e.SeasonId)) .ThenBy(e => e.Type) .ThenBy(e => e.EpisodeNumber) .ToList(); altEpisodesList = altEpisodesList .OrderBy(e => seasonIdOrder.IndexOf(e.SeasonId)) .ThenBy(e => e.Type) .ThenBy(e => e.EpisodeNumber) .ToList(); specialsList = specialsList .OrderBy(e => seasonIdOrder.IndexOf(e.SeasonId)) .ThenBy(e => e.Type) .ThenBy(e => e.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 = seriesConfigurationMap[seasonId].Type; var isCustomType = type != series.AniDB.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 (!isCustomType && type == SeriesType.Movie) type = SeriesType.Web; episodesList = altEpisodesList; altEpisodesList = []; // Re-create the special anchors because the episode list changed. index = 0; lastNormalEpisode = -1; specialsBeforeEpisodes.Clear(); specialsAnchorDictionary.Clear(); foreach (var episode in episodes) { if (episodesList.Contains(episode)) { lastNormalEpisode = index; } else if (specialsList.Contains(episode)) { if (lastNormalEpisode == -1) { specialsBeforeEpisodes.Add(episode.Id); } else { var previousEpisode = episodes .GetRange(lastNormalEpisode, index - lastNormalEpisode) .FirstOrDefault(e => episodesList.Contains(e)); if (previousEpisode != null) specialsAnchorDictionary[episode.Id] = 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 (!isCustomType && type == SeriesType.Movie && episodes.Any(episodeInfo => episodeInfo.IsMainEntry && episodeInfo.IsHidden)) { type = SeriesType.Web; } if (seriesConfigurationMap[seasonId].EpisodeConversion is SeriesEpisodeConversion.SpecialsAsExtraFeaturettes) { if (specialsList.Count > 0) { extrasList.AddRange(specialsList); specialsAnchorDictionary.Clear(); specialsList = []; } if (altEpisodesList.Count > 0) { extrasList.AddRange(altEpisodesList); altEpisodesList = []; } } var genres = episodes.SelectMany(s => s.Genres).ToList(); var tags = episodes.SelectMany(s => s.Tags).ToList(); AddYearlySeasons(ref genres, ref tags, series.YearlySeasons); _client = client; Id = seasonId; ExtraIds = extraIds.ToArray(); TopLevelShokoGroupId = series.IDs.TopLevelGroup.ToString(); StructureType = seriesConfigurationMap[seasonId].StructureType; SeasonOrdering = seriesConfigurationMap[seasonId].SeasonOrdering; SpecialsPlacement = seriesConfigurationMap[seasonId].SpecialsPlacement; Type = type; IsMultiEntry = type is SeriesType.Movie && series.Sizes.Total.Episodes > 1; IsRestricted = series.AniDB.Restricted; Title = series.Name; Titles = [ ..series.AniDB.Titles, ..(tmdbEntity?.Titles ?? []), ]; var notes = (IReadOnlyList<string>)[]; Overview = series.Description == series.AniDB.Description ? TextUtility.SanitizeAnidbDescription(series.Description) : series.Description; Overviews = [ ..(!string.IsNullOrEmpty(series.AniDB.Description) ? [ new() { IsDefault = true, IsPreferred = string.Equals(series.Description, series.AniDB.Description), LanguageCode = "en", Source = "AniDB", Value = TextUtility.SanitizeAnidbDescription(series.AniDB.Description, out notes), }, ] : Array.Empty<Text>()), ..(tmdbEntity?.Overviews ?? []), ]; Notes = notes; OriginalLanguageCode = null; CommunityRating = series.AniDB.Rating; PremiereDate = series.AniDB.AirDate; CreatedAt = series.CreatedAt; LastUpdatedAt = series.LastUpdatedAt; EndDate = series.AniDB.EndDate; Genres = genres.Distinct().Order().ToArray(); Tags = tags.Distinct().Order().ToArray(); Studios = episodes.SelectMany(s => s.Studios).Distinct().Order().ToArray(); ProductionLocations = episodes .SelectMany(sI => sI.ProductionLocations) .GroupBy(kP => kP.Key, kP => kP.Value) .ToDictionary(gB => gB.Key, gB => gB.SelectMany(l => l).Distinct().Order().ToList() as IReadOnlyList<string>); ContentRatings = episodes .SelectMany(sI => sI.ContentRatings) .Distinct() .ToList(); // Movies aren't aired on a schedule like series, so if even if the // movie series has it's type overridden then we don't want to attempt // to create a schedule. if (series.AniDB.Type is SeriesType.Movie) { DaysOfWeek = []; } else { DaysOfWeek = episodesList .Select(e => e.AiredAt) .WhereNotNullOrDefault() .Distinct() .Order() // A single cour season is usually 12-13 episodes, give or take. .TakeLast(12) // In case the two first episodes got an early screening in a 12-13 episode single cour anime. .Skip(2) .Select(e => e.DayOfWeek) .Distinct() .Order() .ToArray(); } YearlySeasons = series.YearlySeasons; Staff = episodes.SelectMany(s => s.Staff).DistinctBy(p => new { p.Type, p.Name, p.Role }).ToArray(); EpisodeList = episodesList; AlternateEpisodesList = altEpisodesList; ExtrasList = extrasList; SpecialsList = specialsList; SpecialsBeforeEpisodes = specialsBeforeEpisodes; SpecialsAnchors = specialsAnchorDictionary; Relations = relations; RelationMap = relationMap; ShokoSeries = [ new() { ShokoSeriesId = series.Id, ShokoGroupId = series.IDs.ParentGroup.ToString(), TopLevelShokoGroupId = series.IDs.TopLevelGroup.ToString(), }, ..extraIds.Select(extraId => new ShokoSeriesInfo { ShokoSeriesId = extraId, ShokoGroupId = series.IDs.ParentGroup.ToString(), TopLevelShokoGroupId = series.IDs.TopLevelGroup.ToString(), }), ]; AnidbAnime = [new() { AnidbAnimeId = series.AniDB.Id.ToString(), }]; TmdbSeasons = tmdbSeasons ?? []; TmdbMovies = [..EpisodeList.SelectMany(eI => eI.TmdbMovies).Distinct()]; } public SeasonInfo(ShokoApiClient client, TmdbSeason tmdbSeason, TmdbShow tmdbShow, IReadOnlyList<EpisodeInfo> episodes, string? topLevelShokoGroupId, AnidbAnimeInfo[] anidbAnime, ShokoSeriesInfo[] shokoSeries) { var tags = new List<string>(); var genres = new List<string>(); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) tags.AddRange(tmdbShow.Keywords); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.TmdbGenres)) tags.AddRange(tmdbShow.Genres); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbKeywords)) genres.AddRange(tmdbShow.Keywords); if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.TmdbGenres)) genres.AddRange(tmdbShow.Genres); AddYearlySeasons(ref genres, ref tags, tmdbSeason.YearlySeasons); _client = client; Id = IdPrefix.TmdbShow + tmdbSeason.Id; ExtraIds = []; TopLevelShokoGroupId = topLevelShokoGroupId; StructureType = SeriesStructureType.TMDB_SeriesAndMovies; SeasonOrdering = Ordering.OrderType.None; SpecialsPlacement = Ordering.SpecialOrderType.Excluded; Type = SeriesType.TV; IsMultiEntry = true; IsRestricted = tmdbShow.IsRestricted; Title = tmdbSeason.Title; Titles = tmdbSeason.Titles; Overview = tmdbSeason.Overview; Overviews = tmdbSeason.Overviews; OriginalLanguageCode = tmdbShow.OriginalLanguage; CommunityRating = tmdbShow.UserRating; if (episodes.Count > 0) { PremiereDate = episodes[0].AiredAt; EndDate = (tmdbShow.LastAiredAt.HasValue || tmdbSeason.SeasonNumber < tmdbShow.SeasonCount) && episodes[^1].AiredAt is { } endDate && endDate < DateTime.Now ? endDate : null; } CreatedAt = tmdbSeason.CreatedAt; LastUpdatedAt = tmdbSeason.LastUpdatedAt; Genres = genres.Distinct().Order().ToArray(); Tags = tags.Distinct().Order().ToArray(); Studios = tmdbShow.Studios.Select(s => s.Name).Distinct().Order().ToArray(); ProductionLocations = new Dictionary<ProviderName, IReadOnlyList<string>>() { { ProviderName.TMDB, tmdbShow.ProductionCountries.Values.ToArray() }, }; ContentRatings = tmdbShow.ContentRatings; DaysOfWeek = episodes .Select(e => e.AiredAt) .WhereNotNullOrDefault() .Distinct() .Order() // A single cour season is usually 12-13 episodes, give or take. .TakeLast(12) // In case the two first episodes got an early screening in a 12-13 episode single cour anime. .Skip(2) .Select(e => e.DayOfWeek) .Distinct() .Order() .ToArray(); YearlySeasons = tmdbSeason.YearlySeasons; Staff = episodes.SelectMany(s => s.Staff).DistinctBy(p => new { p.Type, p.Name, p.Role }).ToArray(); EpisodeList = tmdbSeason.SeasonNumber is not 0 ? episodes.ToList() : []; AlternateEpisodesList = []; ExtrasList = []; SpecialsList = tmdbSeason.SeasonNumber is 0 ? episodes.ToList() : []; SpecialsBeforeEpisodes = new HashSet<string>(); SpecialsAnchors = new Dictionary<string, EpisodeInfo>(); Relations = []; RelationMap = new Dictionary<string, RelationType>(); ShokoSeries = shokoSeries ?? []; AnidbAnime = anidbAnime ?? []; TmdbSeasons = [tmdbSeason.ToInfo()]; TmdbMovies = []; } public SeasonInfo(ShokoApiClient client, TmdbMovie tmdbMovie, EpisodeInfo episodeInfo, string? topLevelShokoGroupId, AnidbAnimeInfo[] anidbAnime, ShokoSeriesInfo[] shokoSeries) { var genres = episodeInfo.Genres.ToList(); var tags = episodeInfo.Tags.ToList(); AddYearlySeasons(ref genres, ref tags, tmdbMovie.YearlySeasons); _client = client; Id = IdPrefix.TmdbMovie + tmdbMovie.Id.ToString(); ExtraIds = []; TopLevelShokoGroupId = topLevelShokoGroupId; StructureType = SeriesStructureType.TMDB_SeriesAndMovies; SeasonOrdering = Ordering.OrderType.None; SpecialsPlacement = Ordering.SpecialOrderType.Excluded; Type = SeriesType.Movie; IsMultiEntry = false; IsRestricted = tmdbMovie.IsRestricted; Title = tmdbMovie.Title; Titles = tmdbMovie.Titles; Overview = tmdbMovie.Overview; Overviews = tmdbMovie.Overviews; OriginalLanguageCode = tmdbMovie.OriginalLanguage; CommunityRating = episodeInfo.CommunityRating; PremiereDate = episodeInfo.AiredAt; CreatedAt = tmdbMovie.CreatedAt; LastUpdatedAt = tmdbMovie.LastUpdatedAt; EndDate = episodeInfo.AiredAt is { } endDate && endDate < DateTime.Now ? endDate : null; Genres = genres.Distinct().Order().ToArray(); Tags = tags.Distinct().Order().ToArray(); Studios = episodeInfo.Studios; ProductionLocations = episodeInfo.ProductionLocations; ContentRatings = episodeInfo.ContentRatings; DaysOfWeek = []; YearlySeasons = tmdbMovie.YearlySeasons; Staff = episodeInfo.Staff; EpisodeList = [episodeInfo]; AlternateEpisodesList = []; ExtrasList = []; SpecialsList = []; SpecialsBeforeEpisodes = new HashSet<string>(); SpecialsAnchors = new Dictionary<string, EpisodeInfo>(); Relations = []; RelationMap = new Dictionary<string, RelationType>(); ShokoSeries = shokoSeries; AnidbAnime = anidbAnime; TmdbSeasons = []; TmdbMovies = [new() { TmdbMovieId = tmdbMovie.Id.ToString(), TmdbMovieCollectionId = tmdbMovie.CollectionId?.ToString(), }]; } public SeasonInfo(ShokoApiClient client, TmdbMovieCollection tmdbMovieCollection, IReadOnlyList<TmdbMovie> movies, IReadOnlyList<EpisodeInfo> episodes, string? topLevelShokoGroupId, AnidbAnimeInfo[] anidbAnime, ShokoSeriesInfo[] shokoSeries) { var genres = episodes.SelectMany(m => m.Genres).ToList(); var tags = episodes.SelectMany(m => m.Genres).ToList(); AddYearlySeasons(ref genres, ref tags, movies.SelectMany(m => m.YearlySeasons)); _client = client; Id = IdPrefix.TmdbMovieCollection + tmdbMovieCollection.Id.ToString(); ExtraIds = []; TopLevelShokoGroupId = topLevelShokoGroupId; StructureType = SeriesStructureType.TMDB_SeriesAndMovies; SeasonOrdering = Ordering.OrderType.None; SpecialsPlacement = Ordering.SpecialOrderType.Excluded; Type = SeriesType.Movie; IsMultiEntry = true; IsRestricted = movies.Any(movie => movie.IsRestricted); Title = tmdbMovieCollection.Title; Titles = tmdbMovieCollection.Titles; Overview = tmdbMovieCollection.Overview; Overviews = tmdbMovieCollection.Overviews; OriginalLanguageCode = movies[0].OriginalLanguage; CommunityRating = episodes[0].CommunityRating; PremiereDate = episodes[0].AiredAt; CreatedAt = tmdbMovieCollection.CreatedAt; LastUpdatedAt = tmdbMovieCollection.LastUpdatedAt; EndDate = episodes[^1].AiredAt is { } endDate && endDate < DateTime.Now ? endDate : null; Genres = genres.Distinct().Order().ToArray(); Tags = tags.Distinct().Order().ToArray(); ProductionLocations = episodes .SelectMany(sI => sI.ProductionLocations) .GroupBy(kP => kP.Key, kP => kP.Value) .ToDictionary(gB => gB.Key, gB => gB.SelectMany(l => l).Distinct().Order().ToList() as IReadOnlyList<string>); ContentRatings = episodes .SelectMany(sI => sI.ContentRatings) .Distinct() .ToList(); DaysOfWeek = []; YearlySeasons = movies.SelectMany(m => m.YearlySeasons).Distinct().Order().ToArray(); Studios = episodes.SelectMany(m => m.Studios).Distinct().Order().ToArray(); Staff = episodes.SelectMany(s => s.Staff).DistinctBy(p => new { p.Type, p.Name, p.Role }).ToArray(); EpisodeList = [.. episodes]; AlternateEpisodesList = []; ExtrasList = []; SpecialsList = []; SpecialsBeforeEpisodes = new HashSet<string>(); SpecialsAnchors = new Dictionary<string, EpisodeInfo>(); Relations = []; RelationMap = new Dictionary<string, RelationType>(); ShokoSeries = shokoSeries; AnidbAnime = anidbAnime; TmdbSeasons = []; TmdbMovies = [ ..movies.Select(movie => new TmdbMovieInfo { TmdbMovieId = movie.Id.ToString(), TmdbMovieCollectionId = movie.CollectionId?.ToString(), }), ]; } private void AddYearlySeasons(ref List<string> genres, ref List<string> tags, IEnumerable<YearlySeason> yearlySeasons) { var seasons = yearlySeasons.Select(season => $"{season.Season} {(season.Season is YearlySeasonName.Winter ? $"{season.Year - 1}/{season.Year.ToString().Substring(2, 2)}" : season.Year)}").ToList(); if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.AllYearlySeasons)) { tags.AddRange(seasons); } else if (Plugin.Instance.Configuration.TagSources.HasFlag(TagFilter.TagSource.AllYearlySeasons) && seasons.Count > 0) { tags.Add(seasons.First()); } if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.AllYearlySeasons)) { genres.AddRange(seasons); } else if (Plugin.Instance.Configuration.GenreSources.HasFlag(TagFilter.TagSource.AllYearlySeasons) && seasons.Count > 0) { genres.Add(seasons.First()); } } public async Task<IReadOnlyList<(File file, string seriesId, HashSet<string> episodeIds)>> GetFiles() { var list = new List<(File file, string seriesId, HashSet<string> episodeIds)>(); if (StructureType is SeriesStructureType.TMDB_SeriesAndMovies) { if (Id[0] is IdPrefix.TmdbShow) { var episodes = (await _client.GetTmdbEpisodesInTmdbSeason(Id[1..]).ConfigureAwait(false)) .Select(e => e.Id) .ToHashSet(); var files = await _client.GetFilesForTmdbSeason(Id[1..]).ConfigureAwait(false); foreach (var file in files) { if (file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.Any(e => e.Shoko.HasValue && episodes.Overlaps(e.TMDB.Episode))).ToList() is not { Count: > 0 } xrefList) continue; foreach (var xref in xrefList) { var episodeIds = xref.Episodes .Where(e => e.Shoko.HasValue && episodes.Overlaps(e.TMDB.Episode)) .SelectMany(e => episodes.Intersect(e.TMDB.Episode)) .Select(e => IdPrefix.TmdbShow + e.ToString()) .ToHashSet(); list.Add((file, xref.Series.Shoko!.Value.ToString(), episodeIds)); } } } else if (Id[0] is IdPrefix.TmdbMovie) { var files = await _client.GetFilesForTmdbMovie(Id[1..]).ConfigureAwait(false); var movieId = int.Parse(Id[1..]); foreach (var file in files) { if (file.CrossReferences.FirstOrDefault(x => x.Series.Shoko.HasValue && x.Episodes.Any(e => e.Shoko.HasValue && e.TMDB.Movie.Contains(movieId))) is not { } xref) continue; list.Add((file, xref.Series.Shoko!.Value.ToString(), [IdPrefix.TmdbMovie + movieId.ToString()])); } } else if (Id[0] is IdPrefix.TmdbMovieCollection) { var movies = (await _client.GetTmdbMoviesInMovieCollection(Id[1..]).ConfigureAwait(false)) .Select(m => m.Id) .ToHashSet(); foreach (var episodeInfo in EpisodeList) { var episodeFiles = await _client.GetFilesForTmdbMovie(episodeInfo.Id[1..]).ConfigureAwait(false); var movieId = int.Parse(episodeInfo.Id[1..]); foreach (var file in episodeFiles) { if (file.CrossReferences.FirstOrDefault(x => x.Series.Shoko.HasValue && x.Episodes.Any(e => e.Shoko.HasValue && e.TMDB.Movie.Contains(movieId))) is not { } xref) continue; list.Add((file, xref.Series.Shoko!.Value.ToString(), [IdPrefix.TmdbMovie + movieId.ToString()])); } } } } else { list.AddRange( (await _client.GetFilesForShokoSeries(Id).ConfigureAwait(false)) .Select(file => ( file, Id, file.CrossReferences.FirstOrDefault(x => x.Series.Shoko.HasValue && x.Series.Shoko!.Value.ToString() == Id)?.Episodes.Select(e => e.Shoko!.Value.ToString()).ToHashSet() ?? [] )) ); foreach (var extraId in ExtraIds) list.AddRange( (await _client.GetFilesForShokoSeries(extraId).ConfigureAwait(false)) .Select(file => ( file, extraId, file.CrossReferences.FirstOrDefault(x => x.Series.Shoko.HasValue && x.Series.Shoko!.Value.ToString() == extraId)?.Episodes.Select(e => e.Shoko!.Value.ToString()).ToHashSet() ?? [] )) ); } return list; } public async Task<Images> GetImages(CancellationToken cancellationToken) => Id[0] switch { IdPrefix.TmdbShow => await _client.GetImagesForTmdbSeason(Id[1..], cancellationToken).ConfigureAwait(false), IdPrefix.TmdbMovie => await _client.GetImagesForTmdbMovie(Id[1..], cancellationToken).ConfigureAwait(false), IdPrefix.TmdbMovieCollection => await _client.GetImagesForTmdbMovieCollection(Id[1..], cancellationToken).ConfigureAwait(false), _ => await _client.GetImagesForShokoSeries(Id, cancellationToken).ConfigureAwait(false), } ?? new(); 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.IsAvailable); // The default "season" for this season info. var episodeList = EpisodeList.Count == 0 ? AlternateEpisodesList : EpisodeList; if (!episodeList.Any(eI => eI.IsAvailable)) return false; return true; } } ================================================ FILE: Shokofin/API/Info/Shoko/ShokoEpisodeInfo.cs ================================================ namespace Shokofin.API.Info.Shoko; public class ShokoEpisodeInfo { public required string ShokoEpisodeId { get; init; } public required string ShokoSeriesId { get; init; } } ================================================ FILE: Shokofin/API/Info/Shoko/ShokoSeriesInfo.cs ================================================ namespace Shokofin.API.Info.Shoko; public class ShokoSeriesInfo { public required string ShokoSeriesId { get; init; } public required string ShokoGroupId { get; init; } public required string TopLevelShokoGroupId { get; init; } } ================================================ FILE: Shokofin/API/Info/ShowInfo.cs ================================================ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using Microsoft.Extensions.Logging; using Shokofin.API.Info.AniDB; using Shokofin.API.Info.Shoko; using Shokofin.API.Info.TMDB; using Shokofin.API.Models; using Shokofin.API.Models.Shoko; using Shokofin.API.Models.TMDB; using Shokofin.Events.Interfaces; using Shokofin.ExternalIds; using Shokofin.Utils; using ContentRating = Shokofin.API.Models.ContentRating; using ContentRatingUtil = Shokofin.Utils.ContentRating; namespace Shokofin.API.Info; public class ShowInfo : IExtendedItemInfo { private readonly ShokoApiClient _client; public string Id { get; init; } public string InternalId => ShokoInternalId.SeriesNamespace + Id; /// <summary> /// Shoko Group Id used for Collection Support. /// </summary> public string? CollectionId { get; init; } public string Title { get; init; } public IReadOnlyList<Title> Titles { get; init; } public string? Overview { get; init; } public IReadOnlyList<Text> Overviews { get; init; } public IReadOnlyList<string> Notes { get; init; } = []; public string? OriginalLanguageCode { get; init; } public DateTime CreatedAt { get; init; } public DateTime LastUpdatedAt { get; init; } /// <summary> /// Indicates that this show is consistent of only movies. /// </summary> public bool IsMovieCollection { get; init; } /// <summary> /// Indicates this is a standalone show without a group attached to it. /// </summary> public bool IsStandalone { get; init; } /// <summary> /// First premiere date of the show. /// </summary> public DateTime? PremiereDate { get; init; } /// <summary> /// Ended date of the show. /// </summary> public DateTime? EndDate { get; init; } /// <summary> /// Custom rating of the show. /// </summary> public string? CustomRating => DefaultSeason.IsRestricted ? "XXX" : null; /// <summary> /// Overall community rating of the show. /// </summary> public float CommunityRating { get; init; } /// <summary> /// All tags from across all seasons. /// </summary> public IReadOnlyList<string> Tags { get; init; } /// <summary> /// All genres from across all seasons. /// </summary> public IReadOnlyList<string> Genres { get; init; } /// <summary> /// All production locations from across all seasons. /// </summary> public IReadOnlyDictionary<ProviderName, IReadOnlyList<string>> ProductionLocations { get; init; } public IReadOnlyList<ContentRating> ContentRatings { get; init; } /// <summary> /// All studios from across all seasons. /// </summary> public IReadOnlyList<string> Studios { get; init; } /// <summary> /// The inferred days of the week this series airs on. /// </summary> /// <value>Each weekday</value> public IReadOnlyList<DayOfWeek> DaysOfWeek { get; init; } /// <summary> /// The yearly seasons this series belongs to. /// </summary> public IReadOnlyList<YearlySeason> YearlySeasons { get; init; } /// <summary> /// All staff from across all seasons. /// </summary> public IReadOnlyList<PersonInfo> Staff { get; init; } /// <summary> /// All seasons. /// </summary> public IReadOnlyList<SeasonInfo> SeasonList { get; init; } /// <summary> /// The season order dictionary. /// </summary> public IReadOnlyDictionary<int, SeasonInfo> SeasonOrderDictionary { get; init; } /// <summary> /// A pre-filtered set of special episode ids without an ExtraType /// attached. /// </summary> public IReadOnlyDictionary<string, bool> SpecialsDict { get; init; } /// <summary> /// The season number base-number dictionary. /// </summary> private Dictionary<string, int> SeasonNumberBaseDictionary { get; init; } /// <summary> /// Indicates that the show has specials. /// </summary> public bool HasSpecials => SpecialsDict.Count > 0; /// <summary> /// Indicates that the show has specials with files. /// </summary> public bool HasSpecialsWithFiles => SpecialsDict.Values.Contains(true); private bool? _isAvailable = null; public bool IsAvailable => _isAvailable ??= SeasonOrderDictionary.Values.Any(sI => sI.IsAvailable) || HasSpecialsWithFiles; /// <summary> /// The default season for the show. /// </summary> public readonly SeasonInfo DefaultSeason; /// <summary> /// Episode number padding for file name generation. /// </summary> public readonly int EpisodePadding; #region Shoko Series Metadata /// <summary> /// Main Shoko Series Id. /// </summary> public string? ShokoSeriesId => ShokoSeries?.FirstOrDefault()?.ShokoSeriesId; /// <summary> /// Main Shoko Group Id. /// </summary> public string? ShokoGroupId => ShokoSeries?.FirstOrDefault()?.ShokoGroupId; /// <summary> /// All Shoko series linked to the show info. /// </summary> public ShokoSeriesInfo[] ShokoSeries { get; init; } #endregion #region AniDB Anime Metadata /// <summary> /// Main AniDB Anime Id. /// </summary> public string? AnidbAnimeId => DefaultSeason.StructureType is not Configuration.SeriesStructureType.TMDB_SeriesAndMovies ? AnidbAnime.FirstOrDefault()?.AnidbAnimeId : null; /// <summary> /// All AniDB anime linked to the show info. /// </summary> public AnidbAnimeInfo[] AnidbAnime { get; init; } #endregion #region TMDB Show Metadata /// <summary> /// Main TMDB Show Id. /// </summary> public string? TmdbShowId => TmdbShows.FirstOrDefault()?.TmdbShowId; /// <summary> /// Main TvDB Show Id. /// </summary> public string? TvdbShowId => TmdbShows.FirstOrDefault()?.TvdbShowId; /// <summary> /// All TMDB shows linked to the show info. /// </summary> public TmdbShowInfo[] TmdbShows { get; init; } #endregion #region TMDB Movie Metadata /// <summary> /// Main TMDB Movie Collection Id. /// </summary> public string? TmdbMovieCollectionId => TmdbMovies.FirstOrDefault()?.TmdbMovieCollectionId; /// <summary> /// All TMDB movies linked to the show info. /// </summary> public TmdbMovieInfo[] TmdbMovies { get; init; } #endregion public ShowInfo(ShokoApiClient client, SeasonInfo seasonInfo, TmdbShow? tmdbShow = null, string? collectionId = null) { var seasonNumberBaseDictionary = new Dictionary<string, int>(); var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); 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); _client = client; Id = seasonInfo.Id; CollectionId = collectionId ?? seasonInfo.ShokoGroupId!; IsMovieCollection = seasonInfo.Type is SeriesType.Movie; IsStandalone = true; Title = seasonInfo.Title; Overview = seasonInfo.Overview; if (tmdbShow != null) { Titles = seasonInfo.Titles.Where(t => t.Source is not "TMDB").Concat(tmdbShow.Titles).ToList(); Overviews = seasonInfo.Overviews.Where(t => t.Source is not "TMDB").Concat(tmdbShow.Overviews).ToList(); } else { Titles = seasonInfo.Titles; Overviews = seasonInfo.Overviews; } Notes = seasonInfo.Notes; OriginalLanguageCode = seasonInfo.OriginalLanguageCode; CommunityRating = seasonInfo.CommunityRating.ToFloat(10); Tags = seasonInfo.Tags; Genres = seasonInfo.Genres; PremiereDate = seasonInfo.PremiereDate; EndDate = seasonInfo.EndDate; CreatedAt = seasonInfo.CreatedAt; LastUpdatedAt = seasonInfo.LastUpdatedAt; ProductionLocations = seasonInfo.ProductionLocations; ContentRatings = seasonInfo.ContentRatings; Studios = seasonInfo.Studios; DaysOfWeek = seasonInfo.DaysOfWeek; YearlySeasons = seasonInfo.YearlySeasons; Staff = seasonInfo.Staff; SeasonList = [seasonInfo]; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; SpecialsDict = seasonInfo.SpecialsList.ToDictionary(episodeInfo => episodeInfo.Id, episodeInfo => episodeInfo.IsAvailable); DefaultSeason = seasonInfo; EpisodePadding = Math.Max(2, (new int[] { seasonInfo.EpisodeList.Count, seasonInfo.AlternateEpisodesList.Count, seasonInfo.SpecialsList.Count }).Max().ToString().Length); AnidbAnime = seasonInfo.AnidbAnime; ShokoSeries = seasonInfo.ShokoSeries; TmdbShows = [..seasonInfo.TmdbSeasons.Select(tmdbSeason => tmdbSeason.ToShowInfo()).Distinct()]; TmdbMovies = seasonInfo.TmdbMovies; } public ShowInfo( ShokoApiClient client, ILogger logger, ShokoGroup group, List<SeasonInfo> seasonList, ITmdbEntity? tmdbEntity, bool useGroupIdForCollection ) { // Order series list based on the main series or first available series. // Select the targeted id if a group specify a default series. var foundIndex = -1; var targetId = group.IDs.MainSeries.ToString(); var orderingSeason = seasonList.FirstOrDefault(s => s.Id == targetId) ?? seasonList[0]; switch (orderingSeason.SeasonOrdering) { case Ordering.OrderType.Default: foundIndex = seasonList.FindIndex(s => s.Id == targetId); break; case Ordering.OrderType.ReleaseDate: seasonList = [.. seasonList.OrderBy(s => s?.PremiereDate ?? DateTime.MaxValue)]; foundIndex = 0; break; case Ordering.OrderType.Chronological: case Ordering.OrderType.ChronologicalIgnoreIndirect: seasonList.Sort(new SeriesInfoRelationComparer(orderingSeason.SeasonOrdering is Ordering.OrderType.Chronological)); foundIndex = seasonList.FindIndex(s => s.Id == targetId); break; } // Fallback to the first series if we can't get a base point for seasons. var groupId = group.Id; 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<string, bool>(); var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); var seasonNumberBaseDictionary = new Dictionary<string, int>(); 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.IsAvailable); } var communityRatingSeasons = seasonOrderDictionary .Where(pair => seasonNumberBaseDictionary.TryGetValue(pair.Value.Id, out var seasonNumber) && seasonNumber == pair.Key && pair.Value.CommunityRating is { Value: > 0 }) .Select(pair => pair.Value) .ToList(); var anidbRating = ContentRatingUtil.GetCombinedAnidbContentRating(seasonOrderDictionary.Values); var contentRatings = seasonOrderDictionary.Values .SelectMany(sI => sI.ContentRatings) .Where(cR => cR.Source is not "AniDB") .Distinct() .ToList(); if (!string.IsNullOrEmpty(anidbRating)) contentRatings.Add(new() { Rating = anidbRating, Country = "US", Language = "en", Source = "AniDB", }); _client = client; Id = defaultSeason.Id; Title = group.Name; Titles = [ ..defaultSeason.Titles.Where(t => t.Source is "AniDB"), ..(tmdbEntity?.Titles ?? []), ]; Overview = !group.HasCustomDescription ? TextUtility.SanitizeAnidbDescription(group.Description) : group.Description; Overviews = [ ..defaultSeason.Overviews.Where(t => t.Source is "AniDB"), ..(tmdbEntity?.Overviews ?? []), ]; Notes = defaultSeason.Notes; CollectionId = useGroupIdForCollection ? groupId : group.IDs.ParentGroup?.ToString(); IsStandalone = false; PremiereDate = seasonList.Select(s => s.PremiereDate).Where(s => s.HasValue).Min(); EndDate = !seasonList.Any(s => s.PremiereDate.HasValue && s.PremiereDate.Value < DateTime.Now && s.EndDate == null) ? seasonList.Select(s => s.EndDate).Where(s => s.HasValue).Max() : null; CreatedAt = seasonList.Select(s => s.CreatedAt).Min(); LastUpdatedAt = seasonList.Select(s => s.LastUpdatedAt).Max(); CommunityRating = communityRatingSeasons.Count > 0 ? communityRatingSeasons.Aggregate(0f, (total, seasonInfo) => total + seasonInfo.CommunityRating.ToFloat(10)) / communityRatingSeasons.Count : 0f; Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); ProductionLocations = seasonList .SelectMany(sI => sI.ProductionLocations) .GroupBy(kP => kP.Key, kP => kP.Value) .ToDictionary(gB => gB.Key, gB => gB.SelectMany(l => l).Distinct().ToList() as IReadOnlyList<string>); ContentRatings = contentRatings; DaysOfWeek = seasonList[^1].DaysOfWeek; YearlySeasons = seasonList.SelectMany(s => s.YearlySeasons).Distinct().Order().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); AnidbAnime = [..defaultSeason.AnidbAnime.Concat(seasonList.Except([defaultSeason]).SelectMany(s => s.AnidbAnime)).Distinct()]; ShokoSeries = [..defaultSeason.ShokoSeries.Concat(seasonList.Except([defaultSeason]).SelectMany(s => s.ShokoSeries)).Distinct()]; TmdbShows = [ ..(tmdbEntity is TmdbShow tmdbShow ? ( new TmdbShowInfo[] { tmdbShow.ToInfo() } .Concat(seasonList.SelectMany(s => s.TmdbSeasons.Select(tmdbSeason => tmdbSeason.ToShowInfo()))) .Distinct() ) : ( seasonList.SelectMany(s => s.TmdbSeasons.Select(tmdbSeason => tmdbSeason.ToShowInfo())).Distinct() )), ]; TmdbMovies = [..seasonList.SelectMany(s => s.TmdbMovies).Distinct()]; } public ShowInfo(ShokoApiClient client, TmdbShow tmdbShow, IReadOnlyList<SeasonInfo> seasonList) { var defaultSeason = seasonList[0]; var specialsSet = new Dictionary<string, bool>(); var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); var seasonNumberBaseDictionary = new Dictionary<string, int>(); 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.IsAvailable); } _client = client; Id = defaultSeason.Id; if (seasonList.All(seasonInfo => seasonInfo.ShokoSeries.Length is > 0)) { var shokoGroupIdList = seasonList .SelectMany(s => s.ShokoSeries) .GroupBy(s => s.ShokoGroupId) .OrderByDescending(g => g.Count()) .Select(g => g.Key) .ToList(); if (shokoGroupIdList.Count is 1) CollectionId = shokoGroupIdList[0]; } else if (seasonList.All(seasonInfo => !string.IsNullOrEmpty(seasonInfo.TopLevelShokoGroupId))) { var shokoGroupIdList = seasonList .GroupBy(s => s.TopLevelShokoGroupId) .OrderByDescending(g => g.Count()) .Select(g => g.Key) .ToList(); if (shokoGroupIdList.Count is 1) CollectionId = shokoGroupIdList[0]; } IsMovieCollection = false; IsStandalone = true; Title = tmdbShow.Title; Titles = tmdbShow.Titles; Overview = tmdbShow.Overview; Overviews = tmdbShow.Overviews; OriginalLanguageCode = tmdbShow.OriginalLanguage; PremiereDate = tmdbShow.FirstAiredAt?.ToDateTime(TimeOnly.Parse("00:00:00", CultureInfo.InvariantCulture), DateTimeKind.Local); EndDate = tmdbShow.LastAiredAt?.ToDateTime(TimeOnly.Parse("00:00:00", CultureInfo.InvariantCulture), DateTimeKind.Local); CreatedAt = tmdbShow.CreatedAt; LastUpdatedAt = tmdbShow.LastUpdatedAt; CommunityRating = tmdbShow.UserRating.ToFloat(10); Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); ProductionLocations = seasonList .SelectMany(sI => sI.ProductionLocations) .GroupBy(kP => kP.Key, kP => kP.Value) .ToDictionary(gB => gB.Key, gB => gB.SelectMany(l => l).Distinct().ToList() as IReadOnlyList<string>); ContentRatings = seasonList .SelectMany(sI => sI.ContentRatings) .Distinct() .ToList(); DaysOfWeek = seasonList[^1].DaysOfWeek; YearlySeasons = seasonList.SelectMany(s => s.YearlySeasons).Distinct().Order().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); AnidbAnime = [..seasonList.SelectMany(s => s.AnidbAnime).Distinct()]; ShokoSeries = [..seasonList.SelectMany(s => s.ShokoSeries).Distinct()]; TmdbShows = [tmdbShow.ToInfo()]; TmdbMovies = []; } public ShowInfo(ShokoApiClient client, TmdbMovie tmdbMovie, SeasonInfo seasonInfo) { var releasedAt = tmdbMovie.ReleasedAt?.ToDateTime(TimeOnly.Parse("00:00:00", CultureInfo.InvariantCulture), DateTimeKind.Local); _client = client; Id = seasonInfo.Id; CollectionId = seasonInfo.ShokoGroupId ?? seasonInfo.TopLevelShokoGroupId; IsMovieCollection = true; IsStandalone = true; Title = tmdbMovie.Title; Titles = tmdbMovie.Titles; Overview = tmdbMovie.Overview; Overviews = tmdbMovie.Overviews; OriginalLanguageCode = tmdbMovie.OriginalLanguage; PremiereDate = releasedAt; EndDate = releasedAt < DateTime.Now ? releasedAt : null; CreatedAt = tmdbMovie.CreatedAt; LastUpdatedAt = tmdbMovie.LastUpdatedAt; CommunityRating = tmdbMovie.UserRating.ToFloat(10); Genres = seasonInfo.Genres; Tags = seasonInfo.Tags; Studios = seasonInfo.Studios; ProductionLocations = seasonInfo.ProductionLocations; ContentRatings = seasonInfo.ContentRatings; DaysOfWeek = []; YearlySeasons = seasonInfo.YearlySeasons; Staff = seasonInfo.Staff; SeasonList = [seasonInfo]; SeasonNumberBaseDictionary = new Dictionary<string, int> { { seasonInfo.Id, 1 } }; SeasonOrderDictionary = new Dictionary<int, SeasonInfo> { { 1, seasonInfo } }; SpecialsDict = new Dictionary<string, bool>(); DefaultSeason = seasonInfo; EpisodePadding = Math.Max(2, (new int[] { seasonInfo.EpisodeList.Count, seasonInfo.AlternateEpisodesList.Count, seasonInfo.SpecialsList.Count }).Max().ToString().Length); AnidbAnime = seasonInfo.AnidbAnime; ShokoSeries = seasonInfo.ShokoSeries; TmdbShows = [..seasonInfo.TmdbSeasons.Select(tmdbSeason => tmdbSeason.ToShowInfo()).Distinct()]; TmdbMovies = seasonInfo.TmdbMovies; } public ShowInfo(ShokoApiClient client, TmdbMovieCollection tmdbMovieCollection, IReadOnlyList<SeasonInfo> seasonList) { var defaultSeason = seasonList[0]; var specialsSet = new Dictionary<string, bool>(); var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); var seasonNumberBaseDictionary = new Dictionary<string, int>(); 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.IsAvailable); } var communityRatingSeasons = seasonOrderDictionary .Where(pair => seasonNumberBaseDictionary.TryGetValue(pair.Value.Id, out var seasonNumber) && seasonNumber == pair.Key && pair.Value.CommunityRating is { Value: > 0 }) .Select(pair => pair.Value) .ToList(); _client = client; Id = defaultSeason.Id; if (seasonList.All(seasonInfo => seasonInfo.ShokoSeries.Length is > 0)) { var shokoGroupIdList = seasonList .SelectMany(s => s.ShokoSeries) .GroupBy(s => s.ShokoGroupId) .OrderByDescending(g => g.Count()) .Select(g => g.Key) .ToList(); if (shokoGroupIdList.Count is 1) CollectionId = shokoGroupIdList[0]; } else if (seasonList.All(seasonInfo => !string.IsNullOrEmpty(seasonInfo.TopLevelShokoGroupId))) { var shokoGroupIdList = seasonList .GroupBy(s => s.TopLevelShokoGroupId) .OrderByDescending(g => g.Count()) .Select(g => g.Key) .ToList(); if (shokoGroupIdList.Count is 1) CollectionId = shokoGroupIdList[0]; } IsMovieCollection = seasonList.Count is 1; IsStandalone = false; Title = tmdbMovieCollection.Title; Titles = tmdbMovieCollection.Titles; Overview = tmdbMovieCollection.Overview; Overviews = tmdbMovieCollection.Overviews; OriginalLanguageCode = defaultSeason.OriginalLanguageCode; PremiereDate = seasonList[0].PremiereDate; EndDate = seasonList[^1].EndDate; CreatedAt = tmdbMovieCollection.CreatedAt; LastUpdatedAt = tmdbMovieCollection.LastUpdatedAt; CommunityRating = communityRatingSeasons.Count > 0 ? communityRatingSeasons.Aggregate(0f, (total, seasonInfo) => total + seasonInfo.CommunityRating.ToFloat(10)) / communityRatingSeasons.Count : 0f; Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); ProductionLocations = seasonList .SelectMany(sI => sI.ProductionLocations) .GroupBy(kP => kP.Key, kP => kP.Value) .ToDictionary(gB => gB.Key, gB => gB.SelectMany(l => l).Distinct().ToList() as IReadOnlyList<string>); ContentRatings = seasonList .SelectMany(sI => sI.ContentRatings) .Distinct() .ToList(); Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); DaysOfWeek = []; YearlySeasons = seasonList.SelectMany(s => s.YearlySeasons).Distinct().Order().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); AnidbAnime = [..seasonList.SelectMany(s => s.AnidbAnime).Distinct()]; ShokoSeries = [..seasonList.SelectMany(s => s.ShokoSeries).Distinct()]; TmdbShows = []; TmdbMovies = [..seasonList.SelectMany(s => s.TmdbMovies).Distinct()]; } public async Task<Images> GetImages(CancellationToken cancellationToken) => Id[0] switch { IdPrefix.TmdbShow => await _client.GetImagesForTmdbShow(TmdbShowId!, cancellationToken).ConfigureAwait(false), IdPrefix.TmdbMovie => !string.IsNullOrEmpty(TmdbMovieCollectionId) ? await _client.GetImagesForTmdbMovieCollection(TmdbMovieCollectionId, cancellationToken).ConfigureAwait(false) : await _client.GetImagesForTmdbMovie(Id[1..], cancellationToken).ConfigureAwait(false), IdPrefix.TmdbMovieCollection => await _client.GetImagesForTmdbMovieCollection(Id[1..], cancellationToken).ConfigureAwait(false), _ => await _client.GetImagesForShokoSeries(Id, cancellationToken).ConfigureAwait(false), } ?? new(); 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) => seasonNumber is > 0 && SeasonOrderDictionary.TryGetValue(seasonNumber, out var seasonInfo) ? seasonInfo : null; } ================================================ FILE: Shokofin/API/Info/TMDB/TmdbEpisodeInfo.cs ================================================ using System.Diagnostics.CodeAnalysis; namespace Shokofin.API.Info.TMDB; public class TmdbEpisodeInfo { public required string TmdbShowId { get; init; } public required string TmdbAlternateOrderingId { get; init; } public required string TmdbSeasonId { get; init; } public required string TmdbEpisodeId { get; init; } public required string? TvdbEpisodeId { get; init; } public required int SeasonNumber { get; init; } public required int EpisodeNumber { get; init; } public required int? OriginalEpisodeNumber { get; init; } public required int? OriginalSeasonNumber { get; init; } [MemberNotNullWhen(true, nameof(OriginalEpisodeNumber))] [MemberNotNullWhen(true, nameof(OriginalSeasonNumber))] public bool UsesAlternateOrdering => TmdbShowId != TmdbAlternateOrderingId; } ================================================ FILE: Shokofin/API/Info/TMDB/TmdbMovieInfo.cs ================================================ using System; namespace Shokofin.API.Info.TMDB; public class TmdbMovieInfo : IComparable<TmdbMovieInfo>, IEquatable<TmdbMovieInfo> { public required string TmdbMovieId { get; init; } public required string? TmdbMovieCollectionId { get; init; } public int CompareTo(TmdbMovieInfo? other) => other is null ? 1 : TmdbMovieId.CompareTo(other?.TmdbMovieId); public bool Equals(TmdbMovieInfo? other) => other is not null && (ReferenceEquals(this, other) || TmdbMovieId == other.TmdbMovieId); public override bool Equals(object? obj) => Equals(obj as TmdbMovieInfo); public override int GetHashCode() => HashCode.Combine(TmdbMovieId); } ================================================ FILE: Shokofin/API/Info/TMDB/TmdbSeasonInfo.cs ================================================ namespace Shokofin.API.Info.TMDB; public class TmdbSeasonInfo { public required string TmdbShowId { get; init; } public required string TmdbAlternateOrderingId { get; init; } public required string TmdbSeasonId { get; init; } public required int SeasonNumber { get; init; } public bool UsesAlternateOrdering => TmdbShowId != TmdbAlternateOrderingId; public TmdbShowInfo ToShowInfo() => new() { TmdbShowId = TmdbShowId, TmdbAlternateOrderingId = TmdbAlternateOrderingId, }; } ================================================ FILE: Shokofin/API/Info/TMDB/TmdbShowInfo.cs ================================================ using System; namespace Shokofin.API.Info.TMDB; public class TmdbShowInfo : IComparable<TmdbShowInfo>, IEquatable<TmdbShowInfo> { public required string TmdbShowId { get; init; } public required string TmdbAlternateOrderingId { get; init; } public string? TvdbShowId { get; init; } public bool UsesAlternateOrdering => TmdbShowId != TmdbAlternateOrderingId; public int CompareTo(TmdbShowInfo? other) => other is null ? 1 : TmdbShowId.CompareTo(other?.TmdbShowId); public bool Equals(TmdbShowInfo? other) => other is not null && (ReferenceEquals(this, other) || TmdbShowId == other.TmdbShowId); public override bool Equals(object? obj) => Equals(obj as TmdbShowInfo); public override int GetHashCode() => HashCode.Combine(TmdbShowId); } ================================================ FILE: Shokofin/API/Models/AniDB/AnidbAnime.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; namespace Shokofin.API.Models.AniDB; public class AnidbAnime { /// <summary> /// AniDB Id /// </summary> [JsonPropertyName("ID")] public int Id { get; set; } /// <summary> /// <see cref="Shoko.ShokoSeries"/> 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 IReadOnlyList<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.Episode"/> 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 AnidbAnimeWithDate : AnidbAnime { /// <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; } = []; /// <summary> /// The average rating for the anime. Only available on /// </summary> public new Rating Rating { get; set; } = new(); /// <summary> /// Number of <see cref="EpisodeType.Episode"/> 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; } } } ================================================ FILE: Shokofin/API/Models/AniDB/AnidbEpisode.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Shokofin.API.Info.AniDB; namespace Shokofin.API.Models.AniDB; public class AnidbEpisode { [JsonPropertyName("ID")] public int Id { get; set; } [JsonPropertyName("AnimeID")] public int AnimeId { get; set; } /// <summary> /// The duration of the episode. /// </summary> public TimeSpan Duration { get; set; } [JsonConverter(typeof(JsonStringEnumConverter))] public EpisodeType Type { get; set; } public int EpisodeNumber { get; set; } public DateTime? AirDate { get; set; } public IReadOnlyList<Title> Titles { get; set; } = []; public string Description { get; set; } = string.Empty; public Rating Rating { get; set; } = new(); public AnidbEpisodeInfo ToInfo() => new() { AnidbAnimeId = AnimeId.ToString(), AnidbEpisodeId = Id.ToString(), EpisodeNumber = EpisodeNumber, Type = Type, }; } ================================================ FILE: Shokofin/API/Models/ApiException.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Text.Json; using Shokofin.Extensions; namespace Shokofin.API.Models; [Serializable] public class ApiException : Exception { private record ValidationResponse { public Dictionary<string, string[]> errors = []; 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<string, string[]> ValidationErrors; public ApiException(HttpStatusCode statusCode, string source, string? message) : base(string.IsNullOrEmpty(message) ? source : $"{source}: {message}") { StatusCode = statusCode; Type = ApiExceptionType.Simple; ValidationErrors = []; } protected ApiException(HttpStatusCode statusCode, RemoteApiException inner) : base(inner.Message, inner) { StatusCode = statusCode; Type = ApiExceptionType.RemoteException; Inner = inner; ValidationErrors = []; } protected ApiException(HttpStatusCode statusCode, string source, string? message, Dictionary<string, string[]>? validationErrors = null): base(string.IsNullOrEmpty(message) ? source : $"{source}: {message}") { StatusCode = statusCode; Type = ApiExceptionType.ValidationErrors; ValidationErrors = validationErrors ?? []; } public static ApiException FromResponse(HttpResponseMessage response) { var text = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); if (text.Length > 0 && text[0] == '{') { var full = JsonSerializer.Deserialize<ValidationResponse>(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(':') ?? []; 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; } /// <inheritdoc/> public override string StackTrace { get; } } public enum ApiExceptionType { Simple = 0, ValidationErrors = 1, RemoteException = 2, } } ================================================ FILE: Shokofin/API/Models/ApiKey.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class ApiKey { /// <summary> /// The Api Key Token. /// </summary> [JsonPropertyName("apikey")] public string Token { get; set; } = string.Empty; } ================================================ FILE: Shokofin/API/Models/ComponentVersion.cs ================================================ using System; using System.ComponentModel; using System.Linq; using System.Text.Json.Serialization; using Shokofin.Extensions; namespace Shokofin.API.Models; public class ComponentVersionSet { /// <summary> /// Shoko.Server version. /// </summary> public ComponentVersion Server { get; set; } = new(); } public class ComponentVersion { /// <summary> /// Version number. /// </summary> [DefaultValue("1.0.0")] public string Version { get; set; } = "1.0.0"; /// <summary> /// Commit SHA. /// </summary> public string? Commit { get; set; } /// <summary> /// Release channel. /// </summary> public ReleaseChannel? ReleaseChannel { get; set; } /// <summary> /// Release date. /// </summary> 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)).WhereNotNull().Join(", "); if (extraDetails.Length == 0) return $"Version {Version}"; return $"Version {Version} ({extraDetails})"; } } [JsonConverter(typeof(JsonStringEnumConverter))] public enum ReleaseChannel { Stable = 1, Dev = 2, Debug = 3, } ================================================ FILE: Shokofin/API/Models/ContentRating.cs ================================================ using System; namespace Shokofin.API.Models; public class ContentRating : IEquatable<ContentRating> { /// <summary> /// The content rating for the specified language. /// </summary> public string Rating { get; set; } = string.Empty; /// <summary> /// The country code the rating applies for. /// </summary> public string Country { get; set; } = string.Empty; /// <summary> /// The language code the rating applies for. /// </summary> public string Language { get; set; } = string.Empty; /// <summary> /// The source of the content rating. /// </summary> public string Source { get; set; } = string.Empty; public bool Equals(ContentRating? other) => other is not null && ( ReferenceEquals(this, other) || ( Rating == other.Rating && Country == other.Country && Language == other.Language && Source == other.Source ) ); public override bool Equals(object? obj) => obj is ContentRating other && Equals(other); public override int GetHashCode() => HashCode.Combine( Rating, Country, Language, Source); } ================================================ FILE: Shokofin/API/Models/CrossReference.cs ================================================ using System.Collections.Generic; using System.Text.Json.Serialization; using Shokofin.API.Models.Shoko; namespace Shokofin.API.Models; public class CrossReference { /// <summary> /// The Series IDs /// </summary> [JsonPropertyName("SeriesID")] public SeriesCrossReferenceIDs Series { get; set; } = new(); /// <summary> /// The Episode IDs /// </summary> [JsonPropertyName("EpisodeIDs")] public List<EpisodeCrossReferenceIDs> Episodes { get; set; } = []; /// <summary> /// File episode cross-reference for a series. /// </summary> public class EpisodeCrossReferenceIDs { /// <summary> /// The Shoko ID, if the local metadata has been created yet. /// </summary> [JsonPropertyName("ID")] public int? Shoko { get; set; } /// <summary> /// The AniDB ID. /// </summary> public int AniDB { get; set; } /// <summary> /// The Movie DataBase (TMDB) Cross-Reference IDs. /// </summary> public ShokoEpisode.TmdbEpisodeIDs TMDB { get; set; } = new(); /// <summary> /// The Release Group ID. /// </summary> public int? ReleaseGroup { get; set; } /// <summary> /// ED2K hash. /// </summary> public string ED2K { get; set; } = string.Empty; /// <summary> /// File size. /// </summary> public long FileSize { get; set; } /// <summary> /// Percentage file is matched to the episode. /// </summary> public CrossReferencePercentage Percentage { get; set; } = new(); } public class CrossReferencePercentage { /// <summary> /// File/episode cross-reference percentage range start. /// </summary> public int Start { get; set; } /// <summary> /// File/episode cross-reference percentage range end. /// </summary> public int End { get; set; } /// <summary> /// The raw percentage to "group" the cross-references by. /// </summary> public int Size { get; set; } /// <summary> /// The assumed number of groups in the release, to group the /// cross-references by. /// </summary> public int? Group { get; set; } } /// <summary> /// File series cross-reference. /// </summary> public class SeriesCrossReferenceIDs { /// <summary> /// The Shoko ID, if the local metadata has been created yet. /// /// </summary> [JsonPropertyName("ID")] public int? Shoko { get; set; } /// <summary> /// The AniDB ID. /// </summary> public int AniDB { get; set; } /// <summary> /// The Movie DataBase (TMDB) Cross-Reference IDs. /// </summary> public ShokoSeries.TmdbSeriesIDs TMDB { get; set; } = new(); } } ================================================ FILE: Shokofin/API/Models/EpisodeType.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; [JsonConverter(typeof(JsonStringEnumConverter))] 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 = 2, /// <summary> /// The episode type is unknown. /// </summary> Unknown = Other, /// <summary> /// A normal episode. /// </summary> Episode = 1, /// <summary> /// A normal episode. /// </summary> Normal = Episode, /// <summary> /// A special episode. /// </summary> Special = 3, /// <summary> /// A trailer. /// </summary> Trailer = 4, /// <summary> /// An opening song, ending song, or other type of credits. /// </summary> Credits = 5, /// <summary> /// Either an opening-song, or an ending-song. /// </summary> ThemeSong = Credits, /// <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, } ================================================ FILE: Shokofin/API/Models/File.cs ================================================ 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; } = []; /// <summary> /// Indicates this file is marked as a variation in Shoko Server. /// </summary> public bool IsVariation { get; set; } /// <summary> /// All the <see cref="Location"/>s this <see cref="File"/> is present at. /// </summary> public List<Location> Locations { get; set; } = []; /// <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("Release")] public ReleaseInfo? Release { get; set; } [JsonPropertyName("AniDB")] public ReleaseInfo? LegacyRelease { get => Release; set => Release = value; } /// <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="ManagedFolder"/> this <see cref="File"/> /// resides in. /// </summary> [JsonPropertyName("ImportFolderID")] public int ImportFolderId { get => ManagedFolderId; set => ManagedFolderId = value; } /// <summary> /// The id of the <see cref="ManagedFolder"/> this <see cref="File"/> /// resides in. /// </summary> [JsonPropertyName("ManagedFolderID")] public int ManagedFolderId { get; set; } /// <summary> /// The relative path from the base of the <see cref="ManagedFolder"/> 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="ManagedFolder"/> 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> /// 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; } } } ================================================ FILE: Shokofin/API/Models/IDs.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class IDs { [JsonPropertyName("ID")] public int Shoko { get; set; } } ================================================ FILE: Shokofin/API/Models/Image.cs ================================================ using System; using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class Image { /// <summary> /// AniDB, TMDB, etc. /// </summary> public ImageSource Source { get; set; } = ImageSource.AniDB; /// <summary> /// Poster, Banner, etc. /// </summary> public ShokoImageType Type { get; set; } = ShokoImageType.Poster; /// <summary> /// The image's id. /// </summary> [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public int ID { get; set; } = 0; /// <summary> /// True if the image is marked as the preferred for the given /// <see cref="ShokoImageType"/>. Only one preferred is possible for a given /// <see cref="ShokoImageType"/>. /// </summary> [JsonPropertyName("Preferred")] public bool IsPreferred { get; set; } = false; /// <summary> /// True if the image has been disabled. You must explicitly ask for these, /// for hopefully obvious reasons. /// </summary> [JsonPropertyName("Disabled")] public bool IsDisabled { get; set; } = false; /// <summary> /// The language code for the image, if available. /// </summary> public string? LanguageCode { get; set; } = null; /// <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> /// Community rating for the image, if available. /// </summary> public Rating? CommunityRating { get; set; } /// <summary> /// Json deserialization constructor. /// </summary> public Image() { } /// <summary> /// Copy constructor. /// </summary> public Image(Image image) : this() { Source = image.Source; Type = image.Type; ID = image.ID; IsPreferred = image.IsPreferred; IsDisabled = image.IsDisabled; LanguageCode = image.LanguageCode; Width = image.Width; Height = image.Height; LocalPath = image.LocalPath; CommunityRating = image.CommunityRating is { } rating ? new(rating) : null; } /// <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(bool internalUrl = false) => new Uri(new Uri(internalUrl ? Plugin.Instance.BaseUrl : Web.ImageHostUrl.BaseUrl), $"{(internalUrl ? Plugin.Instance.BasePath : Web.ImageHostUrl.BasePath)}/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); } /// <summary> /// Image source. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public enum ImageSource { /// <summary> /// /// </summary> AniDB = 1, /// <summary> /// /// </summary> TMDB = 2, /// <summary> /// /// </summary> Shoko = 100, } /// <summary> /// Image type. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public enum ShokoImageType { /// <summary> /// /// </summary> Poster = 1, /// <summary> /// /// </summary> Banner = 2, /// <summary> /// /// </summary> Thumb = 3, /// <summary> /// /// </summary> Thumbnail = Thumb, /// <summary> /// /// </summary> Fanart = 4, /// <summary> /// /// </summary> Backdrop = Fanart, /// <summary> /// /// </summary> Character = 5, /// <summary> /// /// </summary> Staff = 6, /// <summary> /// Clear-text logo. /// </summary> Logo = 7, } ================================================ FILE: Shokofin/API/Models/Images.cs ================================================ using System.Collections.Generic; namespace Shokofin.API.Models; public class Images { public List<Image> Posters { get; set; } = []; public List<Image> Backdrops { get; set; } = []; public List<Image> Banners { get; set; } = []; public List<Image> Logos { get; set; } = []; } public class EpisodeImages : Images { public List<Image> Thumbnails { get; set; } = []; } ================================================ FILE: Shokofin/API/Models/ListResult.cs ================================================ 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; } = []; } ================================================ FILE: Shokofin/API/Models/ManagedFolder.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class ManagedFolder { /// <summary> /// The ID of the managed folder. /// </summary> [JsonPropertyName("ID")] public int Id { get; set; } /// <summary> /// The friendly name of the managed folder, if any. /// </summary> public string? Name { get; set; } } ================================================ FILE: Shokofin/API/Models/Rating.cs ================================================ 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; } /// <summary> /// Json deserialization constructor. /// </summary> public Rating() { } /// <summary> /// Copy constructor. /// </summary> public Rating(Rating rating) { Value = rating.Value; MaxValue = rating.MaxValue; Source = rating.Source; Votes = rating.Votes; Type = rating.Type; } public float ToFloat(int scale) => scale == MaxValue ? (float)Value : (float)((Value * scale) / MaxValue); } ================================================ FILE: Shokofin/API/Models/Relation.cs ================================================ 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="Shoko.ShokoSeries"/> entry. /// </summary> public int? Shoko { get; set; } /// <summary> /// The ID of the <see cref="AniDB.AnidbAnime"/> 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, } ================================================ FILE: Shokofin/API/Models/ReleaseGroup.cs ================================================ using System.Text.Json.Serialization; using Shokofin.API.Converters; namespace Shokofin.API.Models; public class ReleaseGroup { /// <summary> /// The AniDB Release Group ID (e.g. 1) /// /// </summary> [JsonPropertyName("ID"), JsonConverter(typeof(JsonAutoStringConverter))] public string? Id { get; set; } /// <summary> /// The release group's Name (e.g. "Unlimited Translation Works") /// </summary> public string? Name { get; set; } /// <summary> /// The release group's Name (e.g. "UTW") /// </summary> public string? ShortName { get; set; } /// <summary> /// The release group's Source (e.g. "AniDB") /// </summary> public string? Source { get; set; } } ================================================ FILE: Shokofin/API/Models/ReleaseInfo.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class ReleaseInfo { /// <summary> /// Blu-ray, DVD, LD, TV, etc.. /// </summary> [JsonInclude, JsonConverter(typeof(JsonStringEnumConverter))] public ReleaseSource Source { get; set; } /// <summary> /// The Release Group. /// </summary> [JsonInclude, JsonPropertyName("Group")] public ReleaseGroup? Group { get; set; } /// <summary> /// The Release Group. /// </summary> [JsonInclude, JsonPropertyName("ReleaseGroup")] public ReleaseGroup? LegacyGroup { get => Group; set => Group = value; } /// <summary> /// The file's version. /// </summary> public int Version { get; set; } } ================================================ FILE: Shokofin/API/Models/ReleaseSource.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; [JsonConverter(typeof(JsonStringEnumConverter))] public enum ReleaseSource { Unknown = 0, Other = 1, TV = 2, DVD = 3, BluRay = 4, Web = 5, VHS = 6, VCD = 7, LaserDisc = 8, Camera = 9 } ================================================ FILE: Shokofin/API/Models/Role.cs ================================================ 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.Actor"/>. /// </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() => HashCode.Combine(Name, Type, Language, Staff, Character); public class Person : IEquatable<Person> { /// <summary> /// Id of the person. /// </summary> [JsonPropertyName("ID")] public int? Id { get; set; } /// <summary> /// Whether the object is a person, company or collab. Only set for AniDB creators. /// </summary> public string? Type { get; set; } /// <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 Id == other.Id && 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() => HashCode.Combine(Id, Name, Description, AlternateName); } } [JsonConverter(typeof(JsonStringEnumConverter))] public enum CreatorRoleType { /// <summary> /// Voice actor or voice actress. /// </summary> Actor, /// <summary> /// Synonym of <see cref="CreatorRoleType.Actor"/> for backwards compatibility for a few server versions. /// </summary> Seiyuu = Actor, /// <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, } ================================================ FILE: Shokofin/API/Models/SeriesType.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; [JsonConverter(typeof(JsonStringEnumConverter))] public enum SeriesType { /// <summary> /// Only for use with <see cref="Configuration.SeriesConfiguration"/>. /// </summary> None, /// <summary> /// The series type is unknown as of yet. This may be updated in the future. /// </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> /// Original Net Animations (ONAs), AKA standalone releases that aired on the web. /// </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, /// <summary> /// Standalone music videos. /// </summary> MusicVideo, } ================================================ FILE: Shokofin/API/Models/Shoko/ShokoEpisode.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Shokofin.API.Info.Shoko; using Shokofin.API.Models.AniDB; namespace Shokofin.API.Models.Shoko; public class ShokoEpisode { public string Id => IDs.Shoko.ToString(); /// <summary> /// All identifiers related to the episode entry, e.g. the Shoko, AniDB, /// TMDB, etc. /// </summary> public EpisodeIDs IDs { get; set; } = new(); /// <summary> /// The preferred name of the episode based on the selected episode language /// settings on the server. /// </summary> public string Name { get; set; } = string.Empty; /// <summary> /// The preferred description of the episode based on the selected episode /// language settings on the server. /// </summary> public string Description { get; set; } = string.Empty; /// <summary> /// The duration of the episode. /// </summary> public TimeSpan Duration { get; set; } /// <summary> /// Indicates the episode is hidden. /// </summary> public bool IsHidden { get; set; } /// <summary> /// Number of files linked to the episode. /// </summary> /// <value></value> public int Size { get; set; } /// <summary> /// The <see cref="AnidbEpisode"/>, if <see cref="DataSource.AniDB"/> is /// included in the data to add. /// </summary> public AnidbEpisode AniDB { get; set; } = new(); /// <summary> /// File cross-references for the episode. /// </summary> public List<CrossReference.EpisodeCrossReferenceIDs> CrossReferences { get; set; } = []; /// <summary> /// When the episode entry was created. /// </summary> [JsonPropertyName("Created")] public DateTime CreatedAt { get; set; } /// <summary> /// When the episode entry was last updated. /// </summary> [JsonPropertyName("Updated")] public DateTime LastUpdatedAt { get; set; } public ShokoEpisodeInfo ToInfo() => new() { ShokoSeriesId = IDs.ParentSeries.ToString(), ShokoEpisodeId = Id, }; public class EpisodeIDs : IDs { public int ParentSeries { get; set; } #if DEBUG public int AniDB { get; set; } public List<int> TvDB { get; set; } = []; public List<string> IMDB { get; set; } = []; #endif public TmdbEpisodeIDs TMDB { get; init; } = new(); } public class TmdbEpisodeIDs { public List<int> Episode { get; init; } = []; public List<int> Movie { get; init; } = []; #if DEBUG public List<int> Show { get; init; } = []; #endif } } ================================================ FILE: Shokofin/API/Models/Shoko/ShokoGroup.cs ================================================ using System; using System.Text.Json.Serialization; namespace Shokofin.API.Models.Shoko; public class ShokoGroup { public string Id => IDs.Shoko.ToString(); public GroupIDs IDs { get; set; } = new(); public string Name { get; set; } = string.Empty; #if DEBUG public string SortName { get; set; } = string.Empty; public bool HasCustomName { get; set; } #endif public string Description { get; set; } = string.Empty; public bool HasCustomDescription { get; set; } public int Size { get; set; } public GroupSizes Sizes { get; set; } = new(); /// <summary> /// When the group entry was created. /// </summary> [JsonPropertyName("Created")] public DateTime CreatedAt { get; set; } /// <summary> /// When the group entry was last updated. /// </summary> [JsonPropertyName("Updated")] public DateTime LastUpdatedAt { get; set; } 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 : ShokoSeries.SeriesSizes { /// <summary> /// Number of direct sub-groups within the group. /// /// </summary> /// <value></value> public int SubGroups { get; set; } #if DEBUG /// <summary> /// Count of the different series types within the group. /// </summary> public SeriesTypeCounts SeriesTypes { get; set; } = new(); public class SeriesTypeCounts { public int Unknown { get; set; } public int Other { get; set; } public int TV { get; set; } public int TVSpecial { get; set; } public int Web { get; set; } public int Movie { get; set; } public int OVA { get; set; } } #endif } } ================================================ FILE: Shokofin/API/Models/Shoko/ShokoSeries.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Shokofin.API.Models.AniDB; namespace Shokofin.API.Models.Shoko; public class ShokoSeries { public string Id => IDs.Shoko.ToString(); /// <summary> /// All identifiers related to the series entry, e.g. the Shoko, AniDB, /// TMDB, etc. /// </summary> public SeriesIDs IDs { get; set; } = new(); /// <summary> /// The preferred name of the series based on the selected series language /// settings on the server. /// </summary> public string Name { get; set; } = string.Empty; /// <summary> /// The preferred description of the series based on the selected series /// language settings on the server. /// </summary> public string Description { get; set; } = string.Empty; /// <summary> /// The yearly seasons this series belongs to. /// </summary> public List<YearlySeason> YearlySeasons { get; set; } = []; /// <summary> /// The AniDB entry. /// </summary> public AnidbAnimeWithDate AniDB { get; set; } = new(); /// <summary> /// Different size metrics for the series. /// </summary> 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 SeriesIDs : IDs { /// <summary> /// The ID of the direct parent group, if it has one. /// </summary> public int ParentGroup { get; set; } = 0; /// <summary> /// The ID of the top-level (ancestor) group this series belongs to. /// </summary> public int TopLevelGroup { get; set; } = 0; /// <summary> /// The AniDB ID /// </summary> public int AniDB { get; set; } = 0; /// <summary> /// The TvDB IDs /// </summary> public List<int> TvDB { get; set; } = []; /// <summary> /// The IMDB Movie IDs. /// </summary> public List<string> IMDB { get; set; } = []; /// <summary> /// The Movie Database (TMDB) IDs. /// </summary> public TmdbSeriesIDs TMDB { get; set; } = new(); } public class TmdbSeriesIDs { public List<int> Movie { get; init; } = []; public List<int> Show { get; init; } = []; } /// <summary> /// Different size metrics for the series. /// </summary> public class SeriesSizes { #if DEBUG /// <summary> /// Count of hidden episodes, be it available or missing. /// </summary> public int Hidden { get; set; } #endif /// <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(); #if DEBUG /// <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(); #endif /// <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 Episodes { get; set; } #if DEBUG 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; } #endif } public class FileSourceCounts { public int Unknown { get; set; } public int Other { get; set; } public int TV { get; set; } public int DVD { get; set; } public int BluRay { get; set; } public int Web { get; set; } public int VHS { get; set; } public int VCD { get; set; } public int LaserDisc { get; set; } public int Camera { get; set; } } } } ================================================ FILE: Shokofin/API/Models/Studio.cs ================================================ using System.Collections.Generic; using System.Text.Json.Serialization; namespace Shokofin.API.Models; /// <summary> /// APIv3 Studio Data Transfer Object (DTO). /// </summary> public class Studio { /// <summary> /// Studio ID relative to the <see cref="Source"/>. /// </summary> [JsonPropertyName("ID")] public int Id { get; init; } /// <summary> /// The name of the studio. /// </summary> public string Name { get; init; } = string.Empty; /// <summary> /// The country the studio originates from. /// </summary> public string CountryOfOrigin { get; init; } = string.Empty; /// <summary> /// Entities produced by the studio in the local collection, both movies /// and/or shows. /// </summary> public int Size { get; init; } /// <summary> /// Logos used by the studio. /// </summary> public IReadOnlyList<Image> Logos { get; init; } = []; /// <summary> /// The source of which the studio metadata belongs to. /// </summary> public string Source { get; init; } = string.Empty; } ================================================ FILE: Shokofin/API/Models/TMDB/AlternateOrderingType.cs ================================================ namespace Shokofin.API.Models.TMDB; public enum AlternateOrderingType { Unknown = 0, OriginalAirDate = 1, Absolute = 2, DVD = 3, Digital = 4, StoryArc = 5, Production = 6, TV = 7, } ================================================ FILE: Shokofin/API/Models/TMDB/ITmdbEntity.cs ================================================ using System; using System.Collections.Generic; using Jellyfin.Data.Enums; namespace Shokofin.API.Models.TMDB; public interface ITmdbEntity { string Id { get; } BaseItemKind Kind { get; } /// <summary> /// Preferred title based upon Shoko's title preference. /// </summary> string Title { get; } /// <summary> /// All available titles for the entity, if they should be included. /// </summary> IReadOnlyList<Title> Titles { get; } /// <summary> /// Preferred overview based upon description preference. /// </summary> string Overview { get; } /// <summary> /// All available overviews for the entity, if they should be included. /// </summary> IReadOnlyList<Text> Overviews { get; } /// <summary> /// When the local metadata was first created. /// </summary> DateTime CreatedAt { get; } /// <summary> /// When the local metadata was last updated with new changes from the /// remote. /// </summary> DateTime LastUpdatedAt { get; } } ================================================ FILE: Shokofin/API/Models/TMDB/ITmdbParentEntity.cs ================================================ using System.Collections.Generic; namespace Shokofin.API.Models.TMDB; public interface ITmdbParentEntity : ITmdbEntity { /// <summary> /// Original language the entity was shot in. /// /// </summary> string OriginalLanguage { get; } /// <summary> /// Indicates the entity is restricted to an age group above the legal age, /// because it's a pornography. /// </summary> bool IsRestricted { get; } /// <summary> /// Genres. /// </summary> IReadOnlyList<string> Genres { get; } /// <summary> /// Keywords. /// </summary> IReadOnlyList<string> Keywords { get; } /// <summary> /// User rating of the entity from TMDB users. /// </summary> Rating UserRating { get; } /// <summary> /// Content ratings for different countries for this entity. /// </summary> IReadOnlyList<ContentRating> ContentRatings { get; } /// <summary> /// The production countries. /// </summary> IReadOnlyDictionary<string, string> ProductionCountries { get; } /// <summary> /// The production companies (studios) that produced the entity. /// </summary> IReadOnlyList<Studio> Studios { get; } } ================================================ FILE: Shokofin/API/Models/TMDB/TmdbEpisode.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Shokofin.API.Info.TMDB; namespace Shokofin.API.Models.TMDB; /// <summary> /// APIv3 The Movie DataBase (TMDB) Episode Data Transfer Object (DTO). /// </summary> public class TmdbEpisode : ITmdbEntity { /// <summary> /// TMDB Episode ID. /// </summary> [JsonPropertyName("ID")] public int Id { get; set; } /// <summary> /// TMDB Season ID. /// </summary> [JsonPropertyName("SeasonID")] public string SeasonId { get; set; } = string.Empty; /// <summary> /// TMDB Show ID. /// </summary> [JsonPropertyName("ShowID")] public int ShowId { get; set; } /// <summary> /// The ID of the alternate ordering currently in use for the episode. /// </summary> [JsonPropertyName("AlternateOrderingID")] public string AlternateOrderingId { get; init; } = string.Empty; /// <summary> /// TVDB Episode ID, if available. /// </summary> [JsonPropertyName("TvdbEpisodeID")] public int? TvdbEpisodeId { get; set; } /// <summary> /// Preferred title based upon episode title preference. /// </summary> public string Title { get; set; } = string.Empty; /// <summary> /// All available titles for the episode, if they should be included. /// </summary> public IReadOnlyList<Title> Titles { get; set; } = []; /// <summary> /// Preferred overview based upon episode title preference. /// </summary> public string Overview { get; set; } = string.Empty; /// <summary> /// All available overviews for the episode, if they should be included. /// </summary> public IReadOnlyList<Text> Overviews { get; set; } = []; /// <summary> /// The episode number for the main ordering or alternate ordering in use. /// </summary> public int EpisodeNumber { get; set; } /// <summary> /// The season number for the main ordering or alternate ordering in use. /// </summary> public int SeasonNumber { get; set; } /// <summary> /// User rating of the episode from TMDB users. /// </summary> public Rating UserRating { get; set; } = new(); /// <summary> /// The episode run-time, if it is known. /// </summary> public TimeSpan? Runtime { get; set; } /// <summary> /// The cast that have worked on this show across all episodes and all seasons. /// </summary> public IReadOnlyList<Role> Cast { get; set; } = []; /// <summary> /// The crew that have worked on this show across all episodes and all seasons. /// </summary> public IReadOnlyList<Role> Crew { get; set; } = []; /// <summary> /// All available ordering for the episode, if they should be included. /// </summary> public IReadOnlyList<OrderingInformation> Ordering { get; init; } = []; /// <summary> /// TMDB episode to file cross-references. /// </summary> public IReadOnlyList<CrossReference> FileCrossReferences { get; set; } = []; /// <summary> /// The date the episode first aired, if it is known. /// </summary> public DateOnly? AiredAt { get; set; } /// <summary> /// When the local metadata was first created. /// </summary> public DateTime CreatedAt { get; set; } /// <summary> /// When the local metadata was last updated with new changes from the /// remote. /// </summary> public DateTime LastUpdatedAt { get; set; } string ITmdbEntity.Id => Id.ToString(); BaseItemKind ITmdbEntity.Kind => BaseItemKind.Episode; public TmdbEpisodeInfo ToInfo() => new() { TmdbShowId = ShowId.ToString(), TmdbAlternateOrderingId = AlternateOrderingId.ToString(), TmdbSeasonId = SeasonId.ToString(), TmdbEpisodeId = Id.ToString(), SeasonNumber = SeasonNumber, EpisodeNumber = EpisodeNumber, TvdbEpisodeId = TvdbEpisodeId?.ToString(), OriginalEpisodeNumber = AlternateOrderingId != ShowId.ToString() ? Ordering.First(o => o.IsDefault).EpisodeNumber : null, OriginalSeasonNumber = AlternateOrderingId != ShowId.ToString() ? Ordering.First(o => o.IsDefault).SeasonNumber : null, }; public class OrderingInformation { #if DEBUG /// <summary> /// The ordering ID. /// </summary> public string OrderingID { get; set; } = string.Empty; /// <summary> /// The alternate ordering type. Will not be set if the main ordering is /// used. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public AlternateOrderingType? OrderingType { get; set; } /// <summary> /// English name of the alternate ordering scheme. /// </summary> public string OrderingName { get; set; } = string.Empty; /// <summary> /// The season id. Will be a stringified integer for the main ordering, /// or a hex id any alternate ordering. /// </summary> public string SeasonID { get; set; } = string.Empty; /// <summary> /// English name of the season. /// </summary> public string SeasonName { get; set; } = string.Empty; #endif /// <summary> /// The season number for the ordering. /// </summary> public int SeasonNumber { get; set; } /// <summary> /// The episode number for the ordering. /// </summary> public int EpisodeNumber { get; set; } /// <summary> /// Indicates the current ordering is the default ordering for the episode. /// </summary> public bool IsDefault { get; set; } #if DEBUG /// <summary> /// Indicates the current ordering is the preferred ordering for the episode. /// </summary> public bool IsPreferred { get; set; } /// <summary> /// Indicates the current ordering is in use for the episode. /// </summary> public bool InUse { get; set; } #endif } } ================================================ FILE: Shokofin/API/Models/TMDB/TmdbEpisodeCrossReference.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models.TMDB; /// <summary> /// APIv3 The Movie DataBase (TMDB) Episode Cross-Reference Data Transfer Object (DTO). /// </summary> public class TmdbEpisodeCrossReference { /// <summary> /// AniDB Anime ID. /// </summary> [JsonPropertyName("AnidbAnimeID")] public int AnidbAnimeId { get; init; } /// <summary> /// AniDB Episode ID. /// </summary> [JsonPropertyName("AnidbEpisodeID")] public int AnidbEpisodeId { get; init; } /// <summary> /// TMDB Show ID. /// </summary> [JsonPropertyName("TmdbShowID")] public int TmdbShowId { get; init; } /// <summary> /// TMDB Episode ID. Will be <c>0</c> if the <see cref="AnidbEpisodeID"/> /// is not mapped to a TMDB Episode yet. /// </summary> [JsonPropertyName("TmdbEpisodeID")] public int TmdbEpisodeId { get; init; } /// <summary> /// The index to order the cross-references if multiple references /// exists for the same anidb or tmdb episode. /// </summary> public int Index { get; init; } /// <summary> /// The match rating. /// </summary> public string Rating { get; init; } = string.Empty; } ================================================ FILE: Shokofin/API/Models/TMDB/TmdbMovie.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Shokofin.API.Info.TMDB; namespace Shokofin.API.Models.TMDB; public class TmdbMovie : ITmdbParentEntity { /// <summary> /// TMDB Movie ID. /// </summary> [JsonPropertyName("ID")] public int Id { get; set; } /// <summary> /// TMDB Movie Collection ID, if the movie is in a movie collection on TMDB. /// </summary> [JsonPropertyName("CollectionID")] public int? CollectionId { get; set; } /// <summary> /// IMDB Movie ID, if available. /// </summary> [JsonPropertyName("ImdbMovieID")] public string? ImdbMovieId { get; set; } /// <summary> /// Preferred title based upon series title preference. /// </summary> public string Title { get; set; } = string.Empty; /// <summary> /// All available titles for the movie, if they should be included. /// </summary> public IReadOnlyList<Title> Titles { get; set; } = []; /// <summary> /// Preferred overview based upon description preference. /// </summary> public string Overview { get; set; } = string.Empty; /// <summary> /// All available overviews for the movie, if they should be included. /// </summary> public IReadOnlyList<Text> Overviews { get; set; } = []; /// <summary> /// Original language the movie was shot in. /// </summary> public string OriginalLanguage { get; set; } = string.Empty; /// <summary> /// Indicates the movie is restricted to an age group above the legal age, /// because it's a pornography. /// </summary> public bool IsRestricted { get; set; } /// <summary> /// Indicates the entry is not truly a movie, including but not limited to /// the types: /// /// - official compilations, /// - best of, /// - filmed sport events, /// - music concerts, /// - plays or stand-up show, /// - fitness video, /// - health video, /// - live movie theater events (art, music), /// - and how-to DVDs, /// /// among others. /// </summary> public bool IsVideo { get; set; } /// <summary> /// User rating of the movie from TMDB users. /// </summary> public Rating UserRating { get; set; } = new(); /// <summary> /// The movie run-time, if it is known. /// </summary> public TimeSpan? Runtime { get; set; } = null; /// <summary> /// Genres. /// </summary> public IReadOnlyList<string> Genres { get; set; } = []; /// <summary> /// Keywords. /// </summary> public IReadOnlyList<string> Keywords { get; set; } = []; /// <summary> /// Content ratings for different countries for this movie. /// </summary> public IReadOnlyList<ContentRating> ContentRatings { get; set; } = []; /// <summary> /// The production countries. /// </summary> public IReadOnlyDictionary<string, string> ProductionCountries { get; set; } = new Dictionary<string, string>(); /// <summary> /// The production companies (studios) that produced the movie. /// </summary> public IReadOnlyList<Studio> Studios { get; set; } = []; /// <summary> /// The cast that have worked on this movie. /// </summary> public IReadOnlyList<Role> Cast { get; set; } = []; /// <summary> /// The crew that have worked on this movie. /// </summary> public IReadOnlyList<Role> Crew { get; set; } = []; /// <summary> /// The yearly seasons this series belongs to. /// </summary> public List<YearlySeason> YearlySeasons { get; set; } = []; /// <summary> /// TMDB movie to file cross-references. /// </summary> public IReadOnlyList<CrossReference> FileCrossReferences { get; set; } = []; /// <summary> /// The date the movie first released, if it is known. /// </summary> public DateOnly? ReleasedAt { get; set; } /// <summary> /// When the local metadata was first created. /// </summary> public DateTime CreatedAt { get; set; } /// <summary> /// When the local metadata was last updated with new changes from the /// remote. /// </summary> public DateTime LastUpdatedAt { get; set; } string ITmdbEntity.Id => Id.ToString(); BaseItemKind ITmdbEntity.Kind => BaseItemKind.Movie; public TmdbMovieInfo ToInfo() => new() { TmdbMovieId = Id.ToString(), TmdbMovieCollectionId = CollectionId?.ToString(), }; } ================================================ FILE: Shokofin/API/Models/TMDB/TmdbMovieCollection.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; namespace Shokofin.API.Models.TMDB; public class TmdbMovieCollection : ITmdbEntity { /// <summary> /// TMDB Movie Collection ID. /// </summary> [JsonPropertyName("ID")] public int Id { get; init; } /// <summary> /// Preferred title based upon series title preference. /// </summary> public string Title { get; init; } = string.Empty; /// <summary> /// All available titles for the movie collection, if they should be included. /// </summary> public IReadOnlyList<Title> Titles { get; init; } = []; /// <summary> /// Preferred overview based upon description preference. /// </summary> public string Overview { get; init; } = string.Empty; /// <summary> /// All available overviews for the movie collection, if they should be included. /// </summary> public IReadOnlyList<Text> Overviews { get; init; } = []; public int MovieCount { get; init; } /// <summary> /// When the local metadata was first created. /// </summary> public DateTime CreatedAt { get; init; } /// <summary> /// When the local metadata was last updated with new changes from the /// remote. /// </summary> public DateTime LastUpdatedAt { get; init; } string ITmdbEntity.Id => Id.ToString(); BaseItemKind ITmdbEntity.Kind => BaseItemKind.BoxSet; } ================================================ FILE: Shokofin/API/Models/TMDB/TmdbMovieCrossReference.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models.TMDB; /// <summary> /// APIv3 The Movie DataBase (TMDB) Movie Cross-Reference Data Transfer Object (DTO). /// </summary> public class TmdbMovieCrossReference { /// <summary> /// AniDB Anime ID. /// </summary> [JsonPropertyName("AnidbAnimeID")] public int AnidbAnimeId { get; init; } /// <summary> /// AniDB Episode ID. /// </summary> [JsonPropertyName("AnidbEpisodeID")] public int AnidbEpisodeId { get; init; } /// <summary> /// TMDB Show ID. /// </summary> [JsonPropertyName("TmdbMovieID")] public int TmdbMovieId { get; init; } /// <summary> /// The match rating. /// </summary> public string Rating { get; init; } = string.Empty; } ================================================ FILE: Shokofin/API/Models/TMDB/TmdbSeason.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Shokofin.API.Info.TMDB; namespace Shokofin.API.Models.TMDB; public class TmdbSeason : ITmdbEntity { /// <summary> /// TMDB Season ID. /// </summary> [JsonPropertyName("ID")] public string Id { get; set; } = string.Empty; /// <summary> /// TMDB Show ID. /// </summary> [JsonPropertyName("ShowID")] public int ShowId { get; set; } /// <summary> /// The alternate ordering this season is associated with. Will be null /// for main series seasons. /// </summary> [JsonPropertyName("AlternateOrderingID")] public string AlternateOrderingId { get; set; } = string.Empty; /// <summary> /// Preferred title based upon episode title preference. /// </summary> public string Title { get; set; } = string.Empty; /// <summary> /// All available titles for the season, if they should be included. /// /// </summary> public IReadOnlyList<Title> Titles { get; set; } = []; /// <summary> /// Preferred overview based upon episode title preference. /// </summary> public string Overview { get; set; } = string.Empty; /// <summary> /// All available overviews for the season, if they should be included. /// </summary> public IReadOnlyList<Text> Overviews { get; set; } = []; /// <summary> /// The season number for the main ordering or alternate ordering in use. /// </summary> public int SeasonNumber { get; set; } /// <summary> /// Count of episodes associated with the season. /// </summary> public int EpisodeCount { get; set; } /// <summary> /// Indicates the alternate ordering season is locked. Will not be set if /// <seealso cref="AlternateOrderingID"/> is not set. /// </summary> public bool? IsLocked { get; set; } /// <summary> /// The yearly seasons this series belongs to. /// </summary> public List<YearlySeason> YearlySeasons { get; set; } = []; /// <summary> /// When the local metadata was first created. /// </summary> public DateTime CreatedAt { get; set; } /// <summary> /// When the local metadata was last updated with new changes from the /// remote. /// </summary> public DateTime LastUpdatedAt { get; set; } string ITmdbEntity.Id => Id; BaseItemKind ITmdbEntity.Kind => BaseItemKind.Season; public TmdbSeasonInfo ToInfo() => new() { TmdbShowId = ShowId.ToString(), TmdbAlternateOrderingId = AlternateOrderingId, TmdbSeasonId = Id, SeasonNumber = SeasonNumber, }; } ================================================ FILE: Shokofin/API/Models/TMDB/TmdbShow.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Shokofin.API.Info.TMDB; namespace Shokofin.API.Models.TMDB; public class TmdbShow : ITmdbParentEntity { /// <summary> /// TMDB Show ID. /// </summary> [JsonPropertyName("ID")] public int Id { get; set; } /// <summary> /// TvDB Show ID, if available. /// </summary> [JsonPropertyName("TvdbID")] public int? TvdbId { get; set; } /// <summary> /// The ID of the alternate ordering currently in use for the show. /// </summary> [JsonPropertyName("AlternateOrderingID")] public string AlternateOrderingId { get; init; } = string.Empty; /// <summary> /// Preferred title based upon series title preference. /// </summary> public string Title { get; set; } = string.Empty; /// <summary> /// All available titles, if they should be included. /// </summary> public IReadOnlyList<Title> Titles { get; set; } = []; /// <summary> /// Preferred overview based upon description preference. /// </summary> public string Overview { get; set; } = string.Empty; /// <summary> /// All available overviews for the series, if they should be included. /// </summary> public IReadOnlyList<Text> Overviews { get; set; } = []; /// <summary> /// Original language the show was shot in. /// </summary> public string OriginalLanguage { get; set; } = string.Empty; /// <summary> /// Indicates the show is restricted to an age group above the legal age, /// because it's a pornography. /// </summary> public bool IsRestricted { get; set; } /// <summary> /// User rating of the show from TMDB users. /// </summary> public Rating UserRating { get; set; } = new(); /// <summary> /// Genres. /// </summary> public IReadOnlyList<string> Genres { get; set; } = []; /// <summary> /// Keywords. /// </summary> public IReadOnlyList<string> Keywords { get; set; } = []; /// <summary> /// Content ratings for different countries for this show. /// </summary> public IReadOnlyList<ContentRating> ContentRatings { get; set; } = []; /// <summary> /// The production countries. /// </summary> public IReadOnlyDictionary<string, string> ProductionCountries { get; set; } = new Dictionary<string, string>(); /// <summary> /// The production companies (studios) that produced the show. /// </summary> public IReadOnlyList<Studio> Studios { get; set; } = []; /// <summary> /// Count of episodes associated with the show. /// </summary> public int EpisodeCount { get; set; } /// <summary> /// Count of seasons associated with the show. /// </summary> public int SeasonCount { get; set; } /// <summary> /// Count of locally alternate ordering schemes associated with the show. /// </summary> public int AlternateOrderingCount { get; set; } /// <summary> /// The date the first episode aired at, if it is known. /// </summary> public DateOnly? FirstAiredAt { get; set; } /// <summary> /// The date the last episode aired at, if it is known. /// </summary> public DateOnly? LastAiredAt { get; set; } /// <summary> /// When the local metadata was first created. /// </summary> public DateTime CreatedAt { get; set; } /// <summary> /// When the local metadata was last updated with new changes from the /// remote. /// </summary> public DateTime LastUpdatedAt { get; set; } string ITmdbEntity.Id => Id.ToString(); BaseItemKind ITmdbEntity.Kind => BaseItemKind.Series; public TmdbShowInfo ToInfo() => new() { TmdbShowId = Id.ToString(), TmdbAlternateOrderingId = AlternateOrderingId, TvdbShowId = TvdbId?.ToString(), }; } ================================================ FILE: Shokofin/API/Models/Tag.cs ================================================ 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 = [ "/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) ?? []) .DistinctBy(childTag => childTag.Name) .Select(childTag => new ResolvedTag(childTag, this, getChildren, FullName + "/")) .ToDictionary(childTag => childTag.Name, StringComparer.InvariantCultureIgnoreCase); RecursiveNamespacedChildren = Children.Values .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) .ToDictionary(childTag => childTag.FullName[FullName.Length..], StringComparer.InvariantCultureIgnoreCase); } } ================================================ FILE: Shokofin/API/Models/Text.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class Text { /// <summary> /// The text value. /// </summary> [JsonPropertyName("Value")] public string Value { get; set; } = string.Empty; /// <summary> /// Setter for titles. /// </summary> [JsonPropertyName("Name")] public string LegacyValue { set => Value = value; } /// <summary> /// alpha 3 language codes with custom extensions (e.g. "x-jat" for romaji, etc.). /// </summary> [JsonPropertyName("Language")] public string LanguageCode { get; set; } = "unk"; /// <summary> /// True if this is the default text value among all values for the entity. /// </summary> [JsonPropertyName("Default")] public bool IsDefault { get; set; } /// <summary> /// True if this is the preferred text value among all values for the entity. /// </summary> [JsonPropertyName("Preferred")] public bool IsPreferred { get; set; } /// <summary> /// AniDB, TMDB, AniList, etc. /// </summary> [JsonPropertyName("Source")] public string Source { get; set; } = "Unknown"; } ================================================ FILE: Shokofin/API/Models/Title.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class Title : Text { /// <summary> /// AniDB anime title type. Only available on series level titles. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public TitleType? Type { get; set; } } ================================================ FILE: Shokofin/API/Models/TitleType.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.API.Models; [JsonConverter(typeof(JsonStringEnumConverter))] public enum TitleType { None = 0, Main = 1, Official = 2, Short = 3, Synonym = 4, TitleCard = 5, KanjiReading = 6, } ================================================ FILE: Shokofin/API/Models/YearlySeason.cs ================================================ using System; using System.Text.Json.Serialization; namespace Shokofin.API.Models; public class YearlySeason : IComparable<YearlySeason>, IEquatable<YearlySeason> { /// <summary> /// The year of the season. /// </summary> [JsonPropertyName("Year")] public int Year { get; set; } /// <summary> /// The name of the season. /// </summary> [JsonPropertyName("AnimeSeason"), JsonConverter(typeof(JsonStringEnumConverter))] public YearlySeasonName Season { get; set; } public int CompareTo(YearlySeason? other) { if (other is null) return 1; var value = Year.CompareTo(other.Year); if (value == 0) value = Season.CompareTo(other.Season); return value; } public bool Equals(YearlySeason? other) => other is not null && Year == other.Year && Season == other.Season; public override bool Equals(object? obj) => Equals(obj as YearlySeason); public override int GetHashCode() => HashCode.Combine(Year, Season); } ================================================ FILE: Shokofin/API/Models/YearlySeasonName.cs ================================================ namespace Shokofin.API.Models; /// <summary> /// The name of a yearly season. /// </summary> public enum YearlySeasonName { /// <summary> /// Winter. /// </summary> Winter = 0, /// <summary> /// Spring. /// </summary> Spring = 1, /// <summary> /// Summer. /// </summary> Summer = 2, /// <summary> /// Autumn. /// </summary> Autumn = 3, /// <summary> /// Fall. This is an alias for <see cref="Autumn"/>. /// </summary> Fall = Autumn, } ================================================ FILE: Shokofin/API/ShokoApiClient.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Shokofin.API.Models; using Shokofin.API.Models.AniDB; using Shokofin.API.Models.Shoko; using Shokofin.API.Models.TMDB; using Shokofin.Configuration; using Shokofin.Extensions; using Shokofin.Utils; namespace Shokofin.API; /// <summary> /// All API calls to Shoko needs to go through this gateway. /// </summary> public class ShokoApiClient : IDisposable { private static readonly TimeSpan _requestWaitLogThreshold = TimeSpan.FromMilliseconds(50); private readonly ILogger<ShokoApiClient> _logger; private int _pageSize; private int _maxInFlightRequests; private readonly UsageTracker _tracker; private readonly HttpClient _httpClient; private SemaphoreSlim _requestLimiter; private readonly GuardedMemoryCache _cache; private bool _connectionUsable; private static bool HasPluginsExposed { get => Plugin.Instance.Configuration.HasPluginsExposed; set { Plugin.Instance.Configuration.HasPluginsExposed = value; Plugin.Instance.UpdateConfiguration(); } } public ShokoApiClient(ILogger<ShokoApiClient> logger, UsageTracker tracker) { var config = Plugin.Instance.Configuration; _logger = logger; _tracker = tracker; _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10), }; _pageSize = config.Debug.SeriesPageSize; _maxInFlightRequests = config.Debug.MaxInFlightRequests; _requestLimiter = new(_maxInFlightRequests, _maxInFlightRequests); _cache = new( logger, new() { ExpirationScanFrequency = config.Debug.ExpirationScanFrequency }, new() { AbsoluteExpirationRelativeToNow = config.Debug.AbsoluteExpirationRelativeToNow } ); _connectionUsable = Plugin.Instance.Configuration.IsConnectionUsable; Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; _tracker.Stalled += OnTrackerStalled; } ~ShokoApiClient() { _tracker.Stalled -= OnTrackerStalled; Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; } private void OnConfigurationChanged(object? sender, PluginConfiguration config) { var maxRequests = config.Debug.MaxInFlightRequests; if (maxRequests != _maxInFlightRequests) { _logger.LogInformation("Updating request limit to {MaxRequests}", maxRequests); _requestLimiter = new(maxRequests, maxRequests); _maxInFlightRequests = maxRequests; } if (config.Debug.SeriesPageSize != _pageSize) { _logger.LogInformation("Updating page size to {PageSize}", config.Debug.SeriesPageSize); _pageSize = config.Debug.SeriesPageSize; } var connectionUsable = config.IsConnectionUsable; if (_connectionUsable != connectionUsable) { _connectionUsable = connectionUsable; if (connectionUsable) { var hasPluginsExposed = Task.Run(() => CheckIfPluginsExposed()).GetAwaiter().GetResult(); if (hasPluginsExposed != HasPluginsExposed) { HasPluginsExposed = hasPluginsExposed; } } } } private void OnTrackerStalled(object? sender, EventArgs eventArgs) { if (Plugin.Instance.Configuration.Debug.AutoClearClientCache) Clear(); } public void Clear() { _logger.LogDebug("Clearing data…"); _cache.Clear(); } public void Dispose() { GC.SuppressFinalize(this); _httpClient.Dispose(); _cache.Dispose(); } #region Base Implementation private async Task<ReturnType?> GetOrNull<ReturnType>(string url, string? apiKey = null, bool skipCache = false, CancellationToken cancellationToken = default) { try { return await Get<ReturnType>(url, HttpMethod.Get, apiKey, skipCache, cancellationToken).ConfigureAwait(false); } catch (ApiException e) when (e.StatusCode == HttpStatusCode.NotFound) { return default; } } private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = false, CancellationToken cancellationToken = default) => Get<ReturnType>(url, HttpMethod.Get, apiKey, skipCache, cancellationToken); private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = false, CancellationToken cancellationToken = default) { if (skipCache) { _logger.LogTrace("Creating raw object for {Method} {URL}", method, url); var response = await Get(url, method, apiKey, cancellationToken: cancellationToken).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream, cancellationToken: cancellationToken).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoApiClient), "Unexpected null return value."); return value; } return await _cache.GetOrCreateAsync( $"apiKey={apiKey ?? "default"},method={method},body=null,url={url},object", (_) => _logger.LogTrace("Reusing object for {Method} {URL}", method, url), async () => { _logger.LogTrace("Creating cached 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(cancellationToken).ConfigureAwait(false); var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream, cancellationToken: cancellationToken).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoApiClient), "Unexpected null return value."); return value; } ).ConfigureAwait(false); } private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null, bool skipApiKey = false, CancellationToken cancellationToken = default) { // 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(); } var result = await _requestLimiter.WaitAsync(_requestWaitLogThreshold, cancellationToken).ConfigureAwait(false); if (!result) { _logger.LogTrace("Waiting for our turn to try {Method} {URL}", method, url); await _requestLimiter.WaitAsync(cancellationToken).ConfigureAwait(false); _logger.LogTrace("Got our turn to try {Method} {URL}", method, url); } cancellationToken.ThrowIfCancellationRequested(); 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 timeStart = DateTime.UtcNow; var response = await _httpClient.SendAsync(requestMessage, cancellationToken).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} for {Method} {URL} in {Elapsed}", response.StatusCode, method, url, DateTime.UtcNow - timeStart); return response; } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); throw; } finally { _requestLimiter.Release(); } } private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? apiKey = null, bool skipCache = true, CancellationToken cancellationToken = default) => Post<Type, ReturnType>(url, HttpMethod.Post, body, apiKey, skipCache, cancellationToken); private async Task<ReturnType> Post<Type, ReturnType>(string url, HttpMethod method, Type body, string? apiKey = null, bool skipCache = true, CancellationToken cancellationToken = default) { var bodyHash = Convert.ToHexString(MD5.HashData(JsonSerializer.SerializeToUtf8Bytes(body))); if (skipCache) { _logger.LogTrace("Creating raw object for {Method} {URL} ({Hash})", method, url, bodyHash); var response = await Post(url, method, body, bodyHash, apiKey, cancellationToken).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream, cancellationToken: cancellationToken).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoApiClient), "Unexpected null return value."); return value; } return await _cache.GetOrCreateAsync( $"apiKey={apiKey ?? "default"},method={method},body={bodyHash},url={url},object", (_) => _logger.LogTrace("Reusing object for {Method} {URL} ({Hash})", method, url, bodyHash), async () => { _logger.LogTrace("Creating cached object for {Method} {URL} ({Hash})", method, url, bodyHash); var response = await Post(url, method, body, bodyHash, apiKey, cancellationToken).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream, cancellationToken: cancellationToken).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoApiClient), "Unexpected null return value."); return value; } ).ConfigureAwait(false); } private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method, Type body, string? bodyHash = null, string? apiKey = null, CancellationToken cancellationToken = default) { // Use the default key if no key was provided. apiKey ??= Plugin.Instance.Configuration.ApiKey; // Compute the hash if it hasn't been pre-computed. bodyHash ??= Convert.ToHexString(MD5.HashData(JsonSerializer.SerializeToUtf8Bytes(body))); // 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(); } var result = await _requestLimiter.WaitAsync(_requestWaitLogThreshold, cancellationToken).ConfigureAwait(false); if (!result) { _logger.LogTrace("Waiting for our turn to try {Method} {URL} with body {HashCode}", method, url, bodyHash); await _requestLimiter.WaitAsync(cancellationToken).ConfigureAwait(false); _logger.LogTrace("Got our turn to try {Method} {URL} with body {HashCode}", method, url, bodyHash); } cancellationToken.ThrowIfCancellationRequested(); try { _logger.LogTrace("Trying to {Method} {URL} with body {HashCode}", method, url, bodyHash); 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(body), Encoding.UTF8, "application/json"); requestMessage.Headers.Add("apikey", apiKey); var timeStart = DateTime.UtcNow; var response = await _httpClient.SendAsync(requestMessage, cancellationToken).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} for {Method} {URL} with body {HashCode} in {Elapsed}", response.StatusCode, method, url, bodyHash, DateTime.UtcNow - timeStart); return response; } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); throw; } finally { _requestLimiter.Release(); } } #endregion Base Implementation #region Authentication 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; await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); var result = await JsonSerializer.DeserializeAsync<ApiKey>(stream).ConfigureAwait(false); return result; } #endregion #region Version 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).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.OK) { await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); var componentVersionSet = await JsonSerializer.DeserializeAsync<ComponentVersionSet>(stream).ConfigureAwait(false); return componentVersionSet?.Server; } } catch (Exception e) { _logger.LogTrace(e, "Unable to connect to Shoko Server to read the version. Exception; {e}", e.Message); return null; } return null; } public async Task<bool> CheckIfPluginsExposed(CancellationToken cancellationToken = default) => (await Get($"/api/v3/Plugin", HttpMethod.Get, cancellationToken: cancellationToken).ConfigureAwait(false)) is { StatusCode: HttpStatusCode.OK }; public async Task<string?> GetWebPrefix(CancellationToken cancellationToken = default) { try { var settingsResponse = await Get("/api/v3/Settings", HttpMethod.Get, cancellationToken: cancellationToken).ConfigureAwait(false); if (settingsResponse.StatusCode != HttpStatusCode.OK) return null; var settingsJson = await settingsResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var settings = JsonNode.Parse(settingsJson)!; var value = settings["Web"]?["WebUIPrefix"]?.GetValue<string>(); if (value is null) return "webui"; return value; } catch (HttpRequestException httpEx) when (httpEx.Message.Contains("Shoko Server")) { return null; } } #endregion #region Image public Task<HttpResponseMessage> GetImageAsync(ImageSource imageSource, ShokoImageType imageType, int imageId) => Get($"/api/v3/Image/{imageSource}/{imageType}/{imageId}", HttpMethod.Get, null, true); #endregion #region Managed Folder public async Task<ManagedFolder?> GetManagedFolder(int managedFolderId) => HasPluginsExposed ? await GetOrNull<ManagedFolder>($"/api/v3/ManagedFolder/{managedFolderId}").ConfigureAwait(false) : await GetOrNull<ManagedFolder>($"/api/v3/ImportFolder/{managedFolderId}").ConfigureAwait(false); public async Task<ListResult<File>> GetFilesInManagedFolder(int managedFolderId, string subPath, int page = 1) => HasPluginsExposed ? await GetOrNull<ListResult<File>>($"/api/v3/ManagedFolder/{managedFolderId}/File?pageSize=1000&page={page}&include=XRefs&folderPath={Uri.EscapeDataString(subPath)}").ConfigureAwait(false) ?? new() : await GetOrNull<ListResult<File>>($"/api/v3/ImportFolder/{managedFolderId}/File?pageSize=1000&page={page}&include=XRefs&folderPath={Uri.EscapeDataString(subPath)}").ConfigureAwait(false) ?? new(); #endregion #region File public async Task<File?> GetFile(string fileId) => HasPluginsExposed ? await GetOrNull<File>($"/api/v3/File/{fileId}?include=XRefs,ReleaseInfo").ConfigureAwait(false) : await GetOrNull<File>($"/api/v3/File/{fileId}?include=XRefs&includeDataFrom=AniDB").ConfigureAwait(false); public Task<File?> GetFileByEd2kAndFileSize(string ed2k, long fileSize) => GetOrNull<File>($"/api/v3/File/Hash/ED2K?hash={Uri.EscapeDataString(ed2k)}&size={fileSize}"); public async Task<IReadOnlyList<File>> GetFileByPath(string relativePath) => HasPluginsExposed ? await Get<IReadOnlyList<File>>($"/api/v3/File/PathEndsWith?path={Uri.EscapeDataString(relativePath)}&include=XRefs,ReleaseInfo&limit=10").ConfigureAwait(false) : await Get<IReadOnlyList<File>>($"/api/v3/File/PathEndsWith?path={Uri.EscapeDataString(relativePath)}&include=XRefs&includeDataFrom=AniDB&limit=10").ConfigureAwait(false); #region File User Stats 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) when (e.StatusCode is HttpStatusCode.NotFound) { // File user stats were not found. if (!e.Message.Contains("FileUserStats")) _logger.LogWarning("Unable to find user stats for a file that doesn't exist. (File={FileID})", fileId); return null; } } public Task<File.UserStats> PutFileUserStats(string fileId, File.UserStats userStats, string? apiKey = null) => 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) => await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false) is { } response && response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Accepted or HttpStatusCode.NoContent; public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) => await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={progress}", HttpMethod.Patch, apiKey).ConfigureAwait(false) is { } response && response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Accepted or HttpStatusCode.NoContent; public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long? progress, bool watched, string apiKey) => !progress.HasValue ? await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey).ConfigureAwait(false) : await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={progress.Value}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false) is { } response && response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Accepted or HttpStatusCode.NoContent; #endregion #endregion #region Shoko Episode public Task<ShokoEpisode?> GetShokoEpisode(string episodeId) => _cache.GetOrCreateAsync( $"shoko-episode:{episodeId}", (_) => _logger.LogTrace("Reusing object for Shoko episode {EpisodeId}", episodeId), async () => { _logger.LogTrace("Trying to get Shoko episode {EpisodeId}", episodeId); var timeStart = DateTime.UtcNow; var episode = await GetOrNull<ShokoEpisode>($"/api/v3/Episode/{episodeId}?includeDataFrom=AniDB&includeXRefs=true", skipCache: true).ConfigureAwait(false); _logger.LogTrace("Got Shoko episode {EpisodeId} in {Time}", episodeId, DateTime.UtcNow - timeStart); return episode; } ); public Task<IReadOnlyList<ShokoEpisode>> GetShokoEpisodesInShokoSeries(string seriesId) => _cache.GetOrCreateAsync<IReadOnlyList<ShokoEpisode>>( $"shoko-series-episodes:{seriesId}", (_) => _logger.LogTrace("Reusing object for Shoko episodes for shoko series {SeriesId}", seriesId), async () => { _logger.LogTrace("Trying to get Shoko episodes for shoko series {SeriesId}", seriesId); var timeStart = DateTime.UtcNow; var firstPage = await GetOrNull<ListResult<ShokoEpisode>>($"/api/v3/Series/{seriesId}/Episode?pageSize={_pageSize}&includeHidden=true&includeMissing=true&includeUnaired=true&includeDataFrom=AniDB&includeXRefs=true", skipCache: true).ConfigureAwait(false); if (firstPage is null) return []; var pages = new List<IReadOnlyList<ShokoEpisode>>() { firstPage.List }; if (_pageSize > 0 && firstPage.Total > _pageSize) { var totalPages = (int)Math.Ceiling((float)firstPage.Total / firstPage.List.Count); for (var page = 2; page <= totalPages; page++) { var pageData = await Get<ListResult<ShokoEpisode>>($"/api/v3/Series/{seriesId}/Episode?pageSize={_pageSize}&includeHidden=true&includeMissing=true&includeUnaired=true&includeDataFrom=AniDB&includeXRefs=true&page={page}", skipCache: true).ConfigureAwait(false); pages.Add(pageData.List); } } foreach (var page in pages) { foreach (var episode in page) { _cache.Set($"shoko-episode:{episode.Id}", episode); } } _logger.LogTrace("Got Shoko episodes for shoko series {SeriesId} in {Elapsed}", seriesId, DateTime.UtcNow - timeStart); return [..pages.SelectMany(x => x)]; } ); public async Task<IReadOnlyList<ShokoEpisode>> GetShokoEpisodesForTmdbEpisode(string tmdbEpisodeId) => await GetOrNull<IReadOnlyList<ShokoEpisode>>($"/api/v3/TMDB/Episode/{tmdbEpisodeId}/Shoko/Episode?includeDataFrom=AniDB&includeXRefs=true").ConfigureAwait(false) ?? []; public async Task<IReadOnlyList<ShokoEpisode>> GetShokoEpisodesForTmdbMovie(string tmdbMovieId) => await GetOrNull<IReadOnlyList<ShokoEpisode>>($"/api/v3/TMDB/Movie/{tmdbMovieId}/Shoko/Episode?includeDataFrom=AniDB&includeXRefs=true").ConfigureAwait(false) ?? []; public async Task<EpisodeImages?> GetImagesForShokoEpisode(string episodeId, CancellationToken cancellationToken = default) { var episodeImages = await GetOrNull<EpisodeImages>($"/api/v3/Episode/{episodeId}/Images", cancellationToken: cancellationToken).ConfigureAwait(false); if (episodeImages is null) return null; // If the episode has no 'movie' images, get the series images to compensate. if (episodeImages.Posters.Count is 0) { var episode1 = await GetShokoEpisode(episodeId).ConfigureAwait(false); var seriesImages1 = await GetImagesForShokoSeries(episode1!.IDs.ParentSeries.ToString(), cancellationToken: cancellationToken).ConfigureAwait(false) ?? new(); episodeImages.Posters = seriesImages1.Posters; episodeImages.Logos = seriesImages1.Logos; episodeImages.Banners = seriesImages1.Banners; episodeImages.Backdrops = seriesImages1.Backdrops; } return episodeImages; } #endregion #region Shoko Series public Task<IReadOnlyList<int>> GetShokoSeriesIdsForFilter(string filter, bool skipCache = true, CancellationToken cancellationToken = default) => Post<JsonDocument, IReadOnlyList<int>>($"/api/v3/Filter/Preview/Series/OnlyIDs", HttpMethod.Post, JsonDocument.Parse(filter), skipCache: skipCache, cancellationToken: cancellationToken); public Task<ShokoSeries?> GetShokoSeries(string seriesId) => GetOrNull<ShokoSeries>($"/api/v3/Series/{seriesId}?includeDataFrom=AniDB"); public Task<ShokoSeries?> GetShokoSeriesForAnidbAnime(string animeId) => GetOrNull<ShokoSeries>($"/api/v3/Series/AniDB/{animeId}/Series?includeDataFrom=AniDB"); public Task<ShokoSeries?> GetShokoSeriesForShokoEpisode(string episodeId) => GetOrNull<ShokoSeries>($"/api/v3/Episode/{episodeId}/Series?includeDataFrom=AniDB"); public async Task<IReadOnlyList<ShokoSeries>> GetShokoSeriesForDirectory(string directoryName) => await GetOrNull<IReadOnlyList<ShokoSeries>>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(directoryName)}").ConfigureAwait(false) ?? []; public async Task<IReadOnlyList<ShokoSeries>> GetShokoSeriesForTmdbMovie(string movieId) => await GetOrNull<IReadOnlyList<ShokoSeries>>($"/api/v3/TMDB/Movie/{movieId}/Shoko/Series?includeDataFrom=AniDB").ConfigureAwait(false) ?? []; public async Task<IReadOnlyList<ShokoSeries>> GetShokoSeriesForTmdbShow(string showId) => await GetOrNull<IReadOnlyList<ShokoSeries>>($"/api/v3/TMDB/Show/{showId}/Shoko/Series?includeDataFrom=AniDB").ConfigureAwait(false) ?? []; public async Task<IReadOnlyList<ShokoSeries>> GetShokoSeriesInGroup(string groupId, int filterId = 0, bool recursive = false) => await GetOrNull<IReadOnlyList<ShokoSeries>>($"/api/v3/Filter/{filterId}/Group/{groupId}/Series?recursive={recursive}&includeMissing=true&includeIgnored=false&includeDataFrom=AniDB").ConfigureAwait(false) ?? []; public async Task<IReadOnlyList<Role>> GetCastForShokoSeries(string seriesId) => await GetOrNull<IReadOnlyList<Role>>($"/api/v3/Series/{seriesId}/Cast?includeDataFrom=AniDB").ConfigureAwait(false) ?? []; public async Task<IReadOnlyList<Relation>> GetRelationsForShokoSeries(string seriesId) => await GetOrNull<IReadOnlyList<Relation>>($"/api/v3/Series/{seriesId}/Relations").ConfigureAwait(false) ?? []; public async Task<IReadOnlyList<Tag>> GetTagsForShokoSeries(string seriesId) => await GetOrNull<IReadOnlyList<Tag>>($"/api/v3/Series/{seriesId}/Tags?filter=0&excludeDescriptions=true").ConfigureAwait(false) ?? []; public Task<Images?> GetImagesForShokoSeries(string seriesId, CancellationToken cancellationToken = default) => GetOrNull<Images>($"/api/v3/Series/{seriesId}/Images", cancellationToken: cancellationToken); public async Task<IReadOnlyList<File>> GetFilesForShokoSeries(string seriesId) => HasPluginsExposed ? (await GetOrNull<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs,ReleaseInfo", skipCache: true).ConfigureAwait(false))?.List ?? [] : (await GetOrNull<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB", skipCache: true).ConfigureAwait(false))?.List ?? []; public async Task<IReadOnlyList<TmdbEpisodeCrossReference>> GetTmdbCrossReferencesForShokoSeries(string seriesId) => (await GetOrNull<ListResult<TmdbEpisodeCrossReference>>($"/api/v3/Series/{seriesId}/TMDB/Show/CrossReferences/Episode?pageSize=0").ConfigureAwait(false))?.List ?? []; #endregion #region Shoko Group public Task<ShokoGroup?> GetShokoGroup(string groupId) => GetOrNull<ShokoGroup>($"/api/v3/Group/{groupId}"); public Task<ShokoGroup?> GetShokoGroupForShokoSeries(string seriesId) => GetOrNull<ShokoGroup>($"/api/v3/Series/{seriesId}/Group"); public async Task<IReadOnlyList<ShokoGroup>> GetShokoGroupsInShokoGroup(string groupId) => await GetOrNull<IReadOnlyList<ShokoGroup>>($"/api/v3/Group/{groupId}/Group?includeEmpty=true").ConfigureAwait(false) ?? []; #endregion #region AniDB Anime public Task<ListResult<AnidbAnime>> GetAllAnidbAnime(string query = "", int page = 1, int pageSize = 100) => Get<ListResult<AnidbAnime>>($"/api/v3/Series/AniDB/Search?local=true&searchById=false&pageSize={pageSize}&page={page}&query={Uri.EscapeDataString(query)}"); #endregion #region TMDB Episode public Task<TmdbEpisode?> GetTmdbEpisode(string episodeId, bool useDefaultOrdering = false) => _cache.GetOrCreateAsync( $"tmdb-episode:{episodeId}", (_) => _logger.LogTrace("Reusing object for TMDB episode {EpisodeId}", episodeId), async () => { _logger.LogTrace("Trying to get TMDB episode {EpisodeId}", episodeId); var timeStart = DateTime.UtcNow; var episode = await GetOrNull<TmdbEpisode>($"/api/v3/TMDB/Episode/{episodeId}?include=Titles,Overviews,Cast,Crew,Ordering,FileCrossReferences{(useDefaultOrdering ? "&alternateOrderingID=default" : "")}", skipCache: true); _logger.LogTrace("Got TMDB episode {EpisodeId} in {Time}", episodeId, DateTime.UtcNow - timeStart); return episode; } ); public Task<IReadOnlyList<TmdbEpisode>> GetTmdbEpisodesInTmdbSeason(string seasonId) => _cache.GetOrCreateAsync<IReadOnlyList<TmdbEpisode>>( $"tmdb-season-episodes:{seasonId}", (_) => _logger.LogTrace("Reusing object for TMDB episodes for season {SeasonId}", seasonId), async () => { _logger.LogTrace("Trying to get TMDB episodes for season {SeasonId}", seasonId); var timeStart = DateTime.UtcNow; var firstPage = await GetOrNull<ListResult<TmdbEpisode>>($"/api/v3/TMDB/Season/{seasonId}/Episode?pageSize={_pageSize}&include=Titles,Overviews,Cast,Crew,Ordering,FileCrossReferences", skipCache: true).ConfigureAwait(false); if (firstPage is null) return []; var pages = new List<IReadOnlyList<TmdbEpisode>>() { firstPage.List }; if (_pageSize > 0 && firstPage.Total > _pageSize) { var totalPages = (int)Math.Ceiling((float)firstPage.Total / firstPage.List.Count); for (var page = 2; page <= totalPages; page++) { var pageData = await Get<ListResult<TmdbEpisode>>($"/api/v3/TMDB/Season/{seasonId}/Episode?pageSize={_pageSize}&include=Titles,Overviews,Cast,Crew,Ordering,FileCrossReferences&page={page}", skipCache: true).ConfigureAwait(false); pages.Add(pageData.List); } } foreach (var page in pages) { foreach (var episode in page) { _cache.Set($"tmdb-episode:{episode.Id}", episode); } } _logger.LogTrace("Got TMDB episodes for season {SeasonId} in {Elapsed}", seasonId, DateTime.UtcNow - timeStart); return [..pages.SelectMany(x => x)]; } ); public Task<IReadOnlyList<TmdbEpisode>> GetTmdbEpisodesInTmdbShow(string showId) => _cache.GetOrCreateAsync<IReadOnlyList<TmdbEpisode>>( $"tmdb-show-episodes:{showId}", (_) => _logger.LogTrace("Reusing object for TMDB episodes for show {ShowId}", showId), async () => { _logger.LogTrace("Trying to get TMDB episodes for show {ShowId}", showId); var timeStart = DateTime.UtcNow; var firstPage = await GetOrNull<ListResult<TmdbEpisode>>($"/api/v3/TMDB/Show/{showId}/Episode?pageSize={_pageSize}&include=Titles,Overviews,Cast,Crew,Ordering,FileCrossReferences", skipCache: true).ConfigureAwait(false); if (firstPage is null) return []; var pages = new List<IReadOnlyList<TmdbEpisode>>() { firstPage.List }; if (_pageSize > 0 && firstPage.Total > _pageSize) { var totalPages = (int)Math.Ceiling((float)firstPage.Total / firstPage.List.Count); for (var page = 2; page <= totalPages; page++) { var pageData = await Get<ListResult<TmdbEpisode>>($"/api/v3/TMDB/Show/{showId}/Episode?pageSize={_pageSize}&include=Titles,Overviews,Cast,Crew,Ordering,FileCrossReferences&page={page}", skipCache: true).ConfigureAwait(false); pages.Add(pageData.List); } } foreach (var page in pages) { foreach (var episode in page) { _cache.Set($"tmdb-episode:{episode.Id}", episode); } } _logger.LogTrace("Got TMDB episodes for show {ShowId} in {Elapsed}", showId, DateTime.UtcNow - timeStart); return [..pages.SelectMany(x => x)]; } ); public Task<EpisodeImages?> GetImagesForTmdbEpisode(string episodeId, CancellationToken cancellationToken = default) => GetOrNull<EpisodeImages>($"/api/v3/TMDB/Episode/{episodeId}/Images", cancellationToken: cancellationToken); #endregion #region TMDB Season public Task<TmdbSeason?> GetTmdbSeasonForTmdbEpisode(string episodeId) => GetOrNull<TmdbSeason>($"/api/v3/TMDB/Episode/{episodeId}/Season?include=Titles,Overviews,YearlySeasons"); public Task<TmdbSeason?> GetTmdbSeason(string seasonId) => GetOrNull<TmdbSeason>($"/api/v3/TMDB/Season/{seasonId}?include=Titles,Overviews,YearlySeasons"); public async Task<IReadOnlyList<TmdbSeason>> GetTmdbSeasonsInTmdbShow(string showId) => (await GetOrNull<ListResult<TmdbSeason>>($"/api/v3/TMDB/Show/{showId}/Season?pageSize=0&include=Titles,Overviews").ConfigureAwait(false))?.List ?? []; public Task<Images?> GetImagesForTmdbSeason(string seasonId, CancellationToken cancellationToken = default) => GetOrNull<Images>($"/api/v3/TMDB/Season/{seasonId}/Images", cancellationToken: cancellationToken); public async Task<IReadOnlyList<File>> GetFilesForTmdbSeason(string seasonId) => HasPluginsExposed ? (await GetOrNull<ListResult<File>>($"/api/v3/TMDB/Season/{seasonId}/File?pageSize=0&include=XRefs,ReleaseInfo", skipCache: true).ConfigureAwait(false))?.List ?? [] : (await GetOrNull<ListResult<File>>($"/api/v3/TMDB/Season/{seasonId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB", skipCache: true).ConfigureAwait(false))?.List ?? []; #endregion #region TMDB Show public Task<TmdbShow?> GetTmdbShowForSeason(string seasonId) => GetOrNull<TmdbShow>($"/api/v3/TMDB/Season/{seasonId}/Show?include=Titles,Overviews,Keywords,Studios,ContentRatings,ProductionCountries"); public Task<Images?> GetImagesForTmdbShow(string showId, CancellationToken cancellationToken = default) => GetOrNull<Images>($"/api/v3/TMDB/Show/{showId}/Images", cancellationToken: cancellationToken); public async Task<IReadOnlyList<TmdbEpisodeCrossReference>> GetTmdbCrossReferencesForTmdbShow(string showId) => (await GetOrNull<ListResult<TmdbEpisodeCrossReference>>($"/api/v3/TMDB/Show/{showId}/Episode/CrossReferences?pageSize=0").ConfigureAwait(false))?.List ?? []; #endregion #region TMDB Movie public Task<TmdbMovie?> GetTmdbMovie(string movieId) => GetOrNull<TmdbMovie>($"/api/v3/TMDB/Movie/{movieId}?include=Titles,Overviews,Keywords,Studios,ContentRatings,ProductionCountries,Cast,Crew,FileCrossReferences,YearlySeasons"); public async Task<IReadOnlyList<TmdbMovie>> GetTmdbMoviesInMovieCollection(string collectionId) => await GetOrNull<IReadOnlyList<TmdbMovie>>($"/api/v3/TMDB/Movie/Collection/{collectionId}/Movie?include=Titles,Overviews,Keywords,Studios,ContentRatings,ProductionCountries,Cast,Crew,FileCrossReferences,YearlySeasons").ConfigureAwait(false) ?? []; public Task<EpisodeImages?> GetImagesForTmdbMovie(string movieId, CancellationToken cancellationToken = default) => GetOrNull<EpisodeImages>($"/api/v3/TMDB/Movie/{movieId}/Images", cancellationToken: cancellationToken); public async Task<IReadOnlyList<File>> GetFilesForTmdbMovie(string movieId) => HasPluginsExposed ? (await GetOrNull<ListResult<File>>($"/api/v3/TMDB/Movie/{movieId}/File?pageSize=0&include=XRefs,ReleaseInfo", skipCache: true).ConfigureAwait(false))?.List ?? [] : (await GetOrNull<ListResult<File>>($"/api/v3/TMDB/Movie/{movieId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB", skipCache: true).ConfigureAwait(false))?.List ?? []; public async Task<IReadOnlyList<TmdbMovieCrossReference>> GetTmdbCrossReferencesForTmdbMovie(string showId) => await GetOrNull<IReadOnlyList<TmdbMovieCrossReference>>($"/api/v3/TMDB/Movie/{showId}/CrossReferences").ConfigureAwait(false) ?? []; #endregion #region TMDB Movie Collection public Task<TmdbMovieCollection?> GetTmdbMovieCollection(string collectionId) => GetOrNull<TmdbMovieCollection>($"/api/v3/TMDB/Movie/Collection/{collectionId}"); public Task<Images?> GetImagesForTmdbMovieCollection(string collectionId, CancellationToken cancellationToken = default) => GetOrNull<Images>($"/api/v3/TMDB/Movie/Collection/{collectionId}/Images", cancellationToken: cancellationToken); #endregion #region Custom Tags /// <summary> /// Gets a list of all custom tags in Shoko. /// </summary> /// <returns>A list of custom tags.</returns> public async Task<IReadOnlyList<Tag>> GetCustomTags() => (await Get<ListResult<Tag>>($"/api/v3/Tag/User?pageSize=0").ConfigureAwait(false))?.List ?? []; private const string CustomTagByIdFilter = """ { "ApplyAtSeriesLevel": true, "Expression": { "Type": "SetOverlaps", "Left": "CustomTagIDsSelector", "Parameter": [%tagIds%], } } """; public async Task<IReadOnlyList<int>> GetSeriesIdsWithCustomTag(IEnumerable<int> tagIds) => tagIds.Select(x => x.ToString()).ToList() is { Count: > 0 } tagIdList ? await GetShokoSeriesIdsForFilter(CustomTagByIdFilter.Replace("%tagIds%", $"\"{tagIdList.Join("\", \"")}\"")).ConfigureAwait(false) : []; /// <summary> /// Creates a custom tag in Shoko. /// </summary> /// <param name="name">The name of the tag.</param> /// <param name="description">The description of the tag.</param> /// <returns>The custom tag that was created.</returns> public Task<Tag> CreateCustomTag(string name, string? description = null) => Post<Dictionary<string, string?>, Tag>($"/api/v3/Tag/User", HttpMethod.Post, new() { { "name", name }, { "description", description } }); /// <summary> /// Updates an existing custom tag in Shoko. /// </summary> /// <param name="tagId">The ID of the tag to update.</param> /// <param name="name">The new name of the tag.</param> /// <param name="description">The new description of the tag.</param> /// <returns>The custom tag that was updated.</returns> public Task<Tag> UpdateCustomTag(int tagId, string? name = null, string? description = null) => Post<Dictionary<string, string?>, Tag>($"/api/v3/Tag/User/{tagId}", HttpMethod.Put, new() { { "name", name }, { "description", description } }); /// <summary> /// Removes a custom tag from Shoko. /// </summary> /// <param name="tagId">The ID of the tag to remove.</param> /// <returns><c>true</c> if the tag was removed; <c>false</c> otherwise.</returns> public async Task<bool> RemoveCustomTag(int tagId) => (await Get($"/api/v3/Tag/User/{tagId}", HttpMethod.Delete).ConfigureAwait(false)).StatusCode is HttpStatusCode.OK or HttpStatusCode.NoContent; #region Custom Tags on Series /// <summary> /// Gets a list of custom tags for a Shoko Series. /// </summary> /// <param name="seriesId">The ID of the Shoko Series.</param> /// <returns>A list of custom tags.</returns> public async Task<IReadOnlyList<Tag>> GetCustomTagsForShokoSeries(int seriesId) => await GetOrNull<IReadOnlyList<Tag>>($"/api/v3/Series/{seriesId}/Tags/User?excludeDescriptions=true", skipCache: true).ConfigureAwait(false) ?? []; /// <summary> /// Adds a custom tag to a Shoko Series. /// </summary> /// <param name="seriesId">The ID of the Shoko Series.</param> /// <param name="tagId">The ID of the custom tag to add.</param> /// <returns><c>true</c> if the tag was added; <c>false</c> otherwise.</returns> public async Task<bool> AddCustomTagToShokoSeries(int seriesId, int tagId) => (await Post($"/api/v3/Series/{seriesId}/Tags/User", HttpMethod.Post, new Dictionary<string, int[]> { { "IDs", [tagId] } }).ConfigureAwait(false)).StatusCode is HttpStatusCode.OK or HttpStatusCode.NoContent; /// <summary> /// Removes a custom tag from a Shoko Series. /// </summary> /// <param name="seriesId">The ID of the Shoko Series.</param> /// <param name="tagId">The ID of the custom tag to remove.</param> /// <returns><c>true</c> if the tag was removed; <c>false</c> otherwise.</returns> public async Task<bool> RemoveCustomTagFromShokoSeries(int seriesId, int tagId) => (await Post($"/api/v3/Series/{seriesId}/Tags/User", HttpMethod.Delete, new Dictionary<string, int[]> { { "IDs", [tagId] } }).ConfigureAwait(false)).StatusCode is HttpStatusCode.OK or HttpStatusCode.NoContent; #endregion #endregion } ================================================ FILE: Shokofin/API/ShokoApiManager.cs ================================================ 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.Info.AniDB; using Shokofin.API.Info.Shoko; using Shokofin.API.Info.TMDB; using Shokofin.API.Models; using Shokofin.API.Models.Shoko; using Shokofin.API.Models.TMDB; using Shokofin.Configuration; using Shokofin.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; using ContentRating = Shokofin.Utils.ContentRating; using Path = System.IO.Path; using Regex = System.Text.RegularExpressions.Regex; using RegexOptions = System.Text.RegularExpressions.RegexOptions; namespace Shokofin.API; public partial class ShokoApiManager : IDisposable { // Note: This regex will only get uglier with time. [System.Text.RegularExpressions.GeneratedRegex(@"\s+\((?<year>\d{4})(?: dai [2-9] (?:bu|cour))?\)\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase, "en-US")] private static partial Regex YearRegex(); private readonly ILogger<ShokoApiManager> Logger; private readonly ShokoApiClient ApiClient; private readonly ILibraryManager LibraryManager; private readonly UsageTracker UsageTracker; private readonly GuardedMemoryCache DataCache; private readonly object MediaFolderListLock = new(); private readonly List<Folder> MediaFolderList = []; private readonly ConcurrentDictionary<string, string> PathToSeasonIdDictionary = new(); private readonly ConcurrentDictionary<string, List<string>> PathToEpisodeIdsDictionary = new(); private readonly ConcurrentDictionary<string, (string FileId, string SeriesId)> PathToFileIdAndSeriesIdDictionary = new(); private readonly ConcurrentDictionary<string, string> SeasonIdToShowIdDictionary = new(); private readonly ConcurrentDictionary<string, string> EpisodeIdToSeasonIdDictionary = new(); private readonly ConcurrentDictionary<string, List<string>> FileAndSeasonIdToEpisodeIdDictionary = new(); public ShokoApiManager(ILogger<ShokoApiManager> logger, ShokoApiClient apiClient, ILibraryManager libraryManager, UsageTracker usageTracker) { Logger = logger; ApiClient = apiClient; LibraryManager = libraryManager; UsageTracker = usageTracker; DataCache = new( logger, new() { ExpirationScanFrequency = Plugin.Instance.Configuration.Debug.ExpirationScanFrequency }, new() { AbsoluteExpirationRelativeToNow = Plugin.Instance.Configuration.Debug.AbsoluteExpirationRelativeToNow } ); UsageTracker.Stalled += OnTrackerStalled; } ~ShokoApiManager() { UsageTracker.Stalled -= OnTrackerStalled; } private void OnTrackerStalled(object? sender, EventArgs eventArgs) { if (Plugin.Instance.Configuration.Debug.AutoClearManagerCache) 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…"); EpisodeIdToSeasonIdDictionary.Clear(); FileAndSeasonIdToEpisodeIdDictionary.Clear(); lock (MediaFolderListLock) MediaFolderList.Clear(); PathToEpisodeIdsDictionary.Clear(); PathToFileIdAndSeriesIdDictionary.Clear(); PathToSeasonIdDictionary.Clear(); SeasonIdToShowIdDictionary.Clear(); DataCache.Clear(); Logger.LogDebug("Cleanup complete."); } #endregion #region Series Settings internal Task<SeriesConfiguration> GetInternalSeriesConfiguration(string id) => DataCache.GetOrCreateAsync($"series-settings-raw:{id}", async () => { var tags = await GetNamespacedTagsForSeries(id).ConfigureAwait(false); var seriesSettings = new SeriesConfiguration() { Type = SeriesType.None, StructureType = SeriesStructureType.None, SeasonOrdering = Ordering.OrderType.None, SpecialsPlacement = Ordering.SpecialOrderType.None, SeasonMergingBehavior = SeasonMergingBehavior.None, EpisodeConversion = SeriesEpisodeConversion.None, OrderByAirdate = false, }; if (tags.TryGetValue("/custom user tags/series type", out var seriesTypeTag) && seriesTypeTag.Children.Count is >= 1 && Enum.TryParse<SeriesType>(NormalizeCustomSeriesType(seriesTypeTag.Children.Keys.First()), ignoreCase: true, out var seriesType) && seriesType is not SeriesType.None ) seriesSettings.Type = seriesType; if (!tags.TryGetValue("/custom user tags/shokofin", out var customTags)) return seriesSettings; tags = customTags.RecursiveNamespacedChildren; if (tags.ContainsKey("/structure/anidb")) seriesSettings.StructureType = SeriesStructureType.AniDB_Anime; else if (tags.ContainsKey("/structure/shoko")) seriesSettings.StructureType = SeriesStructureType.Shoko_Groups; else if (tags.ContainsKey("/structure/tmdb")) seriesSettings.StructureType = SeriesStructureType.TMDB_SeriesAndMovies; if (tags.ContainsKey("/season ordering/default")) seriesSettings.SeasonOrdering = Ordering.OrderType.Default; else if (tags.ContainsKey("/season ordering/release")) seriesSettings.SeasonOrdering = Ordering.OrderType.ReleaseDate; else if (tags.ContainsKey("/season ordering/chronological")) seriesSettings.SeasonOrdering = Ordering.OrderType.Chronological; else if (tags.ContainsKey("/season ordering/simplified chronological")) seriesSettings.SeasonOrdering = Ordering.OrderType.ChronologicalIgnoreIndirect; if (tags.ContainsKey("/specials placement/excluded")) seriesSettings.SpecialsPlacement = Ordering.SpecialOrderType.Excluded; else if (tags.ContainsKey("/specials placement/after season")) seriesSettings.SpecialsPlacement = Ordering.SpecialOrderType.AfterSeason; else if (tags.ContainsKey("/specials placement/mixed")) seriesSettings.SpecialsPlacement = Ordering.SpecialOrderType.InBetweenSeasonMixed; else if (tags.ContainsKey("/specials placement/air date")) seriesSettings.SpecialsPlacement = Ordering.SpecialOrderType.InBetweenSeasonByAirDate; else if (tags.ContainsKey("/specials placement/tmdb")) seriesSettings.SpecialsPlacement = Ordering.SpecialOrderType.InBetweenSeasonByOtherData; if (tags.ContainsKey("/merge/none")) { seriesSettings.SeasonMergingBehavior = SeasonMergingBehavior.NoMerge; } else { if (tags.ContainsKey("/merge/forward")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeForward; if (tags.ContainsKey("/merge/backward")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeBackward; if (tags.ContainsKey("/merge/main story")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeWithMainStory; if (tags.ContainsKey("/merge/group/a/source")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupASource; else if (tags.ContainsKey("/merge/group/a/target")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupATarget; if (tags.ContainsKey("/merge/group/b/source")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupBSource; else if (tags.ContainsKey("/merge/group/b/target")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupBTarget; if (tags.ContainsKey("/merge/group/c/source")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupCSource; else if (tags.ContainsKey("/merge/group/c/target")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupCTarget; if (tags.ContainsKey("/merge/group/d/source")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupDSource; else if (tags.ContainsKey("/merge/group/d/target")) seriesSettings.SeasonMergingBehavior |= SeasonMergingBehavior.MergeGroupDTarget; } if (tags.ContainsKey("/episodes as specials")) seriesSettings.EpisodeConversion = SeriesEpisodeConversion.EpisodesAsSpecials; else if (tags.ContainsKey("/specials as episodes")) seriesSettings.EpisodeConversion = SeriesEpisodeConversion.SpecialsAsEpisodes; else if (tags.ContainsKey("/specials as extra featurettes")) seriesSettings.EpisodeConversion = SeriesEpisodeConversion.SpecialsAsExtraFeaturettes; if (tags.ContainsKey("/order by airdate")) seriesSettings.OrderByAirdate = true; return seriesSettings; }); private Task<SeriesConfiguration> GetSeriesConfiguration(string id) => DataCache.GetOrCreateAsync($"series-configuration:{id}", async () => { var seriesSettings = await GetInternalSeriesConfiguration(id).ConfigureAwait(false); var config = Plugin.Instance.Configuration; if (seriesSettings.Type is SeriesType.None) { var series = await ApiClient.GetShokoSeries(id).ConfigureAwait(false); seriesSettings.Type = series?.AniDB.Type ?? SeriesType.Other; } if (seriesSettings.StructureType is SeriesStructureType.None) { seriesSettings.StructureType = config.DefaultLibraryStructure; } if (seriesSettings.SeasonOrdering is Ordering.OrderType.None) { seriesSettings.SeasonOrdering = config.DefaultSeasonOrdering; } if (seriesSettings.SpecialsPlacement is Ordering.SpecialOrderType.None) { seriesSettings.SpecialsPlacement = config.DefaultSpecialsPlacement; } if (seriesSettings.SeasonMergingBehavior is SeasonMergingBehavior.None) { seriesSettings.SeasonMergingBehavior = config.SeasonMerging_DefaultBehavior; } if (config.MovieSpecialsAsExtraFeaturettes && seriesSettings.Type is SeriesType.Movie) { seriesSettings.EpisodeConversion = SeriesEpisodeConversion.SpecialsAsExtraFeaturettes; } return seriesSettings; }); private static string NormalizeCustomSeriesType(string seriesType) { seriesType = seriesType.ToLowerInvariant().Replace(" ", ""); if (seriesType[^1] == 's') seriesType = seriesType[..^1]; return seriesType; } #endregion #region Tags, Genres, And Content Ratings public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries(string seriesId) => DataCache.GetOrCreateAsync<IReadOnlyDictionary<string, ResolvedTag>>( $"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.GetTagsForShokoSeries(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 = []; // 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 = []; // 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 = []; 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, StringComparer.InvariantCultureIgnoreCase); // 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, StringComparer.InvariantCultureIgnoreCase); nsTag.RecursiveNamespacedChildren = nsTag.Children.Values .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) .ToDictionary(childTag => childTag.FullName[nsTag.FullName.Length..], StringComparer.InvariantCultureIgnoreCase); } return allResolvedTags; } ); private async Task<string[]> GetTagsForSeries(string seriesId) { var tags = await GetNamespacedTagsForSeries(seriesId).ConfigureAwait(false); return TagFilter.FilterTags(tags); } private async Task<string[]> GetGenresForSeries(string seriesId) { var tags = await GetNamespacedTagsForSeries(seriesId).ConfigureAwait(false); return TagFilter.FilterGenres(tags); } private async Task<string[]> GetProductionLocations(string seriesId) { var tags = await GetNamespacedTagsForSeries(seriesId).ConfigureAwait(false); return TagFilter.GetProductionCountriesFromTags(tags); } private async Task<string?> GetAssumedContentRating(string seriesId) { var tags = await GetNamespacedTagsForSeries(seriesId).ConfigureAwait(false); return ContentRating.GetTagBasedContentRating(tags); } #endregion #region Path Set And Local Episode IDs /// <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 Task<HashSet<string>> GetPathSetForSeries(string seriesId) => DataCache.GetOrCreateAsync( $"series-path-set:${seriesId}", async () => { var pathSet = new HashSet<string>(); foreach (var file in await ApiClient.GetFilesForShokoSeries(seriesId).ConfigureAwait(false)) { if (file.CrossReferences.Count == 1 && file.CrossReferences[0] is { } xref && xref.Series.Shoko.HasValue && xref.Series.Shoko.ToString() == seriesId) foreach (var fileLocation in file.Locations) pathSet.Add((Path.GetDirectoryName(fileLocation.RelativePath) ?? string.Empty) + Path.DirectorySeparatorChar); } return pathSet; } ); /// <summary> /// Get a set of local episode ids for the series. /// </summary> /// <param name="seasonInfo">Season info.</param> /// <returns>Local episode ids for the series</returns> public Task<HashSet<string>> GetLocalEpisodeIdsForSeason(SeasonInfo seasonInfo) => DataCache.GetOrCreateAsync( $"season-episode-ids:${seasonInfo.Id}", async () => { var episodeIds = new HashSet<string>(); foreach (var seasonId in new HashSet<string>([seasonInfo.Id, ..seasonInfo.ExtraIds])) { switch (seasonId[0]) { case IdPrefix.TmdbShow: { var files = await ApiClient.GetFilesForTmdbSeason(seasonId[1..]).ConfigureAwait(false); var episodes = await ApiClient.GetTmdbEpisodesInTmdbSeason(seasonId[1..]).ConfigureAwait(false); foreach (var episode in episodes) { if (files.Any(file => file.CrossReferences.Any(fileXref => fileXref.Episodes.Any(episodeXref => episodeXref.TMDB.Episode.Contains(episode.Id))))) episodeIds.Add(IdPrefix.TmdbShow + episode.Id.ToString()); } break; } case IdPrefix.TmdbMovie: { var files = await ApiClient.GetFilesForTmdbMovie(seasonId[1..]).ConfigureAwait(false); if (files.Count > 0) episodeIds.Add(seasonId); break; } case IdPrefix.TmdbMovieCollection: { var movies = (await ApiClient.GetTmdbMoviesInMovieCollection(seasonId[1..]).ConfigureAwait(false)) .Select(m => m.Id) .ToHashSet(); foreach (var episodeInfo in seasonInfo.EpisodeList) { var episodeFiles = await ApiClient.GetFilesForTmdbMovie(episodeInfo.Id[1..]).ConfigureAwait(false); var movieId = int.Parse(episodeInfo.Id[1..]); foreach (var file in episodeFiles) { if (file.CrossReferences.FirstOrDefault(x => x.Series.Shoko.HasValue && x.Episodes.Any(e => e.Shoko.HasValue && e.TMDB.Movie.Contains(movieId))) is not { } xref) continue; episodeIds.Add(IdPrefix.TmdbMovie + movieId.ToString()); } } break; } default: { var files = await ApiClient.GetFilesForShokoSeries(seasonId).ConfigureAwait(false); foreach (var file in files) { var xref = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.HasValue && xref.Series.Shoko.ToString() == seasonId); foreach (var episodeXRef in xref?.Episodes.Where(e => e.Shoko.HasValue) ?? []) episodeIds.Add(episodeXRef.Shoko!.Value.ToString()); } break; } } } return 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]); } public async Task<(FileInfo?, SeasonInfo?, ShowInfo?)> GetFileInfoByPath(string path) { // Use pointer for fast lookup. if (PathToFileIdAndSeriesIdDictionary.TryGetValue(path, out (string FileId, string SeriesId) tuple)) { var (fI, sI) = tuple; var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); if (fileInfo == null || fileInfo.EpisodeList.Count is 0) return (null, null, null); var selectedSeasonId = fileInfo.EpisodeList[0].Episode.SeasonId; var seasonInfo = await GetSeasonInfo(selectedSeasonId).ConfigureAwait(false); if (seasonInfo == null) return (null, null, null); var showInfo = await GetShowInfoBySeasonId(selectedSeasonId).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(ProviderNames.ShokoSeries, out var sI) || !int.TryParse(sI, out _)) return (null, null, null); if (!fileName.TryGetAttributeValue(ProviderNames.ShokoFile, out var fI) || !int.TryParse(fI, out _)) return (null, null, null); var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); if (fileInfo == null || fileInfo.EpisodeList.Count is 0) return (null, null, null); var selectedSeasonId = fileInfo.EpisodeList[0].Episode.SeasonId; var seasonInfo = await GetSeasonInfo(selectedSeasonId).ConfigureAwait(false); if (seasonInfo == null) return (null, null, null); var showInfo = await GetShowInfoBySeasonId(selectedSeasonId).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 is { Count: > 0 } ? result[0] : null; 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 pathSet = await GetPathSetForSeries(seriesId).ConfigureAwait(false); if (!pathSet.Contains(selectedPath)) continue; // Find the file info for the series. var fileInfo = await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); if (fileInfo.EpisodeList.Count is 0) return (null, null, null); var seasonId = fileInfo.EpisodeList[0].Episode.SeasonId; var seasonInfo = await GetSeasonInfo(seasonId).ConfigureAwait(false); if (seasonInfo == null) return (null, null, null); var showInfo = await GetShowInfoBySeasonId(seasonId).ConfigureAwait(false); if (showInfo == null) return (null, null, null); // 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; if (await ApiClient.GetFile(fileId).ConfigureAwait(false) is not { } file) return null; return await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); } private static readonly EpisodeType[] EpisodePickOrder = [EpisodeType.Special, EpisodeType.Episode, 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 seriesConfig = await GetSeriesConfiguration(seriesId).ConfigureAwait(false); 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(); if (await ApiClient.GetShokoEpisode(episodeId).ConfigureAwait(false) is not { } episode) { Logger.LogDebug("Skipped unknown episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); continue; } if (episode.IsHidden) { Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); continue; } if (seriesConfig.StructureType is SeriesStructureType.TMDB_SeriesAndMovies) { var tmdbEpisodes = await Task.WhenAll(episodeXRef.TMDB.Episode.Select(id => GetEpisodeInfo(IdPrefix.TmdbShow + id.ToString()))).ConfigureAwait(false); foreach (var tmdbEpisode in tmdbEpisodes) { if (tmdbEpisode == null) continue; episodeList.Add((tmdbEpisode, episodeXRef, tmdbEpisode.Id)); } var tmdbMovies = await Task.WhenAll(episodeXRef.TMDB.Movie.Select(id => GetEpisodeInfo(IdPrefix.TmdbMovie + id.ToString()))).ConfigureAwait(false); foreach (var tmdbMovie in tmdbMovies) { if (tmdbMovie == null) continue; episodeList.Add((tmdbMovie, episodeXRef, tmdbMovie.Id)); } continue; } 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})"); episodeList.Add((episodeInfo, episodeXRef, episodeId)); } // Distinct the list in case the shoko episodes are linked to the same tmdb episode(s)/movie(s). if (seriesConfig.StructureType is SeriesStructureType.TMDB_SeriesAndMovies) { episodeList = [.. episodeList.DistinctBy(tuple => tuple.Id)]; } // Group and order the episodes, then select the first group to use. var groupedEpisodeLists = episodeList .GroupBy(tuple => (type: tuple.Episode.Type, group: tuple.CrossReference.Percentage.Group, isStandalone: tuple.Episode.IsStandalone)) .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key.type)) .ThenBy(a => a.Key.group) .ThenByDescending(a => a.Key.isStandalone) .Select(epList => epList.OrderBy(tuple => tuple.Episode.SeasonNumber).ThenBy(tuple => tuple.Episode.EpisodeNumber).ToList() as IReadOnlyList<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)> ?? []) .ToList(); var selectedEpisodeList = groupedEpisodeLists.FirstOrDefault() ?? []; var fileInfo = new FileInfo(file, seriesId, selectedEpisodeList); FileAndSeasonIdToEpisodeIdDictionary[$"{fileId}:{seriesId}"] = [.. episodeList.Select(episode => episode.Id)]; return fileInfo; } ); public bool TryGetFileAndSeriesIdForPath(string path, [NotNullWhen(true)] out string? fileId, [NotNullWhen(true)] out string? seriesId) { if (string.IsNullOrEmpty(path)) { fileId = null; seriesId = null; return false; } // Fast path; using the lookup. if (PathToFileIdAndSeriesIdDictionary.TryGetValue(path, out var pair)) { fileId = pair.FileId; seriesId = pair.SeriesId; return true; } // Slow path; getting the show from cache or remote and finding the default season's id. Logger.LogDebug("Trying to find file id using the slow path. (Path={FullPath})", path); try { if (Task.Run(() => GetFileInfoByPath(path)).GetAwaiter().GetResult() is { } tuple && tuple.Item1 is not null) { var (fileInfo, _, _) = tuple; fileId = fileInfo.Id; seriesId = fileInfo.SeriesId; return true; } } catch (Exception ex) { Logger.LogError(ex, "Encountered an error while trying to lookup the file id for path. (Path={Path})", path); } fileId = null; seriesId = null; return false; } #endregion #region Episode Info public async Task<EpisodeInfo?> GetEpisodeInfo(string episodeId) { if (string.IsNullOrEmpty(episodeId)) return null; if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var episodeInfo)) return episodeInfo; switch (episodeId[0]) { case IdPrefix.TmdbShow: if (await ApiClient.GetTmdbEpisode(episodeId[1..]).ConfigureAwait(false) is not { } tmdbEpisode) return null; if (await ApiClient.GetTmdbShowForSeason(tmdbEpisode.SeasonId).ConfigureAwait(false) is not { } tmdbShow) return null; return await CreateEpisodeInfo(tmdbEpisode, tmdbShow).ConfigureAwait(false); case IdPrefix.TmdbMovie: if (await ApiClient.GetTmdbMovie(episodeId[1..]).ConfigureAwait(false) is not { } tmdbMovie) return null; return await CreateEpisodeInfo(tmdbMovie).ConfigureAwait(false); default: if (await ApiClient.GetShokoEpisode(episodeId).ConfigureAwait(false) is not { } shokoEpisode) return null; return await CreateEpisodeInfo(shokoEpisode).ConfigureAwait(false); } } private Task<EpisodeInfo> CreateEpisodeInfo(TmdbMovie movie) => DataCache.GetOrCreateAsync( $"episode:{IdPrefix.TmdbMovie}{movie.Id}", async () => { Logger.LogTrace("Creating info object for episode {EpisodeName}. (Source=TMDB,Movie={MovieId})", movie.Title, movie.Id); var episodeList = await ApiClient.GetShokoEpisodesForTmdbMovie(movie.Id.ToString()).ConfigureAwait(false); var anidbEpisodes = episodeList .Select(shokoEpisode => shokoEpisode.AniDB.ToInfo()) .ToArray(); var shokoEpisodes = episodeList .Select(shokoEpisode => shokoEpisode.ToInfo()) .ToArray(); return new EpisodeInfo(ApiClient, movie, shokoEpisodes, anidbEpisodes); } ); private Task<EpisodeInfo> CreateEpisodeInfo(TmdbEpisode episode, TmdbShow show) => DataCache.GetOrCreateAsync( $"episode:{IdPrefix.TmdbShow}{episode.Id}", async () => { Logger.LogTrace("Creating info object for episode {EpisodeName}. (Source=TMDB,Episode={EpisodeId})", episode.Title, episode.Id); var episodeList = await ApiClient.GetShokoEpisodesForTmdbEpisode(episode.Id.ToString()).ConfigureAwait(false); var anidbEpisodes = episodeList .Select(shokoEpisode => shokoEpisode.AniDB.ToInfo()) .ToArray(); var shokoEpisodes = episodeList .Select(shokoEpisode => shokoEpisode.ToInfo()) .ToArray(); return new EpisodeInfo(ApiClient, episode, show, shokoEpisodes, anidbEpisodes); } ); private Task<EpisodeInfo> CreateEpisodeInfo(ShokoEpisode episode) => DataCache.GetOrCreateAsync( $"episode:{episode.Id}", async () => { Logger.LogTrace("Creating info object for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", episode.Name, episode.Id); var (cast, genres, tags, productionLocations, contentRating) = await GetExtraEpisodeDetailsForShokoSeries(episode.IDs.ParentSeries.ToString()).ConfigureAwait(false); ITmdbEntity? tmdbEntity = null; ITmdbParentEntity? tmdbParentEntity = null; var tmdbMovies = new List<TmdbMovieInfo>(); var tmdbEpisodes = new List<TmdbEpisodeInfo>(); foreach (var tmdbMovieId in episode.IDs.TMDB.Movie) { Logger.LogTrace("Trying to find TMDB movie {MovieId} for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", tmdbMovieId, episode.Name, episode.Id); if (await ApiClient.GetTmdbMovie(tmdbMovieId.ToString()).ConfigureAwait(false) is not { } tmdbMovie) { Logger.LogTrace("Did not find TMDB movie {MovieId} for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", tmdbMovieId, episode.Name, episode.Id); continue; } tmdbMovies.Add(tmdbMovie.ToInfo()); tmdbEntity ??= tmdbMovie; Logger.LogTrace("Found TMDB movie {MovieId} for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", tmdbMovieId, episode.Name, episode.Id); } foreach (var tmdbEpisodeId in episode.IDs.TMDB.Episode) { Logger.LogTrace("Trying to find TMDB episode {EpisodeId} for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", tmdbEpisodeId, episode.Name, episode.Id); if (await ApiClient.GetTmdbEpisode(tmdbEpisodeId.ToString(), useDefaultOrdering: true).ConfigureAwait(false) is not { } tmdbEpisode) { Logger.LogTrace("Did not find TMDB episode {EpisodeId} for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", tmdbEpisodeId, episode.Name, episode.Id); continue; } tmdbEpisodes.Add(tmdbEpisode.ToInfo()); tmdbEntity ??= tmdbEpisode; Logger.LogTrace("Found TMDB episode {EpisodeId} for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", tmdbEpisodeId, episode.Name, episode.Id); if (await ApiClient.GetTmdbShowForSeason(tmdbEpisode.SeasonId).ConfigureAwait(false) is { } tmdbShow) { tmdbParentEntity = tmdbShow; Logger.LogTrace("Found TMDB show {ShowId} for episode {EpisodeName}. (Source=Shoko,Episode={EpisodeId})", tmdbShow.Id, episode.Name, episode.Id); } } return new EpisodeInfo(ApiClient, episode, cast, [.. genres], [.. tags], productionLocations, contentRating, [.. tmdbMovies], [.. tmdbEpisodes], tmdbEntity, tmdbParentEntity); } ); private Task<(IReadOnlyList<Role>, string[], string[], string[], string?)> GetExtraEpisodeDetailsForShokoSeries(string seriesId) => DataCache.GetOrCreateAsync( $"series-episode-details:{seriesId}", async () => { var cast = await ApiClient.GetCastForShokoSeries(seriesId).ConfigureAwait(false); var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); var productionLocations = await GetProductionLocations(seriesId).ConfigureAwait(false); var contentRating = await GetAssumedContentRating(seriesId).ConfigureAwait(false); return (cast, genres, tags, productionLocations, contentRating); } ); #endregion #region Episode Id Helpers public bool TryGetEpisodeIdsForPath(string path, [NotNullWhen(true)] out List<string>? episodeIds) { if (string.IsNullOrEmpty(path)) { episodeIds = null; return false; } // Fast path; using the lookup. if (PathToEpisodeIdsDictionary.TryGetValue(path, out episodeIds)) return true; // Slow path; getting the show from cache or remote and finding the default season's id. Logger.LogDebug("Trying to find episode ids using the slow path. (Path={FullPath})", path); try { if (Task.Run(() => GetFileInfoByPath(path)).GetAwaiter().GetResult() is { } tuple && tuple.Item1 is not null) { var (fileInfo, _, _) = tuple; episodeIds = [.. fileInfo.EpisodeList.Select(episodeInfo => episodeInfo.Id)]; return episodeIds.Count is > 0; } } catch (Exception ex) { Logger.LogError(ex, "Encountered an error while trying to lookup the episode id for path. (Path={Path})", path); } episodeIds = null; return false; } public bool TryGetEpisodeIdsForFileId(string fileId, string seriesId, [NotNullWhen(true)] out List<string>? episodeIds) { if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId)) { episodeIds = null; return false; } // Fast path; using the lookup. if (FileAndSeasonIdToEpisodeIdDictionary.TryGetValue($"{fileId}:{seriesId}", out episodeIds)) return true; Logger.LogDebug("Trying to find episode ids using the slow path. (Series={SeriesId},File={FileId})", seriesId, fileId); try { // Slow path; getting the show from cache or remote and finding the default season's id. if (Task.Run(() => GetFileInfo(fileId, seriesId)).GetAwaiter().GetResult() is { } fileInfo) { episodeIds = [.. fileInfo.EpisodeList.Select(episodeInfo => episodeInfo.Id)]; return true; } } catch (Exception ex) { Logger.LogError(ex, "Encountered an error while trying to lookup the episode ids for file and series ids. (Series={SeriesId},File={FileId})", fileId, seriesId); } episodeIds = null; return false; } #endregion #region Season Info public async Task<SeasonInfo?> GetSeasonInfo(string seasonId) { if (string.IsNullOrEmpty(seasonId)) return null; if (DataCache.TryGetValue<SeasonInfo>($"season:{seasonId}", out var seasonInfo)) return seasonInfo; switch (seasonId[0]) { case IdPrefix.TmdbShow: if (await ApiClient.GetTmdbSeason(seasonId[1..]).ConfigureAwait(false) is not { } tmdbSeason) return null; if (await ApiClient.GetTmdbShowForSeason(tmdbSeason.Id).ConfigureAwait(false) is not { } tmdbShow) return null; return await CreateSeasonInfo(tmdbSeason, tmdbShow).ConfigureAwait(false); case IdPrefix.TmdbMovie: if (await ApiClient.GetTmdbMovie(seasonId[1..]).ConfigureAwait(false) is not { } tmdbMovie) return null; return await CreateSeasonInfo(tmdbMovie).ConfigureAwait(false); case IdPrefix.TmdbMovieCollection: if (await ApiClient.GetTmdbMovieCollection(seasonId[1..]).ConfigureAwait(false) is not { } tmdbMovieCollection) return null; return await CreateSeasonInfo(tmdbMovieCollection).ConfigureAwait(false); default: if (await ApiClient.GetShokoSeries(seasonId).ConfigureAwait(false) is not { } shokoSeries) return null; return await CreateSeasonInfo(shokoSeries).ConfigureAwait(false); } } public async Task<SeasonInfo?> GetSeasonInfoByPath(string path) { if (!PathToSeasonIdDictionary.TryGetValue(path, out var seasonId)) { seasonId = await GetSeasonIdForPath(path).ConfigureAwait(false); if (string.IsNullOrEmpty(seasonId)) return null; } return await GetSeasonInfo(seasonId).ConfigureAwait(false); } public async Task<SeasonInfo?> GetSeasonInfoForEpisode(string episodeId) { if (string.IsNullOrEmpty(episodeId)) return null; if (EpisodeIdToSeasonIdDictionary.TryGetValue(episodeId, out var seasonId)) return await GetSeasonInfo(seasonId).ConfigureAwait(false); switch (episodeId[0]) { case IdPrefix.TmdbShow: if (await ApiClient.GetTmdbSeasonForTmdbEpisode(episodeId[1..]).ConfigureAwait(false) is not { } tmdbSeason) return null; if (await ApiClient.GetTmdbShowForSeason(tmdbSeason.Id).ConfigureAwait(false) is not { } tmdbShow) return null; return await CreateSeasonInfo(tmdbSeason, tmdbShow).ConfigureAwait(false); case IdPrefix.TmdbMovie: if (await ApiClient.GetTmdbMovie(episodeId[1..]).ConfigureAwait(false) is not { } tmdbMovie) return null; var episodeInfo = await CreateEpisodeInfo(tmdbMovie).ConfigureAwait(false); return await GetSeasonInfo(episodeInfo.SeasonId).ConfigureAwait(false); default: if (await ApiClient.GetShokoSeriesForShokoEpisode(episodeId).ConfigureAwait(false) is not { } shokoSeries) return null; return await CreateSeasonInfo(shokoSeries).ConfigureAwait(false); } } public Task<IReadOnlyList<SeasonInfo>> GetSeasonInfosForShokoSeries(string seriesId) => DataCache.GetOrCreateAsync<IReadOnlyList<SeasonInfo>>( $"seasons-by-series-id:{seriesId}", (seasons) => Logger.LogTrace("Reusing info objects for seasons. (Series={SeriesId})", seriesId), async () => { Logger.LogTrace("Creating info objects for seasons for series {SeriesName}. (Series={SeriesId})", seriesId, seriesId); if (await ApiClient.GetShokoSeries(seriesId).ConfigureAwait(false) is not { } series) return []; var seriesConfig = await GetSeriesConfiguration(seriesId).ConfigureAwait(false); if (seriesConfig.StructureType is SeriesStructureType.TMDB_SeriesAndMovies) { var seasons = new List<SeasonInfo>(); var episodeXrefs = await ApiClient.GetTmdbCrossReferencesForShokoSeries(seriesId).ConfigureAwait(false); var showIds = episodeXrefs .GroupBy(x => x.TmdbShowId) .OrderByDescending(x => x.Count()) .Select(x => x.Key) .Except([0]) .ToList(); foreach (var showId in showIds) { var episodes = (await ApiClient.GetTmdbEpisodesInTmdbShow(showId.ToString()).ConfigureAwait(false)).ToDictionary(e => e.Id); var seasonIds = episodeXrefs .Where(x => x.TmdbShowId == showId) .GroupBy(x => episodes.TryGetValue(x.TmdbEpisodeId, out var e) ? e.SeasonId : string.Empty) .OrderByDescending(x => x.Count()) .Select(x => x.Key) .Except([string.Empty]) .ToList(); foreach (var seasonId in seasonIds) { if (await GetSeasonInfo(IdPrefix.TmdbShow + seasonId).ConfigureAwait(false) is not { } seasonInfo) continue; seasons.Add(seasonInfo); } } foreach (var movieId in series.IDs.TMDB.Movie) { if (await GetSeasonInfo(IdPrefix.TmdbMovie + movieId.ToString()).ConfigureAwait(false) is not { } seasonInfo) continue; seasons.Add(seasonInfo); } return seasons; } else { if (await GetSeasonInfo(seriesId).ConfigureAwait(false) is not { } seasonInfo) return []; return [seasonInfo]; } } ); private Task<SeasonInfo> CreateSeasonInfo(TmdbMovie tmdbMovie) => DataCache.GetOrCreateAsync( $"season:{IdPrefix.TmdbMovie}{tmdbMovie.Id}", (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeasonTitle}. (Source=TMDB,Movie={MovieId})", seasonInfo.Title, tmdbMovie.Id), async () => { Logger.LogTrace("Creating info object for season {SeasonTitle}. (Source=TMDB,Movie={MovieId})", tmdbMovie.Title, tmdbMovie.Id); var episodeInfo = await CreateEpisodeInfo(tmdbMovie).ConfigureAwait(false); var animeIds = (await ApiClient.GetTmdbCrossReferencesForTmdbMovie(tmdbMovie.Id.ToString()).ConfigureAwait(false)) .GroupBy(x => x.AnidbAnimeId) .OrderByDescending(x => x.Count()) .Select(x => x.Key) .Except([0]) .ToList(); var (topLevelShokoGroupId, anidbAnime, shokoSeries) = await GetGroupIdsForAnidbAnime(animeIds, $"TMDB movie \"{tmdbMovie.Title}\"", $"Movie=\"{tmdbMovie.Id}\"").ConfigureAwait(false); return new SeasonInfo(ApiClient, tmdbMovie, episodeInfo, topLevelShokoGroupId, anidbAnime, shokoSeries); }); private Task<SeasonInfo> CreateSeasonInfo(TmdbMovieCollection tmdbMovieCollection) => DataCache.GetOrCreateAsync( $"season:{IdPrefix.TmdbMovieCollection}{tmdbMovieCollection.Id}", (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeasonTitle}. (Source=TMDB,MovieCollection={MovieId})", seasonInfo.Title, tmdbMovieCollection.Id), async () => { Logger.LogTrace("Creating info object for season {SeasonTitle}. (Source=TMDB,MovieCollection={MovieId})", tmdbMovieCollection.Title, tmdbMovieCollection.Id); var moviesInCollection = await ApiClient.GetTmdbMoviesInMovieCollection(tmdbMovieCollection.Id.ToString()).ConfigureAwait(false); var episodeInfos = await Task.WhenAll(moviesInCollection.Select(tmdbMovie => CreateEpisodeInfo(tmdbMovie))).ConfigureAwait(false); var animeIds = (await Task.WhenAll(moviesInCollection.Select(tmdbMovie => ApiClient.GetTmdbCrossReferencesForTmdbMovie(tmdbMovie.Id.ToString()))).ConfigureAwait(false)) .SelectMany(x => x) .GroupBy(x => x.AnidbAnimeId) .OrderByDescending(x => x.Count()) .Select(x => x.Key) .Except([0]) .ToList(); var (topLevelShokoGroupId, anidbAnime, shokoSeries) = await GetGroupIdsForAnidbAnime(animeIds, $"TMDB movie collection \"{tmdbMovieCollection.Title}\"", $"MovieCollection=\"{tmdbMovieCollection.Id}\"").ConfigureAwait(false); return new SeasonInfo(ApiClient, tmdbMovieCollection, moviesInCollection, episodeInfos, topLevelShokoGroupId, anidbAnime, shokoSeries); }); private Task<SeasonInfo> CreateSeasonInfo(TmdbSeason tmdbSeason, TmdbShow tmdbShow) => DataCache.GetOrCreateAsync( $"season:{IdPrefix.TmdbShow}{tmdbSeason.Id}", (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeasonTitle}. (Source=TMDB,Season={SeasonId},Show={ShowId})", seasonInfo.Title, tmdbSeason.Id, tmdbSeason.ShowId), async () => { Logger.LogTrace("Creating info object for season {SeasonTitle}. (Source=TMDB,Season={SeasonId},Show={ShowId})", tmdbSeason.Title, tmdbSeason.Id, tmdbSeason.ShowId); var tmdbEpisodes = (await ApiClient.GetTmdbEpisodesInTmdbSeason(tmdbSeason.Id).ConfigureAwait(false)) .ToDictionary(e => e.Id); var episodeInfos = await Task.WhenAll(tmdbEpisodes.Values.Select(tmdbEpisode => CreateEpisodeInfo(tmdbEpisode, tmdbShow))).ConfigureAwait(false); var animeIds = (await ApiClient.GetTmdbCrossReferencesForTmdbShow(tmdbSeason.ShowId.ToString()).ConfigureAwait(false)) .Where(x => tmdbEpisodes.TryGetValue(x.TmdbEpisodeId, out var tmdbEpisode) && tmdbEpisode.SeasonId == tmdbSeason.Id) .GroupBy(x => x.AnidbAnimeId) .OrderByDescending(x => x.Count()) .Select(x => x.Key) .Except([0]) .ToList(); var (topLevelShokoGroupId, anidbAnime, shokoSeries) = await GetGroupIdsForAnidbAnime(animeIds, $"season {tmdbSeason.SeasonNumber} in TMDB show \"{tmdbShow.Title}\"", $"Season=\"{tmdbSeason.Id}\",Show=\"{tmdbSeason.ShowId}\"").ConfigureAwait(false); return new SeasonInfo(ApiClient, tmdbSeason, tmdbShow, episodeInfos, topLevelShokoGroupId, anidbAnime, shokoSeries); }); #pragma warning disable CA2254 // Template should be a static method private async Task<(string? topLevelShokoGroupId, AnidbAnimeInfo[] anidbAnime, ShokoSeriesInfo[] shokoSeries)> GetGroupIdsForAnidbAnime(IReadOnlyList<int> animeIds, string entryName, string entryId) { Logger.LogTrace($"Found {{AnidbAnimeCount}} AniDB anime for {entryName} to pick a Shoko Group to use. (Anime={{AnimeIds}},{entryId})", animeIds.Count, animeIds); if (animeIds.Count is 0) return (null, [], []); string? topLevelShokoGroupId = null; var anidbAnimeList = new List<AnidbAnimeInfo>(); var shokoSeriesList = new List<ShokoSeriesInfo>(); foreach (var animeId in animeIds) { if (await ApiClient.GetShokoSeriesForAnidbAnime(animeId.ToString()).ConfigureAwait(false) is not { } shokoSeries) continue; anidbAnimeList.Add(new() { AnidbAnimeId = animeId.ToString(), }); shokoSeriesList.Add(new() { ShokoSeriesId = shokoSeries.Id, ShokoGroupId = shokoSeries.IDs.ParentGroup.ToString(), TopLevelShokoGroupId = shokoSeries.IDs.TopLevelGroup.ToString(), }); Logger.LogTrace($"Found Shoko series to use for {entryName}. (Anime={{AnimeId}},Series={{SeriesId}},Group={{GroupId}},{entryId})", animeId, shokoSeries.Id, shokoSeries.IDs.ParentGroup.ToString()); } var topLevelGroupIdCount = shokoSeriesList .GroupBy(x => x.TopLevelShokoGroupId) .OrderByDescending(x => x.Count()) .Select(x => x.Key) .ToList(); if (topLevelGroupIdCount.Count is 1) { topLevelShokoGroupId = topLevelGroupIdCount[0]; Logger.LogDebug($"Multiple Shoko groups in the same top-level groups linked to {entryName}. (Anime={{AnimeIds}},TopLevelGroup={{TopLevelGroupId}},{entryId})", animeIds, topLevelShokoGroupId); } else if (shokoSeriesList.Count is > 0) { Logger.LogDebug($"Multiple Shoko groups in multiple top-level groups linked to {entryName}. (Anime={{AnimeIds}},{entryId})", animeIds); } else { Logger.LogDebug($"Could not find Shoko group for {entryName}. ({entryId})"); } return (topLevelShokoGroupId, [..anidbAnimeList], [..shokoSeriesList]); } #pragma warning restore CA2254 // Template should be a static method private async Task<SeasonInfo> CreateSeasonInfo(ShokoSeries series) { var (primaryId, extraIds) = await GetSeriesIdsForSeason(series).ConfigureAwait(false); return await DataCache.GetOrCreateAsync( $"season:{primaryId}", (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeasonTitle}. (Source=Shoko,Series={SeriesId},ExtraSeries={ExtraIds})", seasonInfo.Title, primaryId, extraIds), 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.Id, primaryId, StringComparison.Ordinal)) series = await ApiClient.GetShokoSeries(primaryId).ConfigureAwait(false) ?? throw new InvalidOperationException("Could not find series with id " + primaryId); Logger.LogTrace("Creating info object for season {SeasonTitle}. (Source=Shoko,Series={SeriesId},ExtraSeries={ExtraIds})", series.Name, primaryId, extraIds); var episodes = (await Task.WhenAll( extraIds.Prepend(primaryId) .Select(id => ApiClient.GetShokoEpisodesInShokoSeries(id) .ContinueWith(task => Task.WhenAll(task.Result.Select(CreateEpisodeInfo))) .Unwrap() ) ).ConfigureAwait(false)) .SelectMany(list => list) .ToList(); ITmdbEntity? tmdbEntity = null; List<TmdbSeasonInfo> tmdbSeasons = []; if (series.IDs.TMDB.Show.Count > 0 || series.IDs.TMDB.Movie.Count > 0) { if (series.IDs.TMDB.Show.Count > 0) { Logger.LogTrace("Found {TmdbShowCount} TMDB shows for Shoko Series {SeriesTitle} to pick a season to use. (Series={SeriesId})", series.IDs.TMDB.Show.Count, series.Name, primaryId); var episodeXrefs = await ApiClient.GetTmdbCrossReferencesForShokoSeries(primaryId).ConfigureAwait(false); var showIds = episodeXrefs .GroupBy(x => x.TmdbShowId) .OrderByDescending(x => x.Count()) .Select(x => x.Key) .Except([0]) .ToList(); foreach (var showId in showIds) { var tmdbEpisodes = (await ApiClient.GetTmdbEpisodesInTmdbShow(showId.ToString()).ConfigureAwait(false)).ToDictionary(e => e.Id); var seasonIds = episodeXrefs .Where(x => x.TmdbShowId == showId) .GroupBy(x => tmdbEpisodes.TryGetValue(x.TmdbEpisodeId, out var e) ? e.SeasonId : string.Empty) .OrderByDescending(x => x.Count()) .ExceptBy([string.Empty], x => x.Key) .ToDictionary(x => x.Key, x => x.Count()); Logger.LogTrace("Found {TmdbSeasonCount} TMDB seasons to potentially use. (Series={SeriesId},Show={ShowId})", seasonIds.Count, primaryId, showId); var fullyMatchedSeasons = 0; foreach (var (seasonId, matchedEpisodeCount) in seasonIds) { if (await ApiClient.GetTmdbSeason(seasonId).ConfigureAwait(false) is { } tmdbSeason0) { if (tmdbSeason0.SeasonNumber is 0) { Logger.LogTrace("Found season zero for Shoko Series {SeriesTitle}. Skipping season match. (Series={SeriesId},Season={SeasonId},Show={ShowId})", series.Name, primaryId, tmdbSeason0.Id, tmdbSeason0.ShowId); continue; } tmdbSeasons.Add(tmdbSeason0.ToInfo()); tmdbEntity ??= tmdbSeason0; Logger.LogTrace("Found TMDB season {TmdbSeasonTitle} for Shoko Series {SeriesTitle}. (Series={SeriesId},Season={SeasonId},Show={ShowId})", tmdbSeason0.Title, series.Name, primaryId, tmdbSeason0.Id, tmdbSeason0.ShowId); // If the Shoko Series is fully matched to more than one TMDB season that's not season zero, then switch to using the show instead. if (tmdbSeason0.EpisodeCount == matchedEpisodeCount) { fullyMatchedSeasons++; } } } if (tmdbEntity is TmdbSeason tmdbSeason1 && tmdbSeason1.ShowId == showId && fullyMatchedSeasons > 1) { if (await ApiClient.GetTmdbShowForSeason(tmdbSeason1.Id).ConfigureAwait(false) is { } tmdbShow) { tmdbEntity = tmdbShow; Logger.LogTrace("Found multiple TMDB seasons for Shoko Series {SeriesTitle}, so switched to show {ShowName} instead. (Series={SeriesId})", series.Name, tmdbShow.Title, primaryId); } } } } if (tmdbEntity is null && series.IDs.TMDB.Movie.Count > 0) { Logger.LogTrace("Found {TmdbMovieCount} TMDB movies for Shoko Series {SeriesTitle} to pick a movie collection to use. (Series={SeriesId})", series.IDs.TMDB.Movie.Count, series.Name, primaryId); var collectionIds = new List<int>(); foreach (var movieId in series.IDs.TMDB.Movie) { if (await ApiClient.GetTmdbMovie(movieId.ToString()).ConfigureAwait(false) is not { } tmdbMovie || !tmdbMovie.CollectionId.HasValue) continue; collectionIds.Add(tmdbMovie.CollectionId.Value); } collectionIds = [.. collectionIds .GroupBy(x => x) .OrderByDescending(x => x.Count()) .Select(x => x.Key)]; foreach (var collectionId in collectionIds) { if (await ApiClient.GetTmdbMovieCollection(collectionId.ToString()).ConfigureAwait(false) is { } tmdbCollection) { tmdbEntity = tmdbCollection; Logger.LogTrace("Found TMDB movie collection {TmdbCollectionTitle} for Shoko Series {SeriesTitle}. (Series={SeriesId},Collection={CollectionId})", tmdbCollection.Title, series.Name, primaryId, tmdbCollection.Id); break; } } } if (tmdbEntity is null) Logger.LogTrace("Could not find TMDB entity to use for Shoko Series {SeriesTitle}. (Series={SeriesId})", series.Name, primaryId); } SeasonInfo seasonInfo; if (extraIds.Count > 0) { var detailsIds = extraIds.Prepend(primaryId).ToList(); // Create the tasks. var relationsTasks = detailsIds.Select(id => ApiClient.GetRelationsForShokoSeries(id)); var seriesConfigurationsTasks = detailsIds.Select(id => GetSeriesConfiguration(id)); // Await the tasks in order. var relations = (await Task.WhenAll(relationsTasks).ConfigureAwait(false)) .SelectMany(r => r) .Where(r => r.RelatedIDs.Shoko.HasValue && !detailsIds.Contains(r.RelatedIDs.Shoko.Value.ToString())) .ToList(); var seriesConfigurations = (await Task.WhenAll(seriesConfigurationsTasks).ConfigureAwait(false)) .Select((t, i) => (t, i)) .ToDictionary(t => detailsIds[t.i], (t) => t.t); // Create the season info using the merged details. seasonInfo = new SeasonInfo(ApiClient, series, extraIds, episodes, relations, tmdbEntity, seriesConfigurations, [.. tmdbSeasons]); } else { var relations = await ApiClient.GetRelationsForShokoSeries(primaryId).ConfigureAwait(false); var seriesConfigurations = new Dictionary<string, SeriesConfiguration>() { { primaryId, await GetSeriesConfiguration(primaryId).ConfigureAwait(false) }, }; seasonInfo = new SeasonInfo(ApiClient, series, extraIds, episodes, relations, tmdbEntity, seriesConfigurations, [.. tmdbSeasons]); } foreach (var episode in episodes) EpisodeIdToSeasonIdDictionary.TryAdd(episode.Id, primaryId); return seasonInfo; } ).ConfigureAwait(false); } #endregion #region Series Merging public async Task<(string primaryId, List<string> extraIds)> GetSeriesIdsForShokoSeries(string seriesId) { if (await ApiClient.GetShokoSeries(seriesId).ConfigureAwait(false) is not { } shokoSeries) return (seriesId, []); return await GetSeriesIdsForSeason(shokoSeries).ConfigureAwait(false); } private Task<(string primaryId, List<string> extraIds)> GetSeriesIdsForSeason(ShokoSeries series) => DataCache.GetOrCreateAsync( $"season-series-ids:{series.Id}", (tuple) => { var config = Plugin.Instance.Configuration; if (!config.SeasonMerging_Enabled) return; Logger.LogTrace("Reusing existing series-to-season mapping for series. (Series={SeriesId},ExtraSeries={ExtraIds})", tuple.primaryId, tuple.extraIds); }, async () => { var primaryId = series.Id; var extraIds = new List<string>(); var config = Plugin.Instance.Configuration; if (!config.SeasonMerging_Enabled) return (primaryId, extraIds); Logger.LogTrace("Creating new series-to-season mapping for series. (Series={SeriesId})", primaryId); var seriesConfig = await GetSeriesConfiguration(series.Id).ConfigureAwait(false); if (seriesConfig.SeasonMergingBehavior is SeasonMergingBehavior.NoMerge) return (primaryId, extraIds); if (seriesConfig.StructureType is not SeriesStructureType.Shoko_Groups) return (primaryId, extraIds); if (seriesConfig.SeasonMergingBehavior is SeasonMergingBehavior.None && !config.SeasonMerging_SeriesTypes.Contains(seriesConfig.Type)) return (primaryId, extraIds); if (series.AniDB.AirDate is null) return (primaryId, extraIds); // We potentially have a "follow-up" season candidate, so look for the "primary" season candidate, then jump into that. var relations = await ApiClient.GetRelationsForShokoSeries(primaryId).ConfigureAwait(false); var mainTitle = series.AniDB.Titles.First(title => title.Type == TitleType.Main).Value; var maxDaysThreshold = config.SeasonMerging_MergeWindowInDays; var adjustedMainTitle = AdjustMainTitle(mainTitle) ?? mainTitle; var currentSeries = series; var currentDate = currentSeries.AniDB.AirDate.Value; var currentRelations = relations; var currentConfig = seriesConfig; var groupId = currentSeries.IDs.ParentGroup; while (currentRelations.Count > 0) { foreach ( var prequelRelation in currentRelations .Where(relation => relation.Type is RelationType.Prequel or RelationType.MainStory && relation.RelatedIDs.Shoko.HasValue) .OrderBy(relation => relation.Type is RelationType.Prequel) .ThenBy(relation => relation.Type) .ThenBy(relation => relation.RelatedIDs.AniDB) ) { if (await ApiClient.GetShokoSeries(prequelRelation.RelatedIDs.Shoko!.Value.ToString()).ConfigureAwait(false) is not { } prequelSeries) continue; if (prequelSeries.IDs.ParentGroup != groupId) continue; var prequelConfig = await GetSeriesConfiguration(prequelSeries.Id).ConfigureAwait(false); if (prequelConfig.SeasonMergingBehavior is SeasonMergingBehavior.NoMerge) continue; if (prequelConfig.StructureType is not SeriesStructureType.Shoko_Groups) continue; if (prequelConfig.SeasonMergingBehavior is SeasonMergingBehavior.None && !config.SeasonMerging_SeriesTypes.Contains(prequelConfig.Type)) continue; if (prequelSeries.AniDB.AirDate is not { } prequelDate) continue; var mergeOverride = ( prequelRelation.Type is RelationType.Prequel && (currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeBackward) || prequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeForward)) ) || ( prequelRelation.Type is RelationType.MainStory && currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeWithMainStory) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupASource) && prequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupATarget) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupBSource) && prequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupBTarget) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupCSource) && prequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupCTarget) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupDSource) && prequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupDTarget) ); if (!mergeOverride) { if (prequelRelation.Type is RelationType.Prequel && prequelDate > currentDate) continue; if (maxDaysThreshold > 0) { var deltaDays = (int)Math.Floor((currentDate - prequelDate).TotalDays); if (deltaDays > maxDaysThreshold) continue; } } var prequelMainTitle = prequelSeries.AniDB.Titles.First(title => title.Type == TitleType.Main).Value; var adjustedPrequelMainTitle = AdjustMainTitle(prequelMainTitle); if (mergeOverride) { adjustedMainTitle = adjustedPrequelMainTitle ?? prequelMainTitle; currentSeries = prequelSeries; currentDate = prequelDate; currentRelations = await ApiClient.GetRelationsForShokoSeries(prequelSeries.Id).ConfigureAwait(false); currentConfig = prequelConfig; goto continuePrequelWhileLoop; } // We only want to merge main/side stories if the override is set. if (prequelRelation.Type is RelationType.MainStory) continue; if (string.IsNullOrEmpty(adjustedPrequelMainTitle)) { if (string.Equals(adjustedMainTitle, prequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { currentSeries = prequelSeries; currentDate = prequelDate; currentRelations = await ApiClient.GetRelationsForShokoSeries(prequelSeries.Id).ConfigureAwait(false); currentConfig = prequelConfig; goto breakPrequelWhileLoop; } continue; } if (string.Equals(adjustedMainTitle, adjustedPrequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { currentSeries = prequelSeries; currentDate = prequelDate; currentRelations = await ApiClient.GetRelationsForShokoSeries(prequelSeries.Id).ConfigureAwait(false); currentConfig = prequelConfig; goto continuePrequelWhileLoop; } } breakPrequelWhileLoop: break; continuePrequelWhileLoop: continue; } // If an earlier candidate was found, use its IDs instead. We re-run the method to // allow it to cache the IDs once for the forward search and re-use them across all // other seasons that perform a backward search. if (currentSeries != series) { (primaryId, extraIds) = await GetSeriesIdsForSeason(currentSeries).ConfigureAwait(false); // I don't want to duplicate the logging here and to use an else branch with // more indention for the while loop, so using goto instead. goto logAndReturn; } var storyStack = new Stack<(string adjustedMainTitle, DateTime currentDate, SeriesConfiguration currentConfig, IReadOnlyList<Relation> currentRelations, int relationOffset)>([ (adjustedMainTitle, currentDate, currentConfig, currentRelations, 0) ]); while (storyStack.Count > 0) { (adjustedMainTitle, currentDate, currentConfig, currentRelations, var relationOffset) = storyStack.Pop(); while (currentRelations.Count > 0) { foreach ( var sequelRelation in currentRelations .Where(relation => relation.Type is RelationType.Sequel or RelationType.SideStory && relation.RelatedIDs.Shoko.HasValue) .OrderBy(relation => relation.Type is RelationType.Sequel) .ThenBy(relation => relation.Type) .ThenBy(relation => relation.RelatedIDs.AniDB) .Skip(relationOffset) ) { relationOffset++; if (await ApiClient.GetShokoSeries(sequelRelation.RelatedIDs.Shoko!.Value.ToString()).ConfigureAwait(false) is not { } sequelSeries) continue; if (sequelSeries.IDs.ParentGroup != groupId) continue; var sequelConfig = await GetSeriesConfiguration(sequelSeries.Id).ConfigureAwait(false); if (sequelConfig.SeasonMergingBehavior is SeasonMergingBehavior.NoMerge) continue; if (sequelConfig.StructureType is not SeriesStructureType.Shoko_Groups) continue; if (sequelConfig.SeasonMergingBehavior is SeasonMergingBehavior.None && !config.SeasonMerging_SeriesTypes.Contains(sequelConfig.Type)) continue; if (sequelSeries.AniDB.AirDate is not { } sequelDate) continue; // Fix for older servers with mismatching relations between the series causing weird behavior. if (sequelRelation.Type is RelationType.SideStory) { var sequelRelations = await ApiClient.GetRelationsForShokoSeries(sequelSeries.Id).ConfigureAwait(false); sequelRelations = sequelRelations .Where(relation => relation.Type is RelationType.MainStory && relation.RelatedIDs.Shoko.HasValue) .OrderBy(relation => relation.RelatedIDs.AniDB) .ToList(); if (sequelRelations.Count == 0 || sequelRelations[0].RelatedIDs.AniDB != sequelRelation.IDs.AniDB) continue; } var mergeOverride = ( sequelRelation.Type is RelationType.Sequel && (currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeForward) || sequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeBackward)) ) || ( sequelRelation.Type is RelationType.SideStory && sequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeWithMainStory) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupATarget) && sequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupATarget) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupBTarget) && sequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupBTarget) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupCTarget) && sequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupCTarget) ) || ( currentConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupDTarget) && sequelConfig.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupDTarget) ); if (!mergeOverride) { if (sequelRelation.Type is RelationType.Sequel && sequelDate < currentDate) continue; if (maxDaysThreshold > 0) { var deltaDays = (int)Math.Floor((sequelDate - currentDate).TotalDays); if (deltaDays > maxDaysThreshold) continue; } } var sequelMainTitle = sequelSeries.AniDB.Titles.First(title => title.Type == TitleType.Main).Value; var adjustedSequelMainTitle = AdjustMainTitle(sequelMainTitle); if (mergeOverride) { // If we're about to enter a side tangent, so push the main story on the stack at the next relation index. if (sequelRelation.Type is not RelationType.Sequel) storyStack.Push((adjustedMainTitle, currentDate, currentConfig, currentRelations, relationOffset)); // Re-focus on the sequel when overriding. adjustedMainTitle = adjustedSequelMainTitle ?? sequelMainTitle; extraIds.Add(sequelSeries.Id); currentDate = sequelDate; currentRelations = await ApiClient.GetRelationsForShokoSeries(sequelSeries.Id).ConfigureAwait(false); currentConfig = sequelConfig; goto continueSequelWhileLoop; } // We only want to merge main/side stories if the override is set. if (sequelRelation.Type is RelationType.SideStory) continue; if (string.IsNullOrEmpty(adjustedSequelMainTitle)) continue; if (string.Equals(adjustedMainTitle, adjustedSequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { extraIds.Add(sequelSeries.Id); currentDate = sequelDate; currentRelations = await ApiClient.GetRelationsForShokoSeries(sequelSeries.Id).ConfigureAwait(false); currentConfig = sequelConfig; goto continueSequelWhileLoop; } } break; continueSequelWhileLoop: continue; } } logAndReturn: Logger.LogTrace("Created new series-to-season mapping for series. (Series={SeriesId},Primary={PrimaryId},ExtraSeries={ExtraIds})", series.Id, primaryId, extraIds); return (primaryId, extraIds); } ); private string? AdjustMainTitle(string title) => YearRegex().Match(title) is { Success: true } result ? title[..^result.Length] : null; #endregion #region Season Id Helpers public bool TryGetSeasonIdForPath(string path, [NotNullWhen(true)] out string? seasonId) { if (string.IsNullOrEmpty(path)) { seasonId = null; return false; } // Fast path; using the lookup. if (PathToSeasonIdDictionary.TryGetValue(path, out seasonId)) return true; // Slow path; getting the show from cache or remote and finding the season's series id. Logger.LogDebug("Trying to find the season's series id for {Path} using the slow path.", path); try { if (Task.Run(() => GetSeasonInfoByPath(path)).GetAwaiter().GetResult() is { } seasonInfo) { seasonId = seasonInfo.Id; return true; } } catch (Exception ex) { Logger.LogError(ex, "Encountered an error while trying to lookup the season id for path. (Path={Path})", path); } seasonId = null; return false; } public bool TryGetSeasonIdForEpisodeId(string episodeId, [NotNullWhen(true)] out string? seasonId) { if (string.IsNullOrEmpty(episodeId)) { seasonId = null; return false; } // Fast path; using the lookup. if (EpisodeIdToSeasonIdDictionary.TryGetValue(episodeId, out seasonId)) return true; // Slow path; asking the http client to get the series from remote to look up it's id. Logger.LogDebug("Trying to find episode ids using the slow path. (Episode={EpisodeId})", episodeId); try { switch (episodeId[0]) { case IdPrefix.TmdbShow: if (Task.Run(() => ApiClient.GetTmdbSeasonForTmdbEpisode(episodeId[1..])).GetAwaiter().GetResult() is not { } tmdbSeason) { seasonId = null; return false; } seasonId = IdPrefix.TmdbShow + tmdbSeason.Id; return true; case IdPrefix.TmdbMovie: seasonId = episodeId; return true; default: if (Task.Run(() => ApiClient.GetShokoSeriesForShokoEpisode(episodeId)).GetAwaiter().GetResult() is not { } series) { seasonId = null; return false; } seasonId = series.Id; return true; } } catch (Exception ex) { Logger.LogError(ex, "Encountered an error while trying to lookup the season id for episode id. (Episode={EpisodeId})", episodeId); } seasonId = null; return false; } [System.Text.RegularExpressions.GeneratedRegex(@"Season (?<seasonNumber>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] private static partial Regex SeasonNameRegex(); private async Task<string?> GetSeasonIdForPath(string path) { // Reuse cached value. if (PathToSeasonIdDictionary.TryGetValue(path, out var seasonId)) return seasonId; // Fast-path for VFS. if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { var fileName = Path.GetFileName(path); var seasonNumberResult = SeasonNameRegex().Match(fileName); if (seasonNumberResult.Success) fileName = Path.GetFileName(Path.GetDirectoryName(path)!); if (!fileName.TryGetAttributeValue(ProviderNames.ShokoSeries, out seasonId)) return null; if (seasonNumberResult.Success) { var seasonNumber = int.Parse(seasonNumberResult.Groups["seasonNumber"].Value); var showInfo = await GetShowInfoBySeasonId(seasonId).ConfigureAwait(false); if (showInfo == null) return null; var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); if (seasonInfo == null) return null; seasonId = seasonInfo.Id; } PathToSeasonIdDictionary[path] = seasonId; return seasonId; } var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for shoko series matching path {Path}", partialPath); var result = await ApiClient.GetShokoSeriesForDirectory(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) { if (await GetSeasonInfosForShokoSeries(series.Id).ConfigureAwait(false) is not { Count: > 0 } seasonInfoList) continue; var seasonInfo = seasonInfoList[0]; var pathSet = await GetPathSetForSeries(series.Id).ConfigureAwait(false); foreach (var uniquePath in pathSet) { // Remove the trailing slash before matching. if (!uniquePath[..^1].EndsWith(partialPath)) goto continueForeach; PathToSeasonIdDictionary[path] = seasonInfo.Id; } return seasonInfo.Id; continueForeach: continue; } // In the edge case for series with only files with multiple // cross-references we just return the first match. if (result.Count > 0) { if (await GetSeasonInfosForShokoSeries(result[0].Id).ConfigureAwait(false) is not { Count: > 0 } seasonInfoList) return null; return seasonInfoList[0].Id; } return null; } #endregion #region Show Info public async Task<ShowInfo?> GetShowInfoByPath(string path) { if (!PathToSeasonIdDictionary.TryGetValue(path, out var seasonId)) { seasonId = await GetSeasonIdForPath(path).ConfigureAwait(false); if (string.IsNullOrEmpty(seasonId)) return null; } return await GetShowInfoBySeasonId(seasonId).ConfigureAwait(false); } public async Task<IReadOnlyList<ShowInfo>> GetShowInfosForShokoSeries(string seriesId) { if (await GetSeasonInfosForShokoSeries(seriesId).ConfigureAwait(false) is not { Count: > 0 } seasonInfoList) return []; var showInfoList = await Task.WhenAll(seasonInfoList.Select(seasonInfo => GetShowInfoBySeasonId(seasonInfo.Id))).ConfigureAwait(false); return showInfoList .WhereNotNull() .DistinctBy(showInfo => showInfo.Id) .ToList(); } public async Task<ShowInfo?> GetShowInfoBySeasonId(string seasonId) { if (string.IsNullOrEmpty(seasonId)) return null; if (seasonId[0] is IdPrefix.TmdbShow) { if (await ApiClient.GetTmdbShowForSeason(seasonId[1..]).ConfigureAwait(false) is not { } tmdbShow) return null; return await CreateShowInfo(tmdbShow).ConfigureAwait(false); } else if (seasonId[0] is IdPrefix.TmdbMovie) { if (await ApiClient.GetTmdbMovie(seasonId[1..]).ConfigureAwait(false) is not { } tmdbMovie) return null; if (!tmdbMovie.CollectionId.HasValue) return await CreateShowInfoForTmdbMovie(tmdbMovie).ConfigureAwait(false); if (await ApiClient.GetTmdbMovieCollection(tmdbMovie.CollectionId.Value.ToString()).ConfigureAwait(false) is not { } tmdbMovieCollection) return await CreateShowInfoForTmdbMovie(tmdbMovie).ConfigureAwait(false); return await CreateShowInfoForTmdbMovieCollection(tmdbMovieCollection).ConfigureAwait(false); } else if (seasonId[0] is IdPrefix.TmdbMovieCollection) { if (await ApiClient.GetTmdbMovieCollection(seasonId[1..]).ConfigureAwait(false) is not { } tmdbMovieCollection) return null; return await CreateShowInfoForTmdbMovieCollection(tmdbMovieCollection, true).ConfigureAwait(false); } var seasonInfo = await GetSeasonInfo(seasonId).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. var seriesConfig = await GetSeriesConfiguration(seasonId).ConfigureAwait(false); if (seriesConfig.StructureType is not SeriesStructureType.Shoko_Groups) return await CreateShowInfoForShokoSeries(seasonInfo).ConfigureAwait(false); var group = await ApiClient.GetShokoGroupForShokoSeries(seasonId).ConfigureAwait(false); if (group == null) return null; // Create a standalone group if grouping is disabled and/or for each series in a group with sub-groups. if (group.Sizes.SubGroups > 0) return await CreateShowInfoForShokoSeries(seasonInfo).ConfigureAwait(false); // 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 otherwise // contain the movie. if (seasonInfo.Type == SeriesType.Movie && Plugin.Instance.Configuration.SeparateMovies) return await CreateShowInfoForShokoSeries(seasonInfo, group.Size > 0 ? group.IDs.ParentGroup?.ToString() : null).ConfigureAwait(false); return await CreateShowInfoForShokoGroup(group, group.Id).ConfigureAwait(false); } private Task<ShowInfo> CreateShowInfo(TmdbShow tmdbShow) => DataCache.GetOrCreateAsync( $"show:by-tmdb-show-id:{tmdbShow.Id}", (showInfo) => Logger.LogTrace("Reusing info object for show {ShowName}. (Source=TMDB,Show={ShowId})", showInfo?.Title, tmdbShow.Id), async () => { Logger.LogTrace("Creating info object for show {ShowName}. (Source=TMDB,Show={ShowId})", tmdbShow.Title, tmdbShow.Id); var seasonsInShow = await ApiClient.GetTmdbSeasonsInTmdbShow(tmdbShow.Id.ToString()).ConfigureAwait(false); var seasonList = new List<SeasonInfo>(); foreach (var season in seasonsInShow) { // Since this is taxing on the upstream, do it 1 season at a time. var seasonInfo = await CreateSeasonInfo(season, tmdbShow).ConfigureAwait(false); seasonList.Add(seasonInfo); } var showInfo = new ShowInfo(ApiClient, tmdbShow, seasonList); foreach (var seasonInfo in seasonList) SeasonIdToShowIdDictionary[seasonInfo.Id] = showInfo.Id; return showInfo; } ); private Task<ShowInfo> CreateShowInfoForTmdbMovieCollection(TmdbMovieCollection tmdbMovieCollection, bool singleSeasonMode = false) => DataCache.GetOrCreateAsync( $"show:by-tmdb-movie-collection-id:{tmdbMovieCollection.Id}:{singleSeasonMode}", (showInfo) => Logger.LogTrace("Reusing info object for show {ShowName}. (Source=TMDB,MovieCollection={MovieCollectionId},SingleSeasonMode={SingleSeasonMode})", showInfo?.Title, tmdbMovieCollection.Id, singleSeasonMode), async () => { Logger.LogTrace("Creating info object for show {ShowName}. (Source=TMDB,MovieCollection={MovieCollectionId},SingleSeasonMode={SingleSeasonMode})", tmdbMovieCollection.Title, tmdbMovieCollection.Id, singleSeasonMode); var moviesInCollection = await ApiClient.GetTmdbMoviesInMovieCollection(tmdbMovieCollection.Id.ToString()).ConfigureAwait(false); var seasonList = singleSeasonMode ? [await CreateSeasonInfo(tmdbMovieCollection).ConfigureAwait(false)] : await Task.WhenAll(moviesInCollection.Select(CreateSeasonInfo)).ConfigureAwait(false); var showInfo = new ShowInfo(ApiClient, tmdbMovieCollection, seasonList); foreach (var seasonInfo in seasonList) SeasonIdToShowIdDictionary[seasonInfo.Id] = showInfo.Id; return showInfo; } ); private Task<ShowInfo> CreateShowInfoForTmdbMovie(TmdbMovie tmdbMovie) => DataCache.GetOrCreateAsync( $"show:by-tmdb-movie-id:{tmdbMovie.Id}", (showInfo) => Logger.LogTrace("Reusing info object for show {ShowName}. (Source=TMDB,Movie={MovieId})", showInfo?.Title, tmdbMovie.Id), async () => { Logger.LogTrace("Creating info object for show {ShowName}. (Source=TMDB,Movie={MovieId})", tmdbMovie.Title, tmdbMovie.Id); var seasonInfo = await CreateSeasonInfo(tmdbMovie).ConfigureAwait(false); var showInfo = new ShowInfo(ApiClient, tmdbMovie, seasonInfo); SeasonIdToShowIdDictionary[seasonInfo.Id] = showInfo.Id; return showInfo; } ); private Task<ShowInfo?> CreateShowInfoForShokoGroup(ShokoGroup group, string groupId) => DataCache.GetOrCreateAsync( $"show:by-group-id:{groupId}", (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Source=Shoko,Group={GroupId})", showInfo?.Title, groupId), async () => { Logger.LogTrace("Creating info object for show {GroupName}. (Source=Shoko,Group={GroupId})", group.Name, groupId); var seriesInGroup = await ApiClient.GetShokoSeriesInGroup(groupId).ConfigureAwait(false); var seasonList = (await Task.WhenAll(seriesInGroup.Select(CreateSeasonInfo)).ConfigureAwait(false)) .DistinctBy(seasonInfo => seasonInfo.Id) .ToList(); var length = seasonList.Count; seasonList = [.. seasonList.Where(s => s.StructureType is SeriesStructureType.Shoko_Groups)]; if (Plugin.Instance.Configuration.SeparateMovies) seasonList = [.. seasonList.Where(s => s.Type is not SeriesType.Movie)]; // 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! (Source=Shoko,Group={GroupId})", groupId); return null; } var tmdbEntities = new List<ITmdbEntity>(); foreach (var seasonInfo in seasonList) { foreach (var tmdbInfo in seasonInfo.TmdbSeasons) { Logger.LogTrace("Fetching TMDB show for Shoko Series {SeriesName}. (Series={SeriesId},Show={ShowId})", seasonInfo.Title, seasonInfo.Id, tmdbInfo.TmdbSeasonId); if (await ApiClient.GetTmdbShowForSeason(tmdbInfo.TmdbSeasonId).ConfigureAwait(false) is not { } tmdbShow) { Logger.LogTrace("Failed to fetch TMDB show for Shoko Series {SeriesName}. (Series={SeriesId},Show={ShowId})", seasonInfo.Title, seasonInfo.Id, tmdbInfo.TmdbSeasonId); continue; } tmdbEntities.Add(tmdbShow); } foreach (var tmdbInfo in seasonInfo.TmdbMovies.DistinctBy(tmdbMovie => tmdbMovie.TmdbMovieId)) { if (string.IsNullOrEmpty(tmdbInfo.TmdbMovieCollectionId)) continue; Logger.LogTrace("Fetching TMDB movie collection for Shoko Series {SeriesName}. (Series={SeriesId},Show={ShowId})", seasonInfo.Title, seasonInfo.Id, tmdbInfo.TmdbMovieCollectionId); if (await ApiClient.GetTmdbMovieCollection(tmdbInfo.TmdbMovieCollectionId).ConfigureAwait(false) is not { } tmdbMovieCollection) { Logger.LogTrace("Failed to fetch TMDB movie collection for Shoko Series {SeriesName}. (Series={SeriesId},Show={ShowId})", seasonInfo.Title, seasonInfo.Id, tmdbInfo.TmdbMovieCollectionId); continue; } tmdbEntities.Add(tmdbMovieCollection); } } var tmdbEntity = tmdbEntities .GroupBy(x => (x.Kind, x.Id)) .OrderByDescending(x => x.Count()) .Select(x => x.First()) .FirstOrDefault(); if (tmdbEntity is not null) Logger.LogTrace("Found TMDB show for group {GroupName}. (Group={GroupId},Show={ShowId})", group.Name, groupId, tmdbEntity.Id); var showInfo = new ShowInfo(ApiClient, Logger, group, seasonList, tmdbEntity, length != seasonList.Count); foreach (var seasonInfo in seasonList) SeasonIdToShowIdDictionary[seasonInfo.Id] = showInfo.Id; return showInfo; } ); private Task<ShowInfo> CreateShowInfoForShokoSeries(SeasonInfo seasonInfo, string? collectionId = null) => DataCache.GetOrCreateAsync( $"show:by-series-id:{seasonInfo.Id}", (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Source=Shoko,Series={SeriesId})", showInfo.Title, seasonInfo.Id), async () => { Logger.LogTrace("Creating info object for show {SeriesName}. (Source=Shoko,Series={SeriesId})", seasonInfo.Title, seasonInfo.Id); TmdbShow? tmdbShow = null; if (seasonInfo.TmdbSeasons.DistinctBy(tmdbSeason => tmdbSeason.TmdbShowId).Count() is 1) { tmdbShow = await ApiClient.GetTmdbShowForSeason(seasonInfo.TmdbSeasons[0].TmdbSeasonId).ConfigureAwait(false); } var showInfo = new ShowInfo(ApiClient, seasonInfo, tmdbShow, collectionId); SeasonIdToShowIdDictionary[seasonInfo.Id] = showInfo.Id; return showInfo; } ); #endregion #region Show Id Helper public bool TryGetShowIdForSeasonId(string seasonId, [NotNullWhen(true)] out string? showId) { if (string.IsNullOrEmpty(seasonId)) { showId = null; return false; } // Fast path; using the lookup. if (SeasonIdToShowIdDictionary.TryGetValue(seasonId, out showId)) return true; // Slow path; getting the show from cache or remote and finding the show id. Logger.LogDebug("Trying to find the show id for season using the slow path. (Season={SeasonId})", seasonId); try { if (Task.Run(() => GetShowInfoBySeasonId(seasonId)).GetAwaiter().GetResult() is { } showInfo) { showId = showInfo.Id; return true; } } catch (Exception ex) { Logger.LogError(ex, "Encountered an error while trying to lookup the show id for season id. (Season={SeasonId})", seasonId); } showId = null; return false; } #endregion #region Collection Info public async Task<CollectionInfo?> GetCollectionInfo(string collectionId) { if (string.IsNullOrEmpty(collectionId)) return null; if (DataCache.TryGetValue<CollectionInfo>($"collection:{collectionId}", out var collectionInfo)) { Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Title, collectionId); return collectionInfo; } if (await ApiClient.GetShokoGroup(collectionId).ConfigureAwait(false) is not { } group) return null; return await CreateCollectionInfo(group, collectionId).ConfigureAwait(false); } private Task<CollectionInfo> CreateCollectionInfo(ShokoGroup group, string groupId) => DataCache.GetOrCreateAsync( $"collection:{groupId}", (collectionInfo) => Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Title, 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.GetShokoSeriesInGroup(groupId, recursive: true).ConfigureAwait(false)) { foreach (var showInfo in await GetShowInfosForShokoSeries(series.Id).ConfigureAwait(false)) { if (showInfo == null) continue; foreach (var shokoInfo in showInfo.ShokoSeries) showGroupIds.Add(shokoInfo.ShokoGroupId); 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.GetShokoGroupsInShokoGroup(groupId).ConfigureAwait(false)) { if (showGroupIds.Contains(subGroup.Id) && !collectionIds.Contains(subGroup.Id)) continue; var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.Id).ConfigureAwait(false); if (subCollectionInfo.FileCount > 0) groupList.Add(subCollectionInfo); } } var showInfoList = await GetShowInfosForShokoSeries(group.IDs.MainSeries.ToString()).ConfigureAwait(false); var mainShowId = showInfoList is { Count: > 0 } ? showInfoList[0].Id : null; var showList = showDict.Values.ToList(); if ( await ApiClient.GetShokoSeries(group.IDs.MainSeries.ToString()).ConfigureAwait(false) is { } mainSeries && group.Name == mainSeries.Name && group.Description == mainSeries.AniDB.Description ) { Logger.LogTrace("Finalizing info object for collection {GroupName}. (MainSeries={MainSeriesId},Group={GroupId})", group.Name, mainSeries.IDs.Shoko.ToString(), groupId); return new CollectionInfo(group, mainSeries, mainShowId, showList, groupList); } Logger.LogTrace("Finalizing info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); return new CollectionInfo(group, mainShowId, showList, groupList); } ); #endregion } ================================================ FILE: Shokofin/API/ShokoIdLookup.cs ================================================ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using Shokofin.Extensions; using Shokofin.ExternalIds; namespace Shokofin.API; /// <summary> /// Looks up Shoko ids for items. /// </summary> public class ShokoIdLookup(ShokoApiManager _apiManager, ILibraryManager _libraryManager) { #region Base Item private static readonly HashSet<string> AllowedTypes = [nameof(Series), nameof(Season), nameof(Episode), nameof(Movie)]; /// <summary> /// Check if the plugin is enabled for <see cref="BaseItem" >the item</see>. /// </summary> /// <param name="item">The <see cref="BaseItem" /> to check.</param> /// <returns>True if the plugin is enabled for the <see cref="BaseItem" /></returns> public bool IsEnabledForItem(BaseItem item) { var reItem = item switch { Series s => s, Season s => s.Series, Episode e => e.Series, _ => item, }; if (reItem == null) { return false; } var libraryOptions = _libraryManager.GetLibraryOptions(reItem); if (libraryOptions == null) { return false; } return IsEnabledForLibraryOptions(libraryOptions); } /// <summary> /// Check if the plugin is enabled for <see cref="LibraryOptions" >the library options</see>. /// </summary> /// <param name="libraryOptions">The <see cref="LibraryOptions" /> 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="LibraryOptions" /></returns> internal static bool IsEnabledForLibraryOptions(LibraryOptions libraryOptions) { var isEnabled = false; 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; } } return isEnabled; } #endregion #region Season Id /// <summary> /// Try to get the main season id for the <see cref="Series" />. /// </summary> /// <param name="series">The <see cref="Series" /> to check for.</param> /// <param name="seasonId">The variable to put the id in.</param> /// <returns>True if it successfully retrieved the id for the <see cref="Series" />.</returns> public bool TryGetSeasonIdFor(Series series, [NotNullWhen(true)] out string? seasonId) { if (series.TryGetSeasonId(out seasonId)) return true; if (_apiManager.TryGetSeasonIdForPath(series.Path, out seasonId)) { if (_apiManager.TryGetShowIdForSeasonId(seasonId, out var mainSeasonId)) series.SetProviderId(ShokoInternalId.Name, mainSeasonId); // Make sure the presentation unique is not cached, so we won't reuse the cache key. // This is for series-merging in a non-VFS based library. if (!series.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) series.PresentationUniqueKey = null; return true; } return false; } /// <summary> /// Try to get the season id for the <see cref="Season" />. /// </summary> /// <param name="season">The <see cref="Season" /> to check for.</param> /// <param name="seasonId">The variable to put the id in.</param> /// <returns>True if it successfully retrieved the id for the <see cref="Season" />.</returns> public bool TryGetSeasonIdFor(Season season, [NotNullWhen(true)] out string? seasonId) { if (season.TryGetSeasonId(out seasonId)) return true; if (_apiManager.TryGetSeasonIdForPath(season.Path, out seasonId)) return true; seasonId = null; return false; } #endregion #region Episode Id /// <summary> /// Try to get the episode ids for the given <see cref="BaseItem" />. /// </summary> /// <param name="item">The <see cref="BaseItem" /> to check for.</param> /// <param name="episodeIds">The variable to put the ids in.</param> /// <returns>True if it successfully retrieved the ids for the <see cref="BaseItem" />.</returns> public bool TryGetEpisodeIdsFor(BaseItem item, [NotNullWhen(true)] out List<string>? episodeIds) { // This will account for existing episodes. if (item.TryGetEpisodeIds(out episodeIds)) return true; // Older path for legacy compatibility, but same as above. if (item.TryGetFileAndSeriesId(out var fileId, out var seasonId) && _apiManager.TryGetEpisodeIdsForFileId(fileId, seasonId, out episodeIds)) return true; // This will account for new episodes that haven't received their first metadata update yet. if (_apiManager.TryGetEpisodeIdsForPath(item.Path, out episodeIds)) return true; // This will account for "missing" episodes. if (item.TryGetEpisodeId(out var episodeId)) { episodeIds = [episodeId]; return true; } episodeIds = null; return false; } #endregion #region File Id /// <summary> /// Try to get the file id for the given <see cref="BaseItem" />. /// </summary> /// <param name="video">The <see cref="BaseItem" /> to check for.</param> /// <param name="fileId">The variable to put the id in.</param> /// <param name="seriesId">The variable to put the id in.</param> /// <returns>True if it successfully retrieved the id for the <see cref="BaseItem" />.</returns> public bool TryGetFileAndSeriesIdFor(BaseItem video, [NotNullWhen(true)] out string? fileId, [NotNullWhen(true)] out string? seriesId) { if (video.TryGetFileAndSeriesId(out fileId, out seriesId)) return true; if (_apiManager.TryGetFileAndSeriesIdForPath(video.Path, out fileId, out seriesId)) return true; fileId = null; seriesId = null; return false; } #endregion } ================================================ FILE: Shokofin/Collections/CollectionManager.cs ================================================ 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.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; namespace Shokofin.Collections; public class CollectionManager( ILibraryManager _libraryManager, ICollectionManager _collection, ILogger<CollectionManager> _logger, ShokoIdLookup _lookup, ShokoApiManager _apiManager ) { private static int MinCollectionSize => Plugin.Instance.Configuration.CollectionMinSizeOfTwo ? 1 : 0; 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).ConfigureAwait(false); break; case Ordering.CollectionCreationType.Movies: await ReconstructMovieSeriesCollections(progress, cancellationToken).ConfigureAwait(false); break; case Ordering.CollectionCreationType.Shared: await ReconstructSharedCollections(progress, cancellationToken).ConfigureAwait(false); 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 (seasonId, collectionList) in existingCollections) { if (seasonDict.ContainsKey(seasonId)) { toCheck.Add(seasonId, 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, [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.Title.ForceASCII()} [{ProviderNames.ShokoCollectionForSeries}={missingId}]", ProviderIds = new() { { ProviderNames.ShokoCollectionForSeries, missingId } }, }).ConfigureAwait(false); childDict.Add(collection.Id, []); 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 (seasonId, collection) in toCheck) { // Edit the metadata to if needed. var updated = false; var seasonInfo = seasonDict[seasonId]; var metadataLanguage = _libraryManager.GetLibraryOptions(collection)?.PreferredMetadataLanguage; var (displayName, alternateTitle) = TextUtility.GetCollectionTitles(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.TryGetSeasonIdFor(show, out var seasonId)) continue; var showInfo = await _apiManager.GetShowInfoBySeasonId(seasonId).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.GetCollectionInfo(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.GetCollectionInfo(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, [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.Title.ForceASCII()} [{ProviderNames.ShokoCollectionForGroup}={missingId}]", ProviderIds = new() { { ProviderNames.ShokoCollectionForGroup, missingId } }, }).ConfigureAwait(false); childDict.Add(collection.Id, []); 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.Title)) { collection.Name = collectionInfo.Title; 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) .WhereNotNull() .ToList(); var expectedShows = collectionInfo.Shows .SelectMany(showInfo => showDict.Where(pair => pair.Value.Id == showInfo.Id)) .Select(pair => pair.Key) .ToList(); var expectedMovies = collectionInfo.Movies .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().ConfigureAwait(false); 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.TryGetEpisodeIdsFor(movie, out var episodeIds) || !_apiManager.TryGetSeasonIdForEpisodeId(episodeIds[0], out var seasonId)) continue; _logger.LogTrace("Removing movie {MovieName} from collection {CollectionName}. (Episode={EpisodeId},Season={SeasonId})", movie.Name, movie.CollectionName, episodeIds[0], seasonId); 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 (seasonId, collectionList) in collectionDict) foreach (var collection in collectionList) RemoveCollection(collection, seasonId: seasonId); } 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? seasonId = 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},Season={SeasonId},Group={GroupId})", collection.Name, children, collection.Id, seasonId, groupId); // Remove the item. _libraryManager.DeleteItem(collection, new() { DeleteFileLocation = true, DeleteFromExternalProvider = false }); } #endregion #region Getter Helpers private List<Movie> GetMovies() => _libraryManager.GetItemList(new() { IncludeItemTypes = [BaseItemKind.Movie], SourceTypes = [SourceType.Library], HasAnyProviderId = new() { { ShokoInternalId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) .Where(_lookup.IsEnabledForItem) .Cast<Movie>() .ToList(); private List<Series> GetShows() => _libraryManager.GetItemList(new() { IncludeItemTypes = [BaseItemKind.Series], SourceTypes = [SourceType.Library], HasAnyProviderId = new() { { ShokoInternalId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) .Where(_lookup.IsEnabledForItem) .Cast<Series>() .ToList(); private Dictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections() => _libraryManager.GetItemList(new() { IncludeItemTypes = [BaseItemKind.BoxSet], SourceTypes = [SourceType.Library], IsVirtualItem = false, Recursive = true, }) .Cast<BoxSet>() .Select(x => x.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForSeries, out var seasonId) ? new { SeasonId = seasonId, BoxSet = x } : null) .WhereNotNull() .GroupBy(x => x.SeasonId, x => x.BoxSet) .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); private Dictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() => _libraryManager.GetItemList(new() { IncludeItemTypes = [BaseItemKind.BoxSet], SourceTypes = [SourceType.Library], IsVirtualItem = false, Recursive = true, }) .Cast<BoxSet>() .Select(x => x.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForGroup, out var groupId) ? new { GroupId = groupId, BoxSet = x } : null) .WhereNotNull() .GroupBy(x => x.GroupId, x => x.BoxSet) .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); #endregion } ================================================ FILE: Shokofin/Configuration/AllDescriptionsConfiguration.cs ================================================ using DescriptionProvider = Shokofin.Utils.TextUtility.DescriptionProvider; namespace Shokofin.Configuration; /// <summary> /// All description configurations, with support for per structure type per /// base item type configuration. /// </summary> public class AllDescriptionsConfiguration { /// <summary> /// Default description settings. /// </summary> public DescriptionConfiguration Default { get; set; } = new() { List = [DescriptionProvider.Shoko], }; /// <summary> /// Description settings for Shoko collections. /// </summary> public ToggleDescriptionConfiguration ShokoCollection { get; set; } = new(); /// <summary> /// Description settings for TMDb collections. /// </summary> public ToggleDescriptionConfiguration TmdbCollection { get; set; } = new(); /// <summary> /// Description settings for AniDB movies. /// </summary> public ToggleDescriptionConfiguration AnidbMovie { get; set; } = new(); /// <summary> /// Description settings for Shoko movies. /// </summary> public ToggleDescriptionConfiguration ShokoMovie { get; set; } = new(); /// <summary> /// Description settings for TMDb movies. /// </summary> public ToggleDescriptionConfiguration TmdbMovie { get; set; } = new(); /// <summary> /// Description settings for AniDB anime. /// </summary> public ToggleDescriptionConfiguration AnidbAnime { get; set; } = new(); /// <summary> /// Description settings for Shoko series. /// </summary> public ToggleDescriptionConfiguration ShokoSeries { get; set; } = new(); /// <summary> /// Description settings for TMDb shows. /// </summary> public ToggleDescriptionConfiguration TmdbShow { get; set; } = new(); /// <summary> /// Description settings for AniDB seasons. /// </summary> public ToggleDescriptionConfiguration AnidbSeason { get; set; } = new(); /// <summary> /// Description settings for Shoko seasons. /// </summary> public ToggleDescriptionConfiguration ShokoSeason { get; set; } = new(); /// <summary> /// Description settings for TMDb seasons. /// </summary> public ToggleDescriptionConfiguration TmdbSeason { get; set; } = new(); /// <summary> /// Description settings for AniDB episodes. /// </summary> public ToggleDescriptionConfiguration AnidbEpisode { get; set; } = new(); /// <summary> /// Description settings for Shoko episodes. /// </summary> public ToggleDescriptionConfiguration ShokoEpisode { get; set; } = new(); /// <summary> /// Description settings for TMDb episodes. /// </summary> public ToggleDescriptionConfiguration TmdbEpisode { get; set; } = new(); } ================================================ FILE: Shokofin/Configuration/AllImagesConfiguration.cs ================================================ namespace Shokofin.Configuration; /// <summary> /// All image configurations, with support for per structure type per /// base item type configuration. /// </summary> public class AllImagesConfiguration { /// <summary> /// Enable debug mode for images. /// </summary> public bool DebugMode { get; set; } /// <summary> /// Default image settings. /// </summary> public ImageConfiguration Default { get; set; } = new() { UsePreferred = true, PosterList = [ ImageLanguageType.None, ], }; /// <summary> /// Image settings for Shoko collections. /// </summary> public ToggleImageConfiguration ShokoCollection { get; set; } = new(); /// <summary> /// Image settings for TMDb collections. /// </summary> public ToggleImageConfiguration TmdbCollection { get; set; } = new(); /// <summary> /// Image settings for AniDB movies. /// </summary> public ToggleImageConfiguration AnidbMovie { get; set; } = new(); /// <summary> /// Image settings for Shoko movies. /// </summary> public ToggleImageConfiguration ShokoMovie { get; set; } = new(); /// <summary> /// Image settings for TMDb movies. /// </summary> public ToggleImageConfiguration TmdbMovie { get; set; } = new(); /// <summary> /// Image settings for AniDB anime. /// </summary> public ToggleImageConfiguration AnidbAnime { get; set; } = new(); /// <summary> /// Image settings for Shoko series. /// </summary> public ToggleImageConfiguration ShokoSeries { get; set; } = new(); /// <summary> /// Image settings for TMDb shows. /// </summary> public ToggleImageConfiguration TmdbShow { get; set; } = new(); /// <summary> /// Image settings for AniDB seasons. /// </summary> public ToggleImageConfiguration AnidbSeason { get; set; } = new(); /// <summary> /// Image settings for Shoko seasons. /// </summary> public ToggleImageConfiguration ShokoSeason { get; set; } = new(); /// <summary> /// Image settings for TMDb seasons. /// </summary> public ToggleImageConfiguration TmdbSeason { get; set; } = new(); /// <summary> /// Image settings for AniDB episodes. /// </summary> public ToggleImageConfiguration AnidbEpisode { get; set; } = new(); /// <summary> /// Image settings for Shoko episodes. /// </summary> public ToggleImageConfiguration ShokoEpisode { get; set; } = new(); /// <summary> /// Image settings for TMDb episodes. /// </summary> public ToggleImageConfiguration TmdbEpisode { get; set; } = new(); } ================================================ FILE: Shokofin/Configuration/AllTitlesConfiguration.cs ================================================ using TitleProvider = Shokofin.Utils.TextUtility.TitleProvider; namespace Shokofin.Configuration; /// <summary> /// Advanced title configuration with support for per structure type per base /// item type configuration. /// </summary> public class AllTitlesConfiguration { /// <summary> /// Default title settings. /// </summary> public TitlesConfiguration Default { get; set; } = new() { MainTitle = new() { List = [TitleProvider.Shoko_Default], }, }; /// <summary> /// Title settings for Shoko collections. /// </summary> public ToggleTitlesConfiguration ShokoCollection { get; set; } = new(); /// <summary> /// Title settings for TMDb collections. /// </summary> public ToggleTitlesConfiguration TmdbCollection { get; set; } = new(); /// <summary> /// Title settings for AniDB movies. /// </summary> public ToggleTitlesConfiguration AnidbMovie { get; set; } = new(); /// <summary> /// Title settings for Shoko movies. /// </summary> public ToggleTitlesConfiguration ShokoMovie { get; set; } = new(); /// <summary> /// Title settings for TMDb movies. /// </summary> public ToggleTitlesConfiguration TmdbMovie { get; set; } = new(); /// <summary> /// Title settings for AniDB anime. /// </summary> public ToggleTitlesConfiguration AnidbAnime { get; set; } = new(); /// <summary> /// Title settings for Shoko series. /// </summary> public ToggleTitlesConfiguration ShokoSeries { get; set; } = new(); /// <summary> /// Title settings for TMDb shows. /// </summary> public ToggleTitlesConfiguration TmdbShow { get; set; } = new(); /// <summary> /// Title settings for AniDB seasons. /// </summary> public ToggleTitlesConfiguration AnidbSeason { get; set; } = new(); /// <summary> /// Title settings for Shoko seasons. /// </summary> public ToggleTitlesConfiguration ShokoSeason { get; set; } = new(); /// <summary> /// Title settings for TMDb seasons. /// </summary> public ToggleTitlesConfiguration TmdbSeason { get; set; } = new(); /// <summary> /// Title settings for AniDB episodes. /// </summary> public ToggleTitlesConfiguration AnidbEpisode { get; set; } = new(); /// <summary> /// Title settings for Shoko episodes. /// </summary> public ToggleTitlesConfiguration ShokoEpisode { get; set; } = new(); /// <summary> /// Title settings for TMDb episodes. /// </summary> public ToggleTitlesConfiguration TmdbEpisode { get; set; } = new(); } ================================================ FILE: Shokofin/Configuration/DebugConfiguration.cs ================================================ using System; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using System.Xml.Serialization; namespace Shokofin.Configuration; public class DebugConfiguration { /// <summary> /// Whether or not to show the debug settings in the UI. /// </summary> public bool ShowInUI { get; set; } = false; /// <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 UsageTrackerStalledTimeInSeconds { get; set; } = 60; /// <summary> /// Amount of time 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> [XmlIgnore, JsonIgnore] public TimeSpan UsageTrackerStalledTime => TimeSpan.FromSeconds(UsageTrackerStalledTimeInSeconds); /// <summary> /// Maximum number of requests of outgoing traffic at any given time. /// </summary> /// <remarks> /// It can be configured between 1 and 1000 requests. /// </remarks> [Range(1, 1000)] public int MaxInFlightRequests { get; set; } = 10; /// <summary> /// The page size to use for series queries. /// </summary> /// <remarks> /// It can be configured between 1 and 10,000. Set to 0 to disable pagination. /// </remarks> [Range(0, 10_000)] public int SeriesPageSize { get; set; } = 25; /// <summary> /// Whether or not to automatically clear the API client's cache. /// </summary> public bool AutoClearClientCache { get; set; } = true; /// <summary> /// Whether or not to automatically clear the API manager's cache. /// </summary> public bool AutoClearManagerCache { get; set; } = true; /// <summary> /// Whether or not to automatically clear the VFS' cache. /// </summary> public bool AutoClearVfsCache { get; set; } = true; /// <summary> /// The expiration scan frequency in minutes for the guarded caches. /// </summary> /// <remarks> /// It can be configured between 1 minute and 3 hours. Requires a restart to take effect. /// </remarks> [Range(1, 180)] public int ExpirationScanFrequencyInMinutes { get; set; } = 25; /// <summary> /// The expiration scan frequency. /// </summary> [XmlIgnore, JsonIgnore] public TimeSpan ExpirationScanFrequency => TimeSpan.FromMinutes(ExpirationScanFrequencyInMinutes); /// <summary> /// The sliding expiration in minutes for the guarded caches. /// </summary> /// <remarks> /// It can be configured between 1 minute and 3 hours. Requires a restart to take effect. /// </remarks> [Range(1, 180)] public int SlidingExpirationInMinutes { get; set; } = 15; /// <summary> /// The sliding expiration. /// </summary> [XmlIgnore, JsonIgnore] public TimeSpan SlidingExpiration => TimeSpan.FromMinutes(SlidingExpirationInMinutes); /// <summary> /// The absolute expiration relative to now in minutes for the guarded caches. /// </summary> /// <remarks> /// It can be configured between 1 minute and 24 hours. Requires a restart to take effect. /// </remarks> [Range(1, 1440)] public int AbsoluteExpirationRelativeToNowInMinutes { get; set; } = 120; /// <summary> /// The absolute expiration relative to now. /// </summary> [XmlIgnore, JsonIgnore] public TimeSpan AbsoluteExpirationRelativeToNow => TimeSpan.FromMinutes(AbsoluteExpirationRelativeToNowInMinutes); } ================================================ FILE: Shokofin/Configuration/DescriptionConfiguration.cs ================================================ using System.Collections.Generic; using System.Linq; using DescriptionProvider = Shokofin.Utils.TextUtility.DescriptionProvider; namespace Shokofin.Configuration; public class DescriptionConfiguration { /// <summary> /// Determines if we should add the note(s) which show up on the AniDB anime /// page to the description for the series and/or season entities. /// </summary> public bool AddNotes { get; set; } = true; /// <summary> /// The collection of providers for descriptions. Replaces the former `DescriptionSource`. /// </summary> public DescriptionProvider[] List { get; set; } = []; /// <summary> /// The prioritization order of source providers for description sources. /// </summary> public DescriptionProvider[] Order { get; set; } = [ DescriptionProvider.Shoko, DescriptionProvider.AniDB, DescriptionProvider.TMDB, ]; /// <summary> /// Returns a list of the providers to check, and in what order. /// </summary> public IEnumerable<DescriptionProvider> GetOrderedDescriptionProviders() => Order.Where((t) => List.Contains(t)); } public class ToggleDescriptionConfiguration : DescriptionConfiguration { /// <summary> /// Whether or not the description configuration is enabled. /// </summary> public bool Enabled { get; set; } } ================================================ FILE: Shokofin/Configuration/Enums/ImageLanguageType.cs ================================================ namespace Shokofin.Configuration; /// <summary> /// Image language types. /// </summary> public enum ImageLanguageType { /// <summary> /// The language is unknown. /// </summary> Unknown = -1, /// <summary> /// No language / Text-less images. /// </summary> None = 0, /// <summary> /// Follow metadata language in library. /// </summary> Metadata = 1, /// <summary> /// Use the language from the media's country of origin. /// </summary> Original = 2, /// <summary> /// English. /// </summary> English = 3, } ================================================ FILE: Shokofin/Configuration/Enums/MetadataRefreshField.cs ================================================ using System; namespace Shokofin.Configuration; /// <summary> /// Determines which metadata fields to update. /// </summary> [Flags] public enum MetadataRefreshField : ulong { /// <summary> /// Will not update any metadata. /// </summary> None = 0, /// <summary> /// Will update the titles and overview. /// </summary> TitlesAndOverview = 1 << 0, /// <summary> /// Will update the premiere date, production year, end date, air status, /// and runtime. /// </summary> Dates = 1 << 1, /// <summary> /// Will update the tags and genres. /// </summary> TagsAndGenres = 1 << 2, /// <summary> /// Will update the studios and production locations. /// </summary> StudiosAndProductionLocations = 1 << 3, /// <summary> /// Will update the cast and crew. /// </summary> CastAndCrew = 1 << 4, /// <summary> /// Will update the official rating, community rating and custom rating. /// </summary> ContentRatings = 1 << 5, /// <summary> /// Will update all images. /// </summary> Images = 1 << 6, /// <summary> /// Will set the preferred images to match what is set in Shoko. /// </summary> PreferredImages = 1 << 7, /// <summary> /// Will update all child metadata recursively. /// </summary> Recursive = 1 << 8, /// <summary> /// Will update all owned metadata. /// </summary> OwnedItems = 1 << 9, /// <summary> /// Will update the plugin managed provider ids. /// </summary> ProviderIds = 1 << 10, /// <summary> /// Will run the custom provider. /// </summary> CustomProvider = 1L << 30, /// <summary> /// Will use the legacy refresh behavior. /// </summary> LegacyRefresh = 1L << 31, } ================================================ FILE: Shokofin/Configuration/Enums/SeasonMergingBehavior.cs ================================================ using System; namespace Shokofin.Configuration; /// <summary> /// Determine how to handle series merging. /// </summary> [Flags] public enum SeasonMergingBehavior { /// <summary> /// Follow the global series merging settings. /// </summary> None = 0, /// <summary> /// Do not merge this series with any other. /// </summary> NoMerge = 1 << 0, /// <summary> /// Attempt to merge this series with the sequel series. /// </summary> MergeForward = 1 << 1, /// <summary> /// Attempt to merge this series with the prequel series. /// </summary> MergeBackward = 1 << 2, /// <summary> /// Attempt to merge this series with the main story series. /// </summary> MergeWithMainStory = 1 << 3, /// <summary> /// Attempt to merge this series with one or more other series in merge /// group A. All other series that should be merged into this series need to /// have <see cref="MergeGroupASource"/> set. /// </summary> MergeGroupATarget = 1 << 4, /// <summary> /// Attempt to merge this series into another series in merge group A. The /// other series needs to have <see cref="MergeGroupATarget"/> set. /// </summary> MergeGroupASource = 1 << 5, /// <summary> /// Attempt to merge this series with one or more other series in merge /// group B. All other series that should be merged into this series need to /// have <see cref="MergeGroupBSource"/> set. /// </summary> MergeGroupBTarget = 1 << 6, /// <summary> /// Attempt to merge this series into another series in merge group B. The /// other series needs to have <see cref="MergeGroupBTarget"/> set. /// </summary> MergeGroupBSource = 1 << 7, /// <summary> /// Attempt to merge this series with one or more other series in merge /// group C. All other series that should be merged into this series need to /// have <see cref="MergeGroupCSource"/> set. /// </summary> MergeGroupCTarget = 1 << 8, /// <summary> /// Attempt to merge this series into another series in merge group C. The /// other series needs to have <see cref="MergeGroupCTarget"/> set. /// </summary> MergeGroupCSource = 1 << 9, /// <summary> /// Attempt to merge this series with one or more other series in merge /// group D. All other series that should be merged into this series need to /// have <see cref="MergeGroupDSource"/> set. /// </summary> MergeGroupDTarget = 1 << 10, /// <summary> /// Attempt to merge this series into another series in merge group D. The /// other series needs to have <see cref="MergeGroupDTarget"/> set. /// </summary> MergeGroupDSource = 1 << 11, } ================================================ FILE: Shokofin/Configuration/Enums/SeriesEpisodeConversion.cs ================================================ namespace Shokofin.Configuration; /// <summary> /// Series episode conversion configuration. /// </summary> public enum SeriesEpisodeConversion { /// <summary> /// Do not convert episode types. /// </summary> None = 0, /// <summary> /// Convert normal episodes to specials. /// </summary> EpisodesAsSpecials = 1, /// <summary> /// Convert specials to normal episodes. /// </summary> SpecialsAsEpisodes = 2, /// <summary> /// Always convert specials to extra featurettes. /// </summary> SpecialsAsExtraFeaturettes = 3, } ================================================ FILE: Shokofin/Configuration/Enums/SeriesStructureType.cs ================================================ namespace Shokofin.Configuration; /// <summary> /// Library structure type to use for series. /// </summary> public enum SeriesStructureType { /// <summary> /// Do not set the library structure. /// </summary> None, /// <summary> /// Structure the libraries as AniDB anime. /// </summary> AniDB_Anime, /// <summary> /// Structure the libraries using Shoko's group structure. /// </summary> Shoko_Groups, /// <summary> /// Structure the libraries as TMDB series and/or movies. /// </summary> TMDB_SeriesAndMovies, } ================================================ FILE: Shokofin/Configuration/Enums/VirtualRootLocation.cs ================================================ namespace Shokofin.Configuration; /// <summary> /// The virtual root location. /// </summary> public enum VirtualRootLocation { /// <summary> /// Use the default virtual root location. /// </summary> Default = 0, // /// <summary> // /// Use the cache for the virtual root location. // /// </summary> // [Obsolete("Using the cache is not longer supported since Jellyfin may clear it at any moment.")] // Cache = 1, /// <summary> /// Use a custom virtual root location. /// </summary> Custom = 2, } ================================================ FILE: Shokofin/Configuration/ImageConfiguration.cs ================================================ using System.Collections.Generic; using System.Linq; namespace Shokofin.Configuration; public class ImageConfiguration { /// <summary> /// Indicates we should respect the preferred image flag sent by the server /// when selecting the images to use for an item. /// </summary> public bool UsePreferred { get; set; } = false; /// <summary> /// Indicates that we should use the community ratings to order the images /// for an item. /// </summary> public bool UseCommunityRating { get; set; } = false; /// <summary> /// Indicates that we should set the dimensions for the images when /// selecting the images to use for an item. /// </summary> public bool UseDimensions { get; set; } = false; /// <summary> /// The enabled image types for posters. /// </summary> public ImageLanguageType[] PosterList { get; set; } = []; /// <summary> /// The order of the enabled image types for posters. /// </summary> public ImageLanguageType[] PosterOrder { get; set; } = [ ImageLanguageType.None, ImageLanguageType.Metadata, ImageLanguageType.Original, ImageLanguageType.English, ]; /// <summary> /// The enabled image types for logos. /// </summary> public ImageLanguageType[] LogoList { get; set; } = []; /// <summary> /// The order of the enabled image types for logos. /// </summary> public ImageLanguageType[] LogoOrder { get; set; } = [ ImageLanguageType.None, ImageLanguageType.Metadata, ImageLanguageType.Original, ImageLanguageType.English, ]; /// <summary> /// The enabled image types for backdrops/banners/thumbnails. /// </summary> public ImageLanguageType[] BackdropList { get; set; } = []; /// <summary> /// The order of the enabled image types for backdrops/banners/thumbnails. /// </summary> public ImageLanguageType[] BackdropOrder { get; set; } = [ ImageLanguageType.None, ImageLanguageType.Metadata, ImageLanguageType.Original, ImageLanguageType.English, ]; /// <summary> /// Returns an ordered list of which image types to include for posters. /// </summary> public IReadOnlyList<ImageLanguageType> GetOrderedPosterTypes() => PosterOrder.Where((t) => t is not ImageLanguageType.Unknown && PosterList.Contains(t)).ToList(); /// <summary> /// Returns an ordered list of which image types to include for logos. /// </summary> public IReadOnlyList<ImageLanguageType> GetOrderedLogoTypes() => LogoOrder.Where((t) => t is not ImageLanguageType.Unknown && LogoList.Contains(t)).ToList(); /// <summary> /// Returns an ordered list of which image types to include for backdrops/banners/thumbnails. /// </summary> public IReadOnlyList<ImageLanguageType> GetOrderedBackdropTypes() => BackdropOrder.Where((t) => t is not ImageLanguageType.Unknown && BackdropList.Contains(t)).ToList(); } public class ToggleImageConfiguration : ImageConfiguration { /// <summary> /// Whether or not the image configuration is enabled. /// </summary> public bool Enabled { get; set; } } ================================================ FILE: Shokofin/Configuration/LegacyMediaFolderConfiguration.cs ================================================ using System; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using System.Xml.Serialization; using MediaBrowser.Controller.Entities; using LibraryOperationMode = Shokofin.Utils.Ordering.LibraryOperationMode; namespace Shokofin.Configuration; /// <summary> /// Media folder configuration. /// </summary> [XmlType("MediaFolderConfiguration")] public class LegacyMediaFolderConfiguration { /// <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 => Guid.Empty == LibraryId ? 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 managed folder id the jellyfin media folder is linked to. /// </summary> [XmlElement("ImportFolderId")] public int ManagedFolderId { get; set; } /// <summary> /// The friendly name of the managed 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> [XmlElement("ImportFolderName")] public string? ManagedFolderName { get; set; } /// <summary> /// The relative path from the root of the managed folder the media folder is located at. /// </summary> [XmlElement("ImportFolderRelativePath")] public string ManagedFolderRelativePath { get; set; } = string.Empty; /// <summary> /// Indicates the Jellyfin Media Folder is a virtual file system folder. /// </summary> [XmlIgnore] [JsonInclude] public bool IsVirtualRoot => ManagedFolderId < 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> /// Shortcut to check if the virtual file system is enabled. /// </summary> [XmlIgnore] [JsonIgnore] public bool IsVirtualFileSystemEnabled => LibraryOperationMode is LibraryOperationMode.VFS; /// <summary> /// Legacy property used to upgrade to the new library operation mode if necessary. /// </summary> [XmlElement("IsVirtualFileSystemEnabled")] [JsonIgnore] public bool? LegacyVirtualFileSystemEnabled { get; set; } /// <summary> /// Determines how the plugin should operate on the selected library. /// </summary> [XmlElement("LibraryFilteringMode")] public LibraryOperationMode LibraryOperationMode { get; set; } = LibraryOperationMode.VFS; /// <summary> /// Only generate links in the VFS for files changed since the last check occurred. /// </summary> public bool IterativeVfsGeneration_Enabled { get; set; } = false; /// <summary> /// Disable the caching on the library itself, making it always iteratively /// re-generate the VFS for the library, and forcing all generations /// occurring within the library to occur. /// </summary> public bool IterativeVfsGeneration_NoCache { get; set; } = false; /// <summary> /// The last time the VFS was iteratively generated. /// </summary> /// <remarks> /// This will be null if the VFS has never been iteratively generated, or if /// a generation with <seealso cref="IterativeVfsGeneration_Enabled"/> /// disabled has occurred. /// </remarks> public DateTime? IterativeVfsGeneration_LastGeneratedAt { get; set; } = null; /// <summary> /// The current number of times the VFS has been iteratively generated. /// </summary> /// <remarks> /// Will be incremented by the system, but only if /// <seealso cref="MaxIterativeGenerationCount"/> is set to a value above 0. /// Otherwise the VFS will always be iteratively generated if /// <seealso cref="IterativeVfsGenerationEnabled"/> is enabled. /// </remarks> [Range(0, 100)] public int IterativeVfsGeneration_CurrentCount { get; set; } = 0; /// <summary> /// The maximum number of times the VFS should be iteratively generated /// before a full generation is performed. Set to a value above 0 to enable. /// </summary> [Range(0, 100)] public int IterativeVfsGeneration_MaxCount { get; set; } = 0; /// <summary> /// Force a full generation of the VFS on the next library generation. /// </summary> /// <remarks> /// This will be turned off once the next generation has started. /// </remarks> public bool IterativeVfsGeneration_ForceFullGenerationOnNextRefresh { get; set; } = false; } ================================================ FILE: Shokofin/Configuration/LibraryConfiguration.cs ================================================ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Text.Json.Serialization; using System.Xml.Serialization; using LibraryOperationMode = Shokofin.Utils.Ordering.LibraryOperationMode; namespace Shokofin.Configuration; /// <summary> /// Media folder configuration. /// </summary> public class LibraryConfiguration { /// <summary> /// The jellyfin library id. /// </summary> public Guid Id { get; set; } /// <summary> /// The Jellyfin library's name. Only for displaying on the plugin /// configuration page. /// </summary> public string Name { get; set; } = string.Empty; /// <summary> /// The virtual root for the library. /// </summary> [XmlIgnore] [JsonIgnore] public string VirtualRoot => string.IsNullOrWhiteSpace(CustomVirtualRoot) ? Path.Join(Plugin.Instance.VirtualRoot, Id.ToString()) : Path.Combine(Plugin.Instance.VirtualRoot, CustomVirtualRoot); /// <summary> /// The user-defined virtual root for the library, if specified. /// </summary> /// <remarks> /// Specified as an absolute path, or a path relative to the plugin's /// virtual root. /// </remarks> public string? CustomVirtualRoot { get; set; } /// <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> /// Shortcut to check if the virtual file system is enabled. /// </summary> [XmlIgnore] [JsonIgnore] public bool IsVirtualFileSystemEnabled => LibraryOperationMode is LibraryOperationMode.VFS; /// <summary> /// Determines how the plugin should operate on the selected library. /// </summary> public LibraryOperationMode LibraryOperationMode { get; set; } = LibraryOperationMode.VFS; /// <summary> /// Only generate links in the VFS for files changed since the last check occurred. /// </summary> public bool IterativeVfsGeneration_Enabled { get; set; } = false; /// <summary> /// Disable the caching on the library itself, making it always iteratively /// re-generate the VFS for the library, and forcing all generations /// occurring within the library to occur. /// </summary> public bool IterativeVfsGeneration_NoCache { get; set; } = false; /// <summary> /// The last time the VFS was iteratively generated. /// </summary> /// <remarks> /// This will be null if the VFS has never been iteratively generated, or if /// a generation with <seealso cref="IterativeVfsGeneration_Enabled"/> /// disabled has occurred. /// </remarks> public DateTime? IterativeVfsGeneration_LastGeneratedAt { get; set; } = null; /// <summary> /// The current number of times the VFS has been iteratively generated. /// </summary> /// <remarks> /// Will be incremented by the system, but only if /// <seealso cref="MaxIterativeGenerationCount"/> is set to a value above 0. /// Otherwise the VFS will always be iteratively generated if /// <seealso cref="IterativeVfsGenerationEnabled"/> is enabled. /// </remarks> [Range(0, 100)] public int IterativeVfsGeneration_CurrentCount { get; set; } = 0; /// <summary> /// The maximum number of times the VFS should be iteratively generated /// before a full generation is performed. Set to a value above 0 to enable. /// </summary> [Range(0, 100)] public int IterativeVfsGeneration_MaxCount { get; set; } = 0; /// <summary> /// Force a full generation of the VFS on the next library generation. /// </summary> /// <remarks> /// This will be turned off once the next generation has started. /// </remarks> public bool IterativeVfsGeneration_ForceFullGenerationOnNextRefresh { get; set; } = false; /// <summary> /// The media folders associated with this library. /// </summary> public IReadOnlyList<MediaFolderConfiguration> MediaFolders => [.. Plugin.Instance.Configuration.LibraryFolders.Where(config => config.LibraryId == Id)]; } ================================================ FILE: Shokofin/Configuration/MediaFolderConfiguration.cs ================================================ using System; using System.Linq; using System.Text.Json.Serialization; using System.Xml.Serialization; namespace Shokofin.Configuration; /// <summary> /// Media folder configuration. /// </summary> [XmlType("MediaFolderConfiguration_V2")] public class MediaFolderConfiguration { /// <summary> /// The jellyfin library id. /// </summary> public Guid LibraryId { get; set; } /// <summary> /// The library configuration. /// </summary> [XmlIgnore] [JsonIgnore] public LibraryConfiguration Library => Plugin.Instance.Configuration.Libraries.First(config => config.Id == LibraryId); /// <summary> /// The jellyfin media folder path. /// </summary> public string Path { get; set; } = string.Empty; /// <summary> /// The shoko managed folder id the jellyfin media folder is linked to. /// </summary> public int ManagedFolderId { get; set; } = 0; /// <summary> /// The friendly name of the managed 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? ManagedFolderName { get; set; } = null; /// <summary> /// The relative path from the root of the managed folder the media folder is located at. /// </summary> public string ManagedFolderRelativePath { get; set; } = string.Empty; /// <summary> /// Indicates that the media folder mapping should be refreshed upon the next refresh. /// </summary> public bool NeedsRefresh { get; set; } = false; /// <summary> /// Indicates that the media folder should be ignored by the plugin, and be available as a media /// folder of the library if the VFS is enabled. /// </summary> public bool IsIgnored { get; set; } = false; /// <summary> /// Indicates the Jellyfin Media Folder is mapped to a Shoko Managed Folder. /// </summary> [XmlIgnore] [JsonInclude] public bool IsMapped => ManagedFolderId != 0; /// <summary> /// Check if a relative path within the managed folder is potentially available in this media folder. /// </summary> /// <param name="relativePath"></param> /// <returns></returns> public bool IsEnabledForPath(string relativePath) => string.IsNullOrEmpty(ManagedFolderRelativePath) || relativePath.StartsWith(ManagedFolderRelativePath + System.IO.Path.DirectorySeparatorChar); /// <summary> /// Merge another media folder configuration into this one. /// </summary> /// <param name="other">The other media folder configuration to merge into this one.</param> public void MergeWith(MediaFolderConfiguration other) { ManagedFolderId = other.ManagedFolderId; ManagedFolderName = other.ManagedFolderName; ManagedFolderRelativePath = other.ManagedFolderRelativePath; } } ================================================ FILE: Shokofin/Configuration/MetadataRefreshConfiguration.cs ================================================ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace Shokofin.Configuration; /// <summary> /// Metadata refresh configuration. /// </summary> public class MetadataRefreshConfiguration { /// <summary> /// Update metadata for all unaired episodes and movies. /// </summary> public bool UpdateUnaired { get; set; } = false; /// <summary> /// Number of days to look back when refreshing metadata. /// </summary> [Range(0, 365)] public int AutoRefreshRangeInDays { get; set; } = 7; /// <summary> /// Minimum number of hours to wait between two consecutive metadata /// refresh operations on the same entity. /// </summary> [Range(0, 8760 /* 24 * 365 */)] public int AntiRefreshDeadZoneInHours { get; set; } = 24; /// <summary> /// If above this range then we will always refresh the entity. /// </summary> [Range(0, 730 /* 365 * 2 */)] public int OutOfSyncInDays { get; set; } = 180; /// <summary> /// Fields to refresh for collections. Set to None to disable. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public MetadataRefreshField Collection { get; set; } = MetadataRefreshField.LegacyRefresh; /// <summary> /// Fields to refresh for movies. Set to None to disable. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public MetadataRefreshField Movie { get; set; } = MetadataRefreshField.LegacyRefresh; /// <summary> /// Fields to refresh for series. Set to None to disable. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public MetadataRefreshField Series { get; set; } = MetadataRefreshField.LegacyRefresh; /// <summary> /// Fields to refresh for seasons. Set to None to disable. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public MetadataRefreshField Season { get; set; } = MetadataRefreshField.LegacyRefresh; /// <summary> /// Fields to refresh for general videos (e.g. extras, trailers, etc.). Set to None to disable. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public MetadataRefreshField Video { get; set; } = MetadataRefreshField.LegacyRefresh; /// <summary> /// Fields to refresh for episodes. Set to None to disable. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public MetadataRefreshField Episode { get; set; } = MetadataRefreshField.LegacyRefresh; } ================================================ FILE: Shokofin/Configuration/Models/LibraryConfigurationChangedEventArgs.cs ================================================ using System; using System.Collections.Generic; namespace Shokofin.Configuration.Models; public class LibraryConfigurationChangedEventArgs(LibraryConfiguration libraryConfiguration, IReadOnlyList<MediaFolderConfiguration> mediaFolderConfigurations) : EventArgs { public LibraryConfiguration LibraryConfiguration { get; private init; } = libraryConfiguration; public IReadOnlyList<MediaFolderConfiguration> MediaFolderConfigurations { get; private init; } = mediaFolderConfigurations; } ================================================ FILE: Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs ================================================ using System; namespace Shokofin.Configuration.Models; public class MediaConfigurationChangedEventArgs(LibraryConfiguration libraryConfiguration, MediaFolderConfiguration config) : EventArgs { public LibraryConfiguration LibraryConfiguration { get; private init; } = libraryConfiguration; public MediaFolderConfiguration MediaFolderConfiguration { get; private init; } = config; } ================================================ FILE: Shokofin/Configuration/PluginConfiguration.cs ================================================ using System; 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.TextUtility.DescriptionProvider; using DescriptionConversionMode = Shokofin.Utils.TextUtility.DescriptionConversionMode; using LibraryOperationMode = Shokofin.Utils.Ordering.LibraryOperationMode; using MergeVersionSortSelector = Shokofin.MergeVersions.MergeVersionSortSelector; 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.TextUtility.TitleProvider; namespace Shokofin.Configuration; // TODO: Split this up in the transition to 6.0 into multiple sub-classes. 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 [XmlIgnore, JsonIgnore] public bool IsConnectionUsable => Uri.IsWellFormedUriString(Url, UriKind.Absolute) && !string.IsNullOrEmpty(ApiKey); /// <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; } [XmlElement("PublicHost")] public string PublicUrl { get; set; } [XmlIgnore] [JsonIgnore] public virtual string PrettyUrl => string.IsNullOrEmpty(PublicUrl) ? Url : PublicUrl; private string _webPrefix; /// <summary> /// The prefix for the web ui on the server. /// </summary> public string WebPrefix { get => _webPrefix; set => _webPrefix = value?.Trim(['/', ' ', '\t', '\r', '\n']) ?? string.Empty; } public virtual string WebUrl => string.IsNullOrEmpty(WebPrefix) ? PrettyUrl : $"{PrettyUrl}/{WebPrefix}"; /// <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; } /// <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; } /// <summary> /// Indicates which set of endpoints to use dependent on which branch of /// Shoko we are using. /// </summary> public bool HasPluginsExposed { get; set; } = false; #endregion #region Plugin Interoperability /// <summary> /// Add IDs from the enabled provider to entities that support it. /// </summary> /// <remarks> /// This is not stored in the xml config file to not break the existing /// settings model until the next major version of the plugin. /// </remarks> /// TODO: Break this during the next major version of the plugin. [JsonInclude] [XmlIgnore] public DescriptionProvider[] ThirdPartyIdProviderList { get { var list = new List<DescriptionProvider>(); if (AddAniDBId) list.Add(DescriptionProvider.AniDB); if (AddTvDBId) list.Add(DescriptionProvider.TvDB); if (AddTMDBId) list.Add(DescriptionProvider.TMDB); return [.. list]; } set { AddAniDBId = value.Contains(DescriptionProvider.AniDB); AddTvDBId = value.Contains(DescriptionProvider.TvDB); AddTMDBId = value.Contains(DescriptionProvider.TMDB); } } /// <summary> /// Add AniDB ids to entries that support it. /// </summary> [JsonIgnore] public bool AddAniDBId { get; set; } /// <summary> /// Add TMDb ids to entries that support it. /// </summary> [JsonIgnore] public bool AddTMDBId { get; set; } /// <summary> /// Add TvDB ids to entries that support it. /// </summary> [JsonIgnore] public bool AddTvDBId { get; set; } #endregion #region Metadata /// <summary> /// The advanced title configuration if you need more control of how to /// handle titles on a per type basis. /// </summary> public AllTitlesConfiguration Title { get; set; } /// <summary> /// The main title configuration. /// </summary> /// TODO: Break this during the next major version of the plugin. public TitleConfiguration? MainTitle { get; set; } /// <summary> /// The alternate title configurations. /// </summary> /// TODO: Break this during the next major version of the plugin. [MaxLength(5, ErrorMessage = "Maximum of 5 alternate titles allowed.")] public TitleConfiguration[]? AlternateTitles { get; set; } /// <summary> /// Determines how we'll be selecting our main title for entries. /// </summary> /// TODO: Break this during the next major version of the plugin. public TitleProvider[]? TitleMainList { get; set; } /// <summary> /// The order of which we will be selecting our main title for entries. /// </summary> /// TODO: Break this during the next major version of the plugin. public TitleProvider[]? TitleMainOrder { get; set; } /// <summary> /// Determines how we'll be selecting our alternate title for entries. /// </summary> /// TODO: Break this during the next major version of the plugin. public TitleProvider[]? TitleAlternateList { get; set; } /// <summary> /// The order of which we will be selecting our alternate title for entries. /// </summary> /// TODO: Break this during the next major version of the plugin. public TitleProvider[]? TitleAlternateOrder { get; set; } /// <summary> /// Allow choosing any title in the selected language if no official /// title is available. /// </summary> /// TODO: Break this during the next major version of the plugin. public bool? TitleAllowAny { 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> /// The new description configuration. /// </summary> public AllDescriptionsConfiguration Description { get; set; } /// <summary> /// The collection of providers for descriptions. Replaces the former `DescriptionSource`. /// </summary> /// TODO: Break this during the next major version of the plugin. public DescriptionProvider[]? DescriptionSourceList { get; set; } /// <summary> /// The prioritization order of source providers for description sources. /// </summary> /// TODO: Break this during the next major version of the plugin. public DescriptionProvider[]? DescriptionSourceOrder { get; set; } /// <summary> /// The conversion mode for descriptions/synopses/summaries. /// </summary> /// TODO: Break this during the next major version of the plugin. [XmlIgnore, JsonInclude] public DescriptionConversionMode DescriptionConversionMode { get { if (SynopsisCleanLinks && SynopsisCleanMiscLines && SynopsisRemoveSummary && SynopsisCleanMultiEmptyLines) return SynopsisEnableMarkdown ? DescriptionConversionMode.Markdown : DescriptionConversionMode.PlainText; return DescriptionConversionMode.PlainText; } set { switch (value) { case DescriptionConversionMode.PlainText: SynopsisEnableMarkdown = false; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; break; case DescriptionConversionMode.Markdown: SynopsisEnableMarkdown = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; break; default: SynopsisEnableMarkdown = false; SynopsisCleanLinks = false; SynopsisCleanMiscLines = false; SynopsisRemoveSummary = false; SynopsisCleanMultiEmptyLines = false; break; } } } /// <summary> /// Disable markdown in the description. /// </summary> [JsonIgnore] public bool SynopsisEnableMarkdown { get; set; } /// <summary> /// Clean up links within the AniDB description for entries. /// </summary> [JsonIgnore] public bool SynopsisCleanLinks { get; set; } /// <summary> /// Clean up misc. lines within the AniDB description for entries. /// </summary> [JsonIgnore] public bool SynopsisCleanMiscLines { get; set; } /// <summary> /// Remove the "summary" preface text in the AniDB description for entries. /// </summary> [JsonIgnore] public bool SynopsisRemoveSummary { get; set; } /// <summary> /// Collapse up multiple empty lines into a single line in the AniDB /// description for entries. /// </summary> [JsonIgnore] public bool SynopsisCleanMultiEmptyLines { get; set; } /// <summary> /// Only select studios responsible for the animation for entities. Only /// applies to AniDB. /// </summary> public bool Metadata_StudioOnlyAnimationWorks { get; set; } #endregion #region Images /// <summary> /// This isn't used anymore, but is kept for upgrading the config in a /// backwards compatible manner. /// TODO: Break this during the next major version of the plugin. /// </summary> [JsonIgnore] public bool? RespectPreferredImage { get; set; } /// <summary> /// The new image configuration /// </summary> public AllImagesConfiguration Image { get; set; } #endregion #region Tags /// <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> /// Exclude tags by name. Tags in this list will not show up as tags. /// </summary> public string[] TagExcludeList { 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> /// Exclude genres by name. Tags in this list will not show up as genres. /// </summary> public string[] GenreExcludeList { get; set; } /// <summary> /// Hide tags that are not verified by the AniDB moderators yet. /// </summary> public bool HideUnverifiedTags { 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> /// 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 Video Version Merging /// <summary> /// Automagically merge alternate versions after a library scan. /// </summary> [XmlElement("EXPERIMENTAL_AutoMergeVersions")] public bool AutoMergeVersions { get; set; } /// <summary> /// Enabled data selectors when merging versions. /// </summary> public MergeVersionSortSelector[] MergeVersionSortSelectorList { get; set; } /// <summary> /// The order to go through the selectors to produce the final sort string. /// </summary> public MergeVersionSortSelector[] MergeVersionSortSelectorOrder { get; set; } #endregion #region Library [JsonInclude] [XmlIgnore] public SeriesStructureType DefaultLibraryStructure { get { if (UseGroupsForShows && !UseTmdbForShows) return SeriesStructureType.Shoko_Groups; if (UseTmdbForShows && !UseGroupsForShows) return SeriesStructureType.TMDB_SeriesAndMovies; return SeriesStructureType.AniDB_Anime; } set { switch (value) { case SeriesStructureType.Shoko_Groups: UseGroupsForShows = true; UseTmdbForShows = false; break; case SeriesStructureType.TMDB_SeriesAndMovies: UseGroupsForShows = false; UseTmdbForShows = true; break; default: UseGroupsForShows = false; UseTmdbForShows = false; break; } } } /// <summary> /// Use Shoko Groups to group Shoko Series together to create the show entries. /// </summary> [JsonIgnore] public bool UseGroupsForShows { get; set; } [JsonIgnore] public bool UseTmdbForShows { get; set; } /// <summary> /// Separate movies out of show type libraries. /// </summary> public bool SeparateMovies { get; set; } /// <summary> /// Filter out anything that's not a movie in a movie library. /// </summary> public bool FilterMovieLibraries { 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 seasons are ordered within a show. /// </summary> [XmlElement("SeasonOrdering")] public OrderType DefaultSeasonOrdering { get; set; } /// <summary> /// Determines how specials are placed within seasons, if at all. /// </summary> [XmlElement("SpecialsPlacement")] public SpecialOrderType DefaultSpecialsPlacement { 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; } /// <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> /// List of folders to ignore when scanning media folders in a non-VFS library. /// </summary> public string[] IgnoredFolders { get; set; } #endregion #region Collection /// <summary> /// Automatically reconstruct collections after a library scan. /// </summary> public bool AutoReconstructCollections { get; set; } = true; /// <summary> /// Determines how collections are made. /// </summary> public CollectionCreationType CollectionGrouping { get; set; } = CollectionCreationType.None; /// <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; } = true; #endregion #region Virtual File System (VFS) /// <summary> /// Determines how the plugin should operate on new libraries. /// </summary> [XmlElement("LibraryFiltering")] public LibraryOperationMode DefaultLibraryOperationMode { get; set; } /// <summary> /// Legacy property used to upgrade to the new library operation mode if necessary. /// </summary> /// TODO: Break this during the next major version of the plugin. [XmlElement("VirtualFileSystem")] public bool? VFS_Legacy_Enabled { get; set; } /// <summary> /// Number of threads to concurrently generate links for the VFS. Set to -1 /// to to match the Jellyfin scan fanout concurrency. Set to 0 or below to /// use the core count. /// </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> /// If the library contains symbolic links to media, it will follow them /// until a final "real" file is found and use the path of said file for the /// VFS /// </summary> public bool VFS_ResolveLinks { get; set; } /// <summary> /// Use the file name of the source file as the version identifier in the /// UI. /// </summary> public bool VFS_UseSourceFileAsVersionIdentifier { get; set; } = false; /// <summary> /// Maximum number of exceptions before aborting the VFS generation. Set to /// 0 to disable limit. /// </summary> [Range(0, 10_000)] public int VFS_MaxTotalExceptionsBeforeAbort { get; set; } /// <summary> /// Maximum number of series with exceptions before aborting the VFS /// generation. Set to 0 to disable limit. /// </summary> [Range(0, 1_000)] public int VFS_MaxSeriesExceptionsBeforeAbort { get; set; } /// <summary> /// Use a semaphore instead of an action block to limit concurrency during /// VFS generation. /// </summary> public bool VFS_UseSemaphore { get; set; } = true; /// <summary> /// Collect and sort the file entries before emitting them, so the /// smallest series will be emitted first, and the biggest last. And every /// file for the same series will be emitted in one go, instead of /// scattered across the emitted range. /// </summary> public bool VFS_CollectAndSort { get; set; } = false; /// <summary> /// Places the VFS in the cache directory instead of the config directory. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public VirtualRootLocation VFS_Location { get; set; } private string? vfs_CustomLocation; /// <summary> /// The custom location for the VFS root, if specified. Should be an /// absolute path or a path relative to the config directory. /// </summary> public string? VFS_CustomLocation { get => vfs_CustomLocation; set => vfs_CustomLocation = string.IsNullOrWhiteSpace(value) ? null : value; } /// <summary> /// A list of AniDB anime IDs to always include during VFS generation, if /// all the files are shared between different series. /// </summary> public int[] VFS_AlwaysIncludedAnidbIdList { get; set; } /// <summary> /// Enable the iterative generation of the VFS for new libraries. /// </summary> public bool VFS_IterativeGenerationEnabled { get; set; } = false; /// <summary> /// Maximum number of iterations to perform when generating the VFS by /// default. Set to a value above 0 to enable. /// </summary> [Range(0, 100)] public int VFS_IterativeGenerationMaxCount { get; set; } = 0; #endregion #region Media Folder /// <summary> /// Per library configuration. /// </summary> [XmlArray("Libraries")] public List<LibraryConfiguration> Libraries { get; set; } = []; /// <summary> /// Per media folder configuration, new format. /// </summary> public List<MediaFolderConfiguration> LibraryFolders { get; set; } = []; /// <summary> /// Per media folder configuration, legacy format. /// </summary> /// TODO: Break this during the next major version of the plugin. [JsonIgnore] [XmlArray("MediaFolders")] public List<LegacyMediaFolderConfiguration>? LegacyMediaFolders { get; set; } = null; #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> /// Indicates whether or not to replace images for entries if related /// metadata is updated in Shoko. /// </summary> /// TODO: Break this during the next major version of the plugin. public bool? SignalR_ReplaceImagesDuringRefresh { get; set; } /// <summary> /// The different SignalR event sources to 'subscribe' to. /// </summary> public ProviderName[] SignalR_EventSources { get; set; } #endregion #region Season Merging /// <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> [XmlElement("EXPERIMENTAL_MergeSeasons")] public bool SeasonMerging_Enabled { get; set; } /// <summary> /// Determines the default merge behavior when not overridden on a per-shoko-series basis. Set to NoMerge to not do merges by default unless overridden. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public SeasonMergingBehavior SeasonMerging_DefaultBehavior { get; set; } /// <summary> /// Series types to attempt to merge. Will respect custom series type overrides. /// </summary> [XmlArray("EXPERIMENTAL_MergeSeasonsTypes")] public SeriesType[] SeasonMerging_SeriesTypes { get; set; } /// <summary> /// Number of days to check between the start of each season, inclusive. /// </summary> /// <remarks> /// Use 0 to disable the threshold and allow all merges, or use any negative /// value to disallow all merges without an override set on one or both of /// the series to merge. /// </remarks> [Range(0, int.MaxValue)] [XmlElement("EXPERIMENTAL_MergeSeasonsMergeWindowInDays")] public int SeasonMerging_MergeWindowInDays { get; set; } #endregion #region Metadata Refresh public MetadataRefreshConfiguration MetadataRefresh { get; set; } #endregion #region Debug /// <summary> /// All debug related configuration. /// </summary> public DebugConfiguration Debug { get; set; } = new(); #endregion #region Misc. features /// <summary> /// Show the plugin in the side-menu. /// </summary> public bool Misc_ShowInMenu { get; set; } /// <summary> /// Enable expert mode. /// </summary> [XmlElement("EXPERT_MODE")] public bool AdvancedMode { get; set; } #endregion public PluginConfiguration() { Url = "http://127.0.0.1:8111"; PublicUrl = string.Empty; _webPrefix = "webui"; Username = "Default"; ApiKey = string.Empty; ServerVersion = null; Title = new(); MarkSpecialsWhenGrouped = true; SynopsisEnableMarkdown = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; Description = new(); HideUnverifiedTags = true; 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 | TagSource.AllYearlySeasons; TagIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted; TagMinimumWeight = TagWeight.Weightless; TagMaximumDepth = 0; TagExcludeList = ["18 restricted"]; GenreSources = TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.Elements; GenreIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted; GenreMinimumWeight = TagWeight.Four; GenreMaximumDepth = 1; GenreExcludeList = ["18 restricted"]; ContentRatingList = [ ProviderName.TMDB, ProviderName.AniDB, ]; ContentRatingOrder = [.. ContentRatingList]; ProductionLocationList = [ ProviderName.AniDB, ProviderName.TMDB, ]; ProductionLocationOrder = [.. ProductionLocationList]; AddAniDBId = true; AddTMDBId = false; AddTvDBId = false; Metadata_StudioOnlyAnimationWorks = false; Image = new(); DefaultLibraryOperationMode = LibraryOperationMode.VFS; VFS_Threads = 4; VFS_AddReleaseGroup = false; VFS_AddResolution = false; VFS_Location = VirtualRootLocation.Default; VFS_CustomLocation = null; VFS_ResolveLinks = false; VFS_MaxTotalExceptionsBeforeAbort = 10; VFS_MaxSeriesExceptionsBeforeAbort = 3; VFS_AlwaysIncludedAnidbIdList = [ 3651, // Suzumiya Haruhi no Yuuutsu (2006) ]; AutoMergeVersions = true; MergeVersionSortSelectorList = [ MergeVersionSortSelector.ImportedAt, ]; MergeVersionSortSelectorOrder = [ MergeVersionSortSelector.ImportedAt, MergeVersionSortSelector.CreatedAt, MergeVersionSortSelector.Resolution, MergeVersionSortSelector.ReleaseGroupName, MergeVersionSortSelector.FileSource, MergeVersionSortSelector.FileVersion, MergeVersionSortSelector.RelativeDepth, MergeVersionSortSelector.NoVariation, ]; UseGroupsForShows = false; UseTmdbForShows = false; SeparateMovies = false; FilterMovieLibraries = true; MovieSpecialsAsExtraFeaturettes = false; AddTrailers = true; AddCreditsAsThemeVideos = true; AddCreditsAsSpecialFeatures = false; DefaultSeasonOrdering = OrderType.Default; DefaultSpecialsPlacement = SpecialOrderType.Excluded; AddMissingMetadata = true; UserList = []; IgnoredFolders = [".streams", "@recently-snapshot"]; LibraryScanReactionTimeInSeconds = 1; SignalR_AutoConnectEnabled = false; SignalR_AutoReconnectInSeconds = [0, 2, 10, 30, 60, 120, 300]; SignalR_EventSources = [ProviderName.Shoko, ProviderName.AniDB, ProviderName.TMDB]; SignalR_RefreshEnabled = false; SignalR_FileEvents = true; SeasonMerging_Enabled = false; SeasonMerging_DefaultBehavior = SeasonMergingBehavior.NoMerge; SeasonMerging_SeriesTypes = [SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA]; SeasonMerging_MergeWindowInDays = 185; MetadataRefresh = new(); Misc_ShowInMenu = false; AdvancedMode = false; } } ================================================ FILE: Shokofin/Configuration/SeriesConfiguration.cs ================================================ using System.Text.Json.Serialization; using Shokofin.API.Models; using Shokofin.Utils; namespace Shokofin.Configuration; /// <summary> /// Per series configuration. /// </summary> public class SeriesConfiguration { /// <summary> /// The series type. /// </summary> public SeriesType Type { get; set; } /// <summary> /// The series structure type to use. /// </summary> public SeriesStructureType StructureType { get; set; } /// <summary> /// Determines how seasons should be ordered for the series. /// </summary> public Ordering.OrderType SeasonOrdering { get; set; } /// <summary> /// Determines how specials should be placed for the series. /// </summary> public Ordering.SpecialOrderType SpecialsPlacement { get; set; } /// <summary> /// Determines how the merging should be handled for the series, if at all. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public SeasonMergingBehavior SeasonMergingBehavior { get; set; } /// <summary> /// Determines how episodes should be converted, if at all. /// </summary> public SeriesEpisodeConversion EpisodeConversion { get; set; } /// <summary> /// Whether to order episodes by airdate instead of episode number. /// </summary> public bool OrderByAirdate { get; set; } } /// <summary> /// Nullable per series configuration. /// </summary> public class NullableSeriesConfiguration { /// <summary> /// The series type. /// </summary> public SeriesType? Type { get; set; } /// <summary> /// The series structure type to use. /// </summary> public SeriesStructureType? StructureType { get; set; } /// <summary> /// Determines how seasons should be ordered for the series. /// </summary> public Ordering.OrderType? SeasonOrdering { get; set; } /// <summary> /// Determines how specials should be placed for the series. /// </summary> public Ordering.SpecialOrderType? SpecialsPlacement { get; set; } /// <summary> /// Determines how the merging should be handled for the series, if at all. /// </summary> [JsonConverter(typeof(JsonStringEnumConverter))] public SeasonMergingBehavior? SeasonMergingBehavior { get; set; } /// <summary> /// Determines how episodes should be converted, if at all. /// </summary> public SeriesEpisodeConversion? EpisodeConversion { get; set; } /// <summary> /// Whether to order episodes by airdate instead of episode number. /// </summary> public bool? OrderByAirdate { get; set; } } ================================================ FILE: Shokofin/Configuration/Services/MediaFolderConfigurationService.cs ================================================ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration.Models; using Shokofin.Extensions; using Shokofin.Utils; namespace Shokofin.Configuration; public class MediaFolderConfigurationService { private readonly ILogger<MediaFolderConfigurationService> Logger; private readonly ILibraryManager LibraryManager; private readonly IFileSystem FileSystem; private readonly NamingOptions NamingOptions; private readonly LibraryScanWatcher LibraryScanWatcher; private readonly UsageTracker UsageTracker; private readonly ShokoApiClient ApiClient; private readonly Dictionary<Guid, int> LibraryChangeTracker = []; private readonly Dictionary<Guid, (string libraryName, HashSet<string> add, HashSet<string> remove)> LibraryEdits = []; private List<VirtualFolderInfo>? CachedVirtualFolders = null; private bool ShouldGenerateAllConfigurations = true; private readonly SemaphoreSlim LockObj = new(1, 1); public event EventHandler<LibraryConfigurationChangedEventArgs>? LibraryConfigurationAdded; public event EventHandler<LibraryConfigurationChangedEventArgs>? LibraryConfigurationChanged; public event EventHandler<LibraryConfigurationChangedEventArgs>? LibraryConfigurationRemoved; public event EventHandler<MediaConfigurationChangedEventArgs>? MediaFolderConfigurationAdded; public event EventHandler<MediaConfigurationChangedEventArgs>? MediaFolderConfigurationRemoved; public MediaFolderConfigurationService( ILogger<MediaFolderConfigurationService> logger, ILibraryManager libraryManager, IFileSystem fileSystem, NamingOptions namingOptions, LibraryScanWatcher libraryScanWatcher, UsageTracker usageTracker, ShokoApiClient apiClient ) { Logger = logger; LibraryManager = libraryManager; FileSystem = fileSystem; NamingOptions = namingOptions; LibraryScanWatcher = libraryScanWatcher; UsageTracker = usageTracker; ApiClient = apiClient; foreach (var libraryConfig in Plugin.Instance.Configuration.Libraries) LibraryChangeTracker[libraryConfig.Id] = ConstructKey(libraryConfig); UsageTracker.Stalled += OnUsageTrackerStalled; LibraryScanWatcher.ValueChanged += OnLibraryScanValueChanged; LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; } ~MediaFolderConfigurationService() { LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; LibraryScanWatcher.ValueChanged -= OnLibraryScanValueChanged; UsageTracker.Stalled -= OnUsageTrackerStalled; LibraryChangeTracker.Clear(); LockObj.Dispose(); } #region Changes Tracking private void OnLibraryScanValueChanged(object? sender, bool isRunning) { if (isRunning) return; Task.Run(() => EditLibraries(true)); } private void OnUsageTrackerStalled(object? sender, EventArgs eventArgs) { Task.Run(() => EditLibraries(false)); } private async Task EditLibraries(bool shouldScheduleLibraryScan) { await LockObj.WaitAsync().ConfigureAwait(false); try { CachedVirtualFolders = null; ShouldGenerateAllConfigurations = true; if (LibraryEdits.Count is 0) return; var libraryEdits = LibraryEdits.ToList(); LibraryEdits.Clear(); foreach (var (libraryId, (libraryName, add, remove)) in libraryEdits) { foreach (var vfsPath in add) { // Before we add the media folder we need to // a) make sure it exists so we can add it without Jellyfin throwing a fit, and // b) make sure it's not empty to make sure Jellyfin doesn't skip resolving it. if (!Directory.Exists(vfsPath)) Directory.CreateDirectory(vfsPath); if (!FileSystem.GetFileSystemEntryPaths(vfsPath).Any()) File.WriteAllText(Path.Join(vfsPath, ".keep"), string.Empty); LibraryManager.AddMediaPath(libraryName, new(vfsPath)); } foreach (var vfsPath in remove) LibraryManager.RemoveMediaPath(libraryName, new(vfsPath)); } if (shouldScheduleLibraryScan) await LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } finally { LockObj.Release(); } } private int ConstructKey(LibraryConfiguration config) => HashCode.Combine(config.Id, config.IsFileEventsEnabled, config.IsRefreshEventsEnabled, config.LibraryOperationMode, config.IterativeVfsGeneration_Enabled); private void OnConfigurationChanged(object? sender, PluginConfiguration config) { foreach (var libraryConfig in config.Libraries) { var currentKey = ConstructKey(libraryConfig); if (LibraryChangeTracker.TryGetValue(libraryConfig.Id, out var previousKey) && previousKey != currentKey) { LibraryChangeTracker[libraryConfig.Id] = currentKey; LibraryConfigurationChanged?.Invoke(sender, new(libraryConfig, libraryConfig.MediaFolders)); } } } private async 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)) { await LockObj.WaitAsync().ConfigureAwait(false); try { ShouldGenerateAllConfigurations = false; await GenerateAllConfigurations(GetVirtualFolders()).ConfigureAwait(false); } finally { LockObj.Release(); } } } #endregion #region Media Folder Mapping public async Task<IReadOnlyList<(string vfsPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)>> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) { await LockObj.WaitAsync().ConfigureAwait(false); try { var virtualFolders = GetVirtualFolders(); if (ShouldGenerateAllConfigurations) { ShouldGenerateAllConfigurations = false; await GenerateAllConfigurations(virtualFolders).ConfigureAwait(false); } return Plugin.Instance.Configuration.LibraryFolders .Where(config => config.IsMapped && (filter is null || filter(config))) .GroupBy(config => config.LibraryId) .Select(groupBy => ( libraryConfig: groupBy.First().Library, virtualFolder: virtualFolders.FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == groupBy.Key), mediaList: groupBy.ToList() as IReadOnlyList<MediaFolderConfiguration> )) .Where(tuple => tuple.virtualFolder is not null && tuple.virtualFolder.Locations.Length is > 0 && tuple.mediaList.Count is > 0) .Select(tuple => ( vfsPath: tuple.libraryConfig.VirtualRoot, collectionType: tuple.virtualFolder!.CollectionType.ConvertToCollectionType(), tuple.mediaList )) .Where(tuple => !string.IsNullOrEmpty(tuple.vfsPath)) .ToList(); } finally { LockObj.Release(); } } public async Task<(LibraryConfiguration? vfsRootConfig, IReadOnlyList<MediaFolderConfiguration> mediaList, bool skipGeneration)> GetMediaFoldersForLibraryInVFS(Folder mediaFolder, CollectionType? collectionType) { var (libraryConfig, mediaFolderConfig) = await GetOrCreateConfigurationForMediaFolder(mediaFolder, collectionType).ConfigureAwait(false); await LockObj.WaitAsync().ConfigureAwait(false); try { var skipGeneration = LibraryEdits.Count is > 0 && LibraryManager.IsScanRunning; if (libraryConfig is null || !libraryConfig.IsVirtualFileSystemEnabled || mediaFolderConfig is not null) return (null, [], skipGeneration); var mediaFolders = libraryConfig.MediaFolders .Where(config => config.IsMapped) .ToList(); return (libraryConfig, mediaFolders, skipGeneration); } finally { LockObj.Release(); } } public async Task<(LibraryConfiguration? libraryConfiguration, MediaFolderConfiguration? mediaFolderConfiguration)> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder, CollectionType? collectionType = CollectionType.unknown) { await LockObj.WaitAsync().ConfigureAwait(false); try { var allVirtualFolders = GetVirtualFolders(); if (allVirtualFolders.FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path) && (collectionType is CollectionType.unknown || p.CollectionType.ConvertToCollectionType() == collectionType)) is not { } library) throw new Exception($"Unable to find any library to use for media folder \"{mediaFolder.Path}\""); if (string.IsNullOrEmpty(library.ItemId) || !Guid.TryParse(library.ItemId, out var libraryId)) throw new Exception($"Unable to parse library id for library \"{library.Name}\" to use for media folder \"{mediaFolder.Path}\". This is not a plugin bug, but the media folder is missing from the default view in Jellyfin."); if (ShouldGenerateAllConfigurations) { ShouldGenerateAllConfigurations = false; await GenerateAllConfigurations(allVirtualFolders).ConfigureAwait(false); } if (Plugin.Instance.Configuration.Libraries.FirstOrDefault(lib => lib.IsVirtualFileSystemEnabled && lib.VirtualRoot == mediaFolder.Path) is { } libraryConfig) { return (libraryConfig, null); } foreach (var mediaFolderConfig in Plugin.Instance.Configuration.LibraryFolders.Where(mf => mf.Path == mediaFolder.Path).ToList()) { return (mediaFolderConfig.Library, mediaFolderConfig); } return (null, null); } finally { LockObj.Release(); } } private async Task GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders) { var filteredVirtualFolders = allVirtualFolders .Where(virtualFolder => { if (virtualFolder is not { ItemId: not null, LibraryOptions: { } } || !Guid.TryParse(virtualFolder.ItemId, out var libraryId)) { Logger.LogWarning("Skipping virtual folder {Name} because it has no ItemId or LibraryOptions.", virtualFolder.Name); return false; } if (virtualFolder.CollectionType.ConvertToCollectionType() is not (null or CollectionType.movies or CollectionType.tvshows)) { Logger.LogTrace("Skipping virtual folder {Name} because it is not a mixed, movie or tvshow library. (Id={LibraryId})", virtualFolder.Name, libraryId); return false; } if (!ShokoIdLookup.IsEnabledForLibraryOptions(virtualFolder.LibraryOptions)) { Logger.LogTrace("Skipping virtual folder {Name} because provider is not enabled for the library. (Id={LibraryId})", virtualFolder.Name, libraryId); return false; } return true; }) .ToList(); Logger.LogDebug("Found {Count} out of {TotalCount} libraries to check media folder configurations for.", filteredVirtualFolders.Count, allVirtualFolders.Count); var shouldSaveConfig = false; var newLibraryConfigList = new List<LibraryConfiguration>(); var oldLibraryConfigList = new List<LibraryConfiguration>(); var newFolderConfigList = new List<(LibraryConfiguration libraryConfiguration, MediaFolderConfiguration mediaFolderConfiguration)>(); var oldFolderConfigList = new List<(LibraryConfiguration libraryConfiguration, MediaFolderConfiguration mediaFolderConfiguration)>(); var librariesToKeep = new HashSet<Guid>(); var config = Plugin.Instance.Configuration; foreach (var virtualFolder in filteredVirtualFolders) { if (!Guid.TryParse(virtualFolder.ItemId, out var libraryId) || LibraryManager.GetItemById(libraryId) is not Folder libraryFolder) { Logger.LogWarning("Unable to find virtual folder with name: {VirtualFolderName}", virtualFolder.Name); continue; } if (!librariesToKeep.Add(libraryId)) { Logger.LogTrace("Skipping library {LibraryName} because it has already been processed. (Library={LibraryId})", libraryFolder.Name, libraryId); continue; } if (config.Libraries.FirstOrDefault(c => c.Id == libraryId) is not { } libraryConfig) { libraryConfig = new() { Id = libraryId, Name = libraryFolder.Name, IsFileEventsEnabled = config.SignalR_FileEvents, IsRefreshEventsEnabled = config.SignalR_RefreshEnabled, LibraryOperationMode = config.DefaultLibraryOperationMode, IterativeVfsGeneration_Enabled = config.VFS_IterativeGenerationEnabled, IterativeVfsGeneration_MaxCount = config.VFS_IterativeGenerationMaxCount, }; config.Libraries.Add(libraryConfig); newLibraryConfigList.Add(libraryConfig); shouldSaveConfig = true; } if (!string.Equals(libraryConfig.Name, libraryFolder.Name, StringComparison.Ordinal)) { libraryConfig.Name = libraryFolder.Name; shouldSaveConfig = true; } Logger.LogDebug("Checking {MediaFolderCount} media folders for library {LibraryName}. (Library={LibraryId})", virtualFolder.Locations.Length, virtualFolder.Name, libraryId); foreach (var mediaFolderPath in virtualFolder.Locations) { // Remove empty/invalid media folders. if (string.IsNullOrEmpty(mediaFolderPath)) { RemoveFromLibrary(libraryConfig, string.Empty); continue; } // Add or remove the VFS root as a media folder as needed. if (mediaFolderPath == libraryConfig.VirtualRoot) { if (!libraryConfig.IsVirtualFileSystemEnabled) { RemoveFromLibrary(libraryConfig, mediaFolderPath); } continue; } // Remove stale VFS roots. if (Plugin.Instance.AllVirtualRoots.Any(mediaFolderPath.StartsWith)) { RemoveFromLibrary(libraryConfig, mediaFolderPath); continue; } // Add config if needed. if (libraryConfig.MediaFolders.FirstOrDefault(mf => mf.Path == mediaFolderPath) is not { } mediaFolderConfig) { mediaFolderConfig = await CreateConfigurationForPath(libraryId, mediaFolderPath).ConfigureAwait(false); config.LibraryFolders.Add(mediaFolderConfig); newFolderConfigList.Add((libraryConfig, mediaFolderConfig)); shouldSaveConfig = true; } // Remove folder from library if VFS is enabled and it's not ignored. if (libraryConfig.IsVirtualFileSystemEnabled && !mediaFolderConfig.IsIgnored) { RemoveFromLibrary(libraryConfig, mediaFolderPath); } } if (libraryConfig.IsVirtualFileSystemEnabled && !virtualFolder.Locations.Contains(libraryConfig.VirtualRoot)) { AddToLibrary(libraryConfig, libraryConfig.VirtualRoot); } foreach (var mediaFolderConfig in libraryConfig.MediaFolders) { if (!libraryConfig.IsVirtualFileSystemEnabled) { // We have disabled the VFS and need to re-add the plugin managed media folders again. var ignoredFolders = libraryConfig.MediaFolders.Where(mf => mf.IsIgnored).Select(mf => mf.Path).ToHashSet(); if (virtualFolder.Locations.Length == ignoredFolders.Count + 1 && virtualFolder.Locations.All(l => l == libraryConfig.VirtualRoot || ignoredFolders.Contains(l))) { AddToLibrary(libraryConfig, mediaFolderConfig.Path); } // The VFS is disabled, and we have a mapping for a media folder which is not linked to the library, // so we need to remove it. else if (!virtualFolder.Locations.Contains(mediaFolderConfig.Path)) { config.LibraryFolders.Remove(mediaFolderConfig); oldFolderConfigList.Add((libraryConfig, mediaFolderConfig)); shouldSaveConfig = true; continue; } } // If we have an ignored media folder that's not added to the library when the VFS is enabled, // then we need to add it. else if (mediaFolderConfig.IsIgnored && !virtualFolder.Locations.Contains(mediaFolderConfig.Path)) { AddToLibrary(libraryConfig, mediaFolderConfig.Path); } // Refresh config if needed. if (!mediaFolderConfig.IsIgnored && mediaFolderConfig.NeedsRefresh) { var newMediaFolderConfig = await CreateConfigurationForPath(libraryId, mediaFolderConfig.Path).ConfigureAwait(false); mediaFolderConfig.MergeWith(newMediaFolderConfig); mediaFolderConfig.NeedsRefresh = false; shouldSaveConfig = true; } } } var librariesToRemove = config.Libraries .ExceptBy(librariesToKeep, c => c.Id) .ToList(); var mediaFoldersToRemove = config.LibraryFolders .ExceptBy(librariesToKeep, c => c.LibraryId) .ToList(); foreach (var mediaFolder in mediaFoldersToRemove) { Logger.LogTrace("Removing config for media folder at path {Path} (Library={LibraryId})", mediaFolder.Path, mediaFolder.LibraryId); config.LibraryFolders.Remove(mediaFolder); var libraryConfig = config.Libraries.FirstOrDefault(c => c.Id == mediaFolder.LibraryId); if (libraryConfig is not null) oldFolderConfigList.Add((libraryConfig, mediaFolder)); shouldSaveConfig = true; } foreach (var library in librariesToRemove) { Logger.LogTrace("Removing config for library {LibraryName} (Library={LibraryId})", library.Name, library.Id); config.Libraries.Remove(library); oldLibraryConfigList.Add(library); shouldSaveConfig = true; } Logger.LogDebug("Removed {Count} libraries and {MediaFolderCount} media folders from configuration.", librariesToRemove.Count, mediaFoldersToRemove.Count); if (shouldSaveConfig) Plugin.Instance.SaveConfiguration(config); foreach (var libraryConfig in newLibraryConfigList) { LibraryChangeTracker[libraryConfig.Id] = ConstructKey(libraryConfig); try { LibraryConfigurationAdded?.Invoke(null, new(libraryConfig, libraryConfig.MediaFolders)); } catch { } } foreach (var (libraryConfig, mediaFolderConfig) in newFolderConfigList.ExceptBy(newLibraryConfigList, c => c.libraryConfiguration)) { try { MediaFolderConfigurationAdded?.Invoke(null, new(libraryConfig, mediaFolderConfig)); } catch { } } foreach (var (libraryConfig, mediaFolderConfig) in oldFolderConfigList.ExceptBy(oldLibraryConfigList, c => c.libraryConfiguration)) { try { MediaFolderConfigurationRemoved?.Invoke(null, new(libraryConfig, mediaFolderConfig)); } catch { } } foreach (var libraryConfig in oldLibraryConfigList) { LibraryChangeTracker.Remove(libraryConfig.Id); try { var mediaFolders = mediaFoldersToRemove .Where(mf => mf.LibraryId == libraryConfig.Id) .ToList(); LibraryConfigurationRemoved?.Invoke(null, new(libraryConfig, mediaFolders)); } catch { } } } private void AddToLibrary(LibraryConfiguration config, string path) { if (!LibraryEdits.TryGetValue(config.Id, out var edits)) LibraryEdits[config.Id] = edits = (config.Name, [], []); edits.add.Add(path); } private void RemoveFromLibrary(LibraryConfiguration config, string path) { if (!LibraryEdits.TryGetValue(config.Id, out var edits)) LibraryEdits[config.Id] = edits = (config.Name, [], []); edits.remove.Add(path); } private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid libraryId, string mediaFolderPath) { var config = Plugin.Instance.Configuration; var mediaFolderConfig = new MediaFolderConfiguration() { LibraryId = libraryId, Path = mediaFolderPath }; if (File.Exists(Path.Join(mediaFolderPath, ".shoko-ignore"))) { mediaFolderConfig.IsIgnored = true; return mediaFolderConfig; } var start = DateTime.UtcNow; var attempts = 0; var foundLocations = new List<(int, string)>(); var samplePaths = GetSamplePaths(mediaFolderPath).ToList(); Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}. (Library={LibraryId})", samplePaths.Count > 100 ? 100 : samplePaths.Count, mediaFolderPath, libraryId); foreach (var path in samplePaths) { attempts++; var partialPath = path[mediaFolderPath.Length..]; var files = await ApiClient.GetFileByPath(partialPath).ConfigureAwait(false); var file = files.Count > 0 ? files[0] : null; 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]; foundLocations.Add((fileLocation.ManagedFolderId, fileLocation.RelativePath[..^partialPath.Length])); } if (foundLocations.Count > 0) { var groupedLocations = foundLocations .GroupBy(x => x) .ToDictionary(x => x.Key, x => x.Count()); foreach (var ((managedFolderId, relativePath), count) in groupedLocations) { Logger.LogDebug("Found {Count} hits for managed folder {Id} at relative path {RelativePath}. (Library={LibraryId})", count, managedFolderId, relativePath, libraryId); } (mediaFolderConfig.ManagedFolderId, mediaFolderConfig.ManagedFolderRelativePath) = groupedLocations .MaxBy(x => x.Value)! .Key; } try { var managedFolder = await ApiClient.GetManagedFolder(mediaFolderConfig.ManagedFolderId).ConfigureAwait(false); if (managedFolder != null) mediaFolderConfig.ManagedFolderName = managedFolder.Name; } catch { } if (mediaFolderConfig.IsMapped) { Logger.LogInformation( "Found a match for media folder at {Path} in {TimeSpan}. (ManagedFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts},Library={LibraryId})", mediaFolderPath, DateTime.UtcNow - start, mediaFolderConfig.ManagedFolderId, mediaFolderConfig.ManagedFolderRelativePath, mediaFolderPath, attempts, libraryId ); } else { Logger.LogWarning( "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}. (Library={LibraryId})", mediaFolderPath, attempts, DateTime.UtcNow - start, libraryId ); } return mediaFolderConfig; } /// <summary> /// Max number of sample paths to return. We use an odd number as a tie /// breaker in case of multiple different matches. /// </summary> private const int MaxSamplePaths = 101; /// <summary> /// Gets the sample paths for the given media folder. /// </summary> /// <param name="mediaFolder">The media folder to get the sample paths /// for.</param> /// <returns>The sample paths for the given media folder.</returns> private IEnumerable<string> GetSamplePaths(string mediaFolder) { var count = 0; var rootFiles = FileSystem.GetFilePaths(mediaFolder, false); foreach (var filePath in rootFiles) { if ( Path.GetExtension(filePath) is not { Length: > 0 } extName || !NamingOptions.VideoFileExtensions.Contains(extName, StringComparer.OrdinalIgnoreCase) || IgnorePatterns.ShouldIgnore(filePath) ) continue; yield return filePath; if (++count is MaxSamplePaths) yield break; } var rootFolders = FileSystem.GetDirectoryPaths(mediaFolder, false); foreach (var directoryPath in rootFolders) { if (IgnorePatterns.ShouldIgnore(directoryPath)) continue; var files = FileSystem.GetFilePaths(directoryPath, true); foreach (var filePath in files) { if ( Path.GetExtension(filePath) is not { Length: > 0 } extName || !NamingOptions.VideoFileExtensions.Contains(extName, StringComparer.OrdinalIgnoreCase) || IgnorePatterns.ShouldIgnore(filePath) ) continue; yield return filePath; if (++count is MaxSamplePaths) yield break; } } } #endregion #region Helpers private List<VirtualFolderInfo> GetVirtualFolders() => CachedVirtualFolders ??= LibraryManager.GetVirtualFolders(); #endregion } ================================================ FILE: Shokofin/Configuration/Services/SeriesConfigurationService.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Models; using Shokofin.Utils; namespace Shokofin.Configuration; public class SeriesConfigurationService(ILogger<SeriesConfigurationService> logger, ShokoApiClient apiClient, ShokoApiManager apiManager) { private readonly GuardedMemoryCache _cache = new( logger, new() { ExpirationScanFrequency = Plugin.Instance.Configuration.Debug.ExpirationScanFrequency }, new() { AbsoluteExpirationRelativeToNow = Plugin.Instance.Configuration.Debug.AbsoluteExpirationRelativeToNow } ); private const string ManagedBy = "This tag is managed by Shokofin and should not be edited manually."; private readonly IReadOnlyList<SimpleTag> _simpleTags = [ new() { NameRegex = new(@"^Series Type/Unknowns?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/Unknown", Description = $"Override the series type as an unknown type series. {ManagedBy}", }, new() { NameRegex = new(@"^Series Type/Others?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/Other", Description = $"Override the series type as an other type series. {ManagedBy}", }, new() { NameRegex = new(@"^Series Type/TVs?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/TV", Description = $"Override the series type as a TV series. {ManagedBy}", }, new() { NameRegex = new(@"^Series Type/TV ?Specials?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/TV Special", Description = $"Override the series type as a TV special. {ManagedBy}", }, new() { NameRegex = new(@"^Series Type/Webs?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/Web", Description = $"Override the series type as a web series. {ManagedBy}", }, new() { NameRegex = new(@"^Series Type/Movies?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/Movie", Description = $"Override the series type as a movie series. {ManagedBy}", }, new() { NameRegex = new(@"^Series Type/OVAs?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/OVA", Description = $"Override the series type as an original video animation series. {ManagedBy}", }, new() { NameRegex = new(@"^Series Type/Music ?Videos?$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Series Type/Music Video", Description = $"Override the series type as a music video series. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(AniDB Structure|Structure/AniDB)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Structure/AniDB", Description = $"Use an AniDB based structure for this Shoko series in Jellyfin. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(Shoko Structure|Structure/Shoko)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Structure/Shoko", Description = $"Use a Shoko Group based structure for this Shoko series in Jellyfin. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(TMDb Structure|Structure/TMDb)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Structure/TMDb", Description = $"Use a TMDb based structure for this Shoko series in Jellyfin. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(Default Season Ordering|Season Ordering/Default)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Season Ordering/Default", Description = $"Let the server decide the season ordering. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(Release Based Season Ordering|Season Ordering/Release)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Season Ordering/Release", Description = $"Order seasons based on release date. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(Chronological Season Ordering|Season Ordering/Chronological)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Season Ordering/Chronological", Description = $"Order seasons in chronological order with indirect relations weighting in on the position of each season. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(Simplified Chronological Season Ordering|Season Ordering/Simplified Chronological)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Season Ordering/Simplified Chronological", Description = $"Order seasons in chronological order while ignoring indirect relations. {ManagedBy}", }, new() { Name = "Shokofin/Specials Placement/Excluded", Description = $"Always exclude the specials from the season. {ManagedBy}", }, new() { Name = "Shokofin/Specials Placement/After Season", Description = $"Always place the specials after the normal episodes in the season. {ManagedBy}", }, new() { Name = "Shokofin/Specials Placement/Mixed", Description = $"Place the specials in-between normal episodes based upon data from TMDb or when the episodes aired. {ManagedBy}", }, new() { Name = "Shokofin/Specials Placement/Air Date", Description = $"Place the specials in-between normal episodes based on when the episodes aired. {ManagedBy}", }, new() { Name = "Shokofin/Specials Placement/TMDb", Description = $"Place the specials in-between normal episodes based upon data from TMDb. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/(No Merge|Merge/None)$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Merge/None", Description = $"Never merge this series with other series when deciding on what to merge for seasons in Jellyfin. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/Merge[ /]Forward$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Merge/Forward", Description = $"Merge the current series with the sequel series in Jellyfin. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/Merge[ /]Backward$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Merge/Backward", Description = $"Merge the current series with the prequel series in Jellyfin. {ManagedBy}", }, new() { NameRegex = new(@"^Shokofin/Merge( with |/)Main Story$", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Compiled), Name = "Shokofin/Merge/Main Story", Description = $"Merge the current side-story with the main-story in Jellyfin. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/A/Target", Description = $"Merge the current series with one or more other series in merge group A in Jellyfin. All other series that should be merged into this series need to have the \"Shokofin/Merge/Group/A/Source\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/A/Source", Description = $"Merge the current series into the main series in merge group A in Jellyfin. The main series needs to have the \"Shokofin/Merge/Group/A/Target\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/B/Target", Description = $"Merge the current series with one or more other series in merge group B in Jellyfin. All other series that should be merged into this series need to have the \"Shokofin/Merge/Group/B/Source\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/B/Source", Description = $"Merge the current series into the main series in merge group B in Jellyfin. The main series needs to have the \"Shokofin/Merge/Group/B/Target\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/C/Target", Description = $"Merge the current series with one or more other series in merge group C in Jellyfin. All other series that should be merged into this series need to have the \"Shokofin/Merge/Group/C/Source\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/C/Source", Description = $"Merge the current series into the main series in merge group C in Jellyfin. The main series needs to have the \"Shokofin/Merge/Group/C/Target\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/D/Target", Description = $"Merge the current series with one or more other series in merge group D in Jellyfin. All other series that should be merged into this series need to have the \"Shokofin/Merge/Group/D/Source\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Merge/Group/D/Source", Description = $"Merge the current series into the main series in merge group D in Jellyfin. The main series needs to have the \"Shokofin/Merge/Group/D/Target\" tag set. {ManagedBy}", }, new() { Name = "Shokofin/Episodes as Specials", Description = $"Converts normal episodes to specials in Jellyfin. {ManagedBy}", }, new() { Name = "Shokofin/Specials as Episodes", Description = $"Converts specials to normal episodes in Jellyfin. {ManagedBy}", }, new() { Name = "Shokofin/Specials As Extra Featurettes", Description = $"Always convert specials to extra featurettes in Jellyfin. {ManagedBy}", }, new() { Name = "Shokofin/Order by AirDate", Description = $"Order episodes by air date instead of episode number in Jellyfin. {ManagedBy}", }, ]; private Task<IReadOnlyDictionary<string, int>> CreatOrGetRequiredTags() => _cache.GetOrCreateAsync<IReadOnlyDictionary<string, int>>("tags", async () => { var allCustomTags = await apiClient.GetCustomTags().ConfigureAwait(false); var outputDict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); foreach (var simpleTag in _simpleTags) { var localTags = allCustomTags .Where(x => simpleTag.NameRegex is not null ? simpleTag.NameRegex.IsMatch(x.Name) : string.Equals(simpleTag.Name, x.Name, StringComparison.OrdinalIgnoreCase) ) .ToList(); if (localTags.Count == 0) { var newTag = await apiClient.CreateCustomTag(simpleTag.Name, simpleTag.Description).ConfigureAwait(false); outputDict[simpleTag.Key] = newTag.Id; continue; } var existingTag = localTags[0]; if ( !string.Equals(simpleTag.Name, existingTag.Name, StringComparison.Ordinal) || !string.Equals(simpleTag.Description, existingTag.Description, StringComparison.Ordinal) ) { existingTag = await apiClient.UpdateCustomTag(existingTag.Id, simpleTag.Name, simpleTag.Description).ConfigureAwait(false); } if (localTags.Skip(1).ToList() is { Count: > 0 } otherTags) { var seriesIds = await apiClient.GetSeriesIdsWithCustomTag(otherTags.Select(x => x.Id)).ConfigureAwait(false); foreach (var otherTag in otherTags) { await apiClient.RemoveCustomTag(otherTag.Id).ConfigureAwait(false); } foreach (var seriesId in seriesIds) { await apiClient.AddCustomTagToShokoSeries(seriesId, existingTag.Id).ConfigureAwait(false); } } outputDict[simpleTag.Key] = existingTag.Id; } return outputDict; }); public async Task<SeriesConfiguration?> GetSeriesConfigurationForId(int shokoSeriesId) { if (await apiClient.GetShokoSeries(shokoSeriesId.ToString()).ConfigureAwait(false) is not { }) return null; return await apiManager.GetInternalSeriesConfiguration(shokoSeriesId.ToString()); } public async Task<SeriesConfiguration> UpdateSeriesConfigurationForId(int shokoSeriesId, NullableSeriesConfiguration seriesConfiguration) { var config = await GetSeriesConfigurationForId(shokoSeriesId).ConfigureAwait(false) ?? throw new InvalidOperationException("Series not found."); if (seriesConfiguration.Type is not null) config.Type = seriesConfiguration.Type.Value; if (seriesConfiguration.StructureType is not null) config.StructureType = seriesConfiguration.StructureType.Value; if (seriesConfiguration.SeasonOrdering is not null) config.SeasonOrdering = seriesConfiguration.SeasonOrdering.Value; if (seriesConfiguration.SpecialsPlacement is not null) config.SpecialsPlacement = seriesConfiguration.SpecialsPlacement.Value; if (seriesConfiguration.SeasonMergingBehavior is not null) config.SeasonMergingBehavior = seriesConfiguration.SeasonMergingBehavior.Value; if (seriesConfiguration.EpisodeConversion is not null) config.EpisodeConversion = seriesConfiguration.EpisodeConversion.Value; if (seriesConfiguration.OrderByAirdate is not null) config.OrderByAirdate = seriesConfiguration.OrderByAirdate.Value; return await UpdateSeriesConfigurationForId(shokoSeriesId, config).ConfigureAwait(false); } public async Task<SeriesConfiguration> UpdateSeriesConfigurationForId(int shokoSeriesId, SeriesConfiguration seriesConfiguration) { if (await apiClient.GetShokoSeries(shokoSeriesId.ToString()).ConfigureAwait(false) is not { } series) throw new InvalidOperationException("Series not found."); var toAddSet = new HashSet<int>(); var toRemoveSet = new HashSet<int>(); var knownTagDict = await CreatOrGetRequiredTags().ConfigureAwait(false); var currentTagSet = await apiClient.GetCustomTagsForShokoSeries(shokoSeriesId) .ContinueWith(x => x.Result.Select(x => x.Id).ToHashSet()) .ConfigureAwait(false); var seriesTypes = knownTagDict.Where(x => x.Key.StartsWith("/series type/")).ToDictionary(x => x.Key, x => x.Value); foreach (var (_, id) in seriesTypes) toRemoveSet.Add(id); switch (seriesConfiguration.Type) { case SeriesType.None: break; case SeriesType.TVSpecial: toRemoveSet.Remove(knownTagDict["/series type/tv special"]); toAddSet.Add(knownTagDict["/series type/tv special"]); break; case SeriesType.MusicVideo: toRemoveSet.Remove(knownTagDict["/series type/music video"]); toAddSet.Add(knownTagDict["/series type/music video"]); break; default: toRemoveSet.Remove(knownTagDict[$"/series type/{seriesConfiguration.Type.ToString().ToLower()}"]); toAddSet.Add(knownTagDict[$"/series type/{seriesConfiguration.Type.ToString().ToLower()}"]); break; } var structureTypes = knownTagDict.Where(x => x.Key.Contains("structure")).ToDictionary(x => x.Key, x => x.Value); foreach (var (_, id) in structureTypes) toRemoveSet.Add(id); switch (seriesConfiguration.StructureType) { case SeriesStructureType.TMDB_SeriesAndMovies: toRemoveSet.Remove(knownTagDict["/shokofin/structure/tmdb"]); toAddSet.Add(knownTagDict["/shokofin/structure/tmdb"]); break; case SeriesStructureType.AniDB_Anime: toRemoveSet.Remove(knownTagDict["/shokofin/structure/anidb"]); toAddSet.Add(knownTagDict["/shokofin/structure/anidb"]); break; case SeriesStructureType.Shoko_Groups: toRemoveSet.Remove(knownTagDict["/shokofin/structure/shoko"]); toAddSet.Add(knownTagDict["/shokofin/structure/shoko"]); break; } var seasonOrderingTypes = knownTagDict.Where(x => x.Key.Contains("season ordering")).ToDictionary(x => x.Key, x => x.Value); foreach (var (_, id) in seasonOrderingTypes) toRemoveSet.Add(id); switch (seriesConfiguration.SeasonOrdering) { case Ordering.OrderType.None: break; case Ordering.OrderType.Default: toRemoveSet.Remove(knownTagDict["/shokofin/season ordering/default"]); toAddSet.Add(knownTagDict["/shokofin/season ordering/default"]); break; case Ordering.OrderType.ReleaseDate: toRemoveSet.Remove(knownTagDict["/shokofin/season ordering/release"]); toAddSet.Add(knownTagDict["/shokofin/season ordering/release"]); break; case Ordering.OrderType.Chronological: toRemoveSet.Remove(knownTagDict["/shokofin/season ordering/chronological"]); toAddSet.Add(knownTagDict["/shokofin/season ordering/chronological"]); break; case Ordering.OrderType.ChronologicalIgnoreIndirect: toRemoveSet.Remove(knownTagDict["/shokofin/season ordering/simplified chronological"]); toAddSet.Add(knownTagDict["/shokofin/season ordering/simplified chronological"]); break; } var specialsPlacementTypes = knownTagDict.Where(x => x.Key.Contains("specials placement")).ToDictionary(x => x.Key, x => x.Value); foreach (var (_, id) in specialsPlacementTypes) toRemoveSet.Add(id); switch (seriesConfiguration.SpecialsPlacement) { case Ordering.SpecialOrderType.None: break; case Ordering.SpecialOrderType.Excluded: toRemoveSet.Remove(knownTagDict["/shokofin/specials placement/excluded"]); toAddSet.Add(knownTagDict["/shokofin/specials placement/excluded"]); break; case Ordering.SpecialOrderType.AfterSeason: toRemoveSet.Remove(knownTagDict["/shokofin/specials placement/after season"]); toAddSet.Add(knownTagDict["/shokofin/specials placement/after season"]); break; case Ordering.SpecialOrderType.InBetweenSeasonMixed: toRemoveSet.Remove(knownTagDict["/shokofin/specials placement/mixed"]); toAddSet.Add(knownTagDict["/shokofin/specials placement/mixed"]); break; case Ordering.SpecialOrderType.InBetweenSeasonByAirDate: toRemoveSet.Remove(knownTagDict["/shokofin/specials placement/air date"]); toAddSet.Add(knownTagDict["/shokofin/specials placement/air date"]); break; case Ordering.SpecialOrderType.InBetweenSeasonByOtherData: toRemoveSet.Remove(knownTagDict["/shokofin/specials placement/tmdb"]); toAddSet.Add(knownTagDict["/shokofin/specials placement/tmdb"]); break; } var mergeTypes = knownTagDict.Where(x => x.Key.Contains("merge")).ToDictionary(x => x.Key, x => x.Value); foreach (var (_, id) in mergeTypes) toRemoveSet.Add(id); if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.NoMerge)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/none"]); toAddSet.Add(knownTagDict["/shokofin/merge/none"]); } else { if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeForward)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/forward"]); toAddSet.Add(knownTagDict["/shokofin/merge/forward"]); } if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeBackward)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/backward"]); toAddSet.Add(knownTagDict["/shokofin/merge/backward"]); } if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeWithMainStory)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/main story"]); toAddSet.Add(knownTagDict["/shokofin/merge/main story"]); } if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupASource)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/a/source"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/a/source"]); } else if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupATarget)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/a/target"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/a/target"]); } if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupBSource)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/b/source"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/b/source"]); } else if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupBTarget)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/b/target"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/b/target"]); } if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupCSource)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/c/source"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/c/source"]); } else if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupCTarget)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/c/target"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/c/target"]); } if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupDSource)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/d/source"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/d/source"]); } else if (seriesConfiguration.SeasonMergingBehavior.HasFlag(SeasonMergingBehavior.MergeGroupDTarget)) { toRemoveSet.Remove(knownTagDict["/shokofin/merge/group/d/target"]); toAddSet.Add(knownTagDict["/shokofin/merge/group/d/target"]); } } var episodeConversions = knownTagDict.Where(x => x.Key.Contains(" as ")).ToDictionary(x => x.Key, x => x.Value); foreach (var (_, id) in episodeConversions) toRemoveSet.Add(id); switch (seriesConfiguration.EpisodeConversion) { case SeriesEpisodeConversion.EpisodesAsSpecials: toRemoveSet.Remove(knownTagDict["/shokofin/episodes as specials"]); toAddSet.Add(knownTagDict["/shokofin/episodes as specials"]); break; case SeriesEpisodeConversion.SpecialsAsEpisodes: toRemoveSet.Remove(knownTagDict["/shokofin/specials as episodes"]); toAddSet.Add(knownTagDict["/shokofin/specials as episodes"]); break; case SeriesEpisodeConversion.SpecialsAsExtraFeaturettes: toRemoveSet.Remove(knownTagDict["/shokofin/specials as extra featurettes"]); toAddSet.Add(knownTagDict["/shokofin/specials as extra featurettes"]); break; } if (seriesConfiguration.OrderByAirdate) { toAddSet.Add(knownTagDict["/shokofin/order by airdate"]); } else { toRemoveSet.Add(knownTagDict["/shokofin/order by airdate"]); } toAddSet.ExceptWith(currentTagSet); toRemoveSet.IntersectWith(currentTagSet); foreach (var tagToRemove in toRemoveSet) await apiClient.RemoveCustomTagFromShokoSeries(shokoSeriesId, tagToRemove).ConfigureAwait(false); foreach (var tagToAdd in toAddSet) await apiClient.AddCustomTagToShokoSeries(shokoSeriesId, tagToAdd).ConfigureAwait(false); return seriesConfiguration; } /// <summary> /// A simple tag with a name and description. Used to determine which tags /// exist in Shoko, and which tags need to be created. /// </summary> class SimpleTag { /// <summary> /// Regular expression to match against the name, if any. /// </summary> public Regex? NameRegex { get; init; } /// <summary> /// Properly cased name of the tag. /// </summary> public required string Name { get; init; } /// <summary> /// Proper description of the tag. /// </summary> public required string Description { get; init; } /// <summary> /// The namespaced key for the tag. /// </summary> public string Key => $"/{Name.ToLower()}"; } } ================================================ FILE: Shokofin/Configuration/TitleConfiguration.cs ================================================ using System.Collections.Generic; using System.Linq; using TitleProvider = Shokofin.Utils.TextUtility.TitleProvider; namespace Shokofin.Configuration; public class TitleConfiguration { /// <summary> /// Determines how we'll be selecting the title for entries. /// </summary> public TitleProvider[] List { get; set; } = []; /// <summary> /// The order of which we will be selecting the title for entries. /// </summary> public TitleProvider[] Order { get; set; } = [ TitleProvider.Shoko_Default, TitleProvider.AniDB_Default, TitleProvider.AniDB_LibraryLanguage, TitleProvider.AniDB_CountryOfOrigin, TitleProvider.TMDB_Default, TitleProvider.TMDB_LibraryLanguage, TitleProvider.TMDB_CountryOfOrigin, ]; /// <summary> /// Allow choosing any title in the selected language if no official /// title is available. /// </summary> public bool AllowAny { get; set; } /// <summary> /// Returns a list of the providers to check, and in what order. /// </summary> public IEnumerable<TitleProvider> GetOrderedTitleProviders() => Order.Where((t) => List.Contains(t)); } ================================================ FILE: Shokofin/Configuration/TitlesConfiguration.cs ================================================ using System.ComponentModel.DataAnnotations; namespace Shokofin.Configuration; /// <summary> /// Titles configuration. /// </summary> public class TitlesConfiguration { /// <summary> /// Remove duplicates from the alternate title list during display. /// </summary> public bool RemoveDuplicates { get; set; } /// <summary> /// The main title configuration. /// </summary> public TitleConfiguration MainTitle { get; set; } = new(); /// <summary> /// The alternate title configurations. /// </summary> [MaxLength(5, ErrorMessage = "Maximum of 5 alternate titles allowed.")] [MinLength(1, ErrorMessage = "Minimum of 1 alternate title allowed.")] public TitleConfiguration[] AlternateTitles { get; set; } = [new()]; } public class ToggleTitlesConfiguration : TitlesConfiguration { /// <summary> /// Whether or not the titles configuration is enabled. /// </summary> public bool Enabled { get; set; } } ================================================ FILE: Shokofin/Configuration/UserConfiguration.cs ================================================ 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; } ================================================ FILE: Shokofin/Events/EventDispatchService.cs ================================================ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; 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.Configuration; using Shokofin.Events.Interfaces; using Shokofin.Extensions; using Shokofin.ExternalIds; using Shokofin.Resolvers; using Shokofin.Resolvers.Models; using Shokofin.Utils; using File = System.IO.File; using Movie = MediaBrowser.Controller.Entities.Movies.Movie; using Timer = System.Timers.Timer; using TvEpisode = MediaBrowser.Controller.Entities.TV.Episode; using TvSeason = MediaBrowser.Controller.Entities.TV.Season; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; 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 MetadataRefreshService MetadataRefreshService; private readonly MediaFolderConfigurationService ConfigurationService; private readonly VirtualFileSystemService ResolveManager; private readonly IFileSystem FileSystem; private readonly ILogger<EventDispatchService> Logger; private readonly UsageTracker UsageTracker; private int ChangesDetectionSubmitterCount = 0; private readonly Timer ChangesDetectionTimer; private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List, Guid trackerId)> ChangesPerSeries = []; private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ManagedFolderId, string Path, IFileEventArgs Event)> List, Guid trackerId)> ChangesPerFile = []; private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = []; // It's so magical that it matches the magical value in the library monitor in JF core. 🪄 private const int MagicalDelayValue = 45000; private readonly ConcurrentDictionary<Guid, bool> RecentlyUpdatedEntitiesDict = new(); private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); public EventDispatchService( ShokoApiManager apiManager, ShokoApiClient apiClient, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, LibraryScanWatcher libraryScanWatcher, MetadataRefreshService metadataRefreshService, MediaFolderConfigurationService configurationService, VirtualFileSystemService resolveManager, IFileSystem fileSystem, ILogger<EventDispatchService> logger, UsageTracker usageTracker ) { ApiManager = apiManager; ApiClient = apiClient; LibraryManager = libraryManager; LibraryMonitor = libraryMonitor; LibraryScanWatcher = libraryScanWatcher; MetadataRefreshService = metadataRefreshService; ConfigurationService = configurationService; ResolveManager = resolveManager; FileSystem = fileSystem; Logger = logger; UsageTracker = usageTracker; UsageTracker.Stalled += OnStalled; ChangesDetectionTimer = new() { AutoReset = true, Interval = TimeSpan.FromSeconds(4).TotalMilliseconds }; ChangesDetectionTimer.Elapsed += OnIntervalElapsed; } ~EventDispatchService() { UsageTracker.Stalled -= OnStalled; ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; } private void OnStalled(object? sender, EventArgs eventArgs) { Clear(); } public void Clear() => RecentlyUpdatedEntitiesDict.Clear(); #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 ManagedFolderId, 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 ManagedFolderId, 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 managedFolderId, string filePath, IFileEventArgs eventArgs) { lock (ChangesPerFile) { if (ChangesPerFile.TryGetValue(fileId, out var tuple)) tuple.LastUpdated = DateTime.Now; else ChangesPerFile.Add(fileId, tuple = (DateTime.Now, [], Plugin.Instance.Tracker.Add($"File event. (Reason=\"{reason}\",ManagedFolder={eventArgs.ManagedFolderId},RelativePath=\"{eventArgs.RelativePath}\")"))); tuple.List.Add((reason, managedFolderId, filePath, eventArgs)); } } private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ManagedFolderId, 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 seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)).ConfigureAwait(false); var libraries = await ConfigurationService.GetAvailableMediaFoldersForLibraries(c => c.Library.IsFileEventsEnabled).ConfigureAwait(false); var (reason, managedFolderId, relativePath, lastEvent) = changes.Last(); if (reason is not UpdateReason.MetadataRemoved) { Logger.LogTrace("Processing file changed. (File={FileId})", fileId); foreach (var (vfsPath, collectionType, mediaConfigs) in libraries) { foreach (var (managedFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToManagedFolderList(managedFolderId, relativePath)) { foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, relativePath[managedFolderSubPath.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 => tuple.symbolicLinks.Length > 0 && tuple.importedAt.HasValue) .ToList(); foreach (var (symLinks, importDate) in vfsLocations) { result += ResolveManager.GenerateSymbolicLinks(vfsPath, sourceLocation, 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() { SourceTypes = [SourceType.Library], HasAnyProviderId = new Dictionary<string, string> { { ProviderNames.ShokoFile, 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); locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); break; } } } } // Something was removed, so assume the location is gone. else if (changes.FirstOrDefault(t => t.Reason is UpdateReason.MetadataRemoved).Event is IFileEventArgs firstRemovedEvent) { // If we don't know which series to remove, then add all of them to be scanned. if (seriesIds.Count is 0) { Logger.LogTrace("No series found for file. Adding all libraries. (File={FileId})", fileId); foreach (var (vfsPath, collectionType, mediaConfigs) in libraries) { locationsToNotify.Add(vfsPath); } goto aLabelToReduceNesting; } Logger.LogTrace("Processing file removed. (File={FileId})", fileId); relativePath = firstRemovedEvent.RelativePath; managedFolderId = firstRemovedEvent.ManagedFolderId; foreach (var (vfsPath, collectionType, mediaConfigs) in libraries) { foreach (var (managedFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToManagedFolderList(managedFolderId, relativePath)) { foreach (var mediaFolderPath in mediaFolderPaths) { // Let the core logic handle the rest. if (!vfsEnabled) { var sourceLocation = Path.Join(mediaFolderPath, relativePath[managedFolderSubPath.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(managedFolderId, managedFolderSubPath, fileId, relativePath, mediaFolderPath).ConfigureAwait(false); if (!string.IsNullOrEmpty(newSourceLocation)) { var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(collectionType, vfsPath, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) .Where(tuple => tuple.symbolicLinks.Length > 0 && tuple.importedAt.HasValue) .ToList(); foreach (var (symLinks, importDate) in vfsLocations) { result += ResolveManager.GenerateSymbolicLinks(vfsPath, newSourceLocation, 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.SelectMany(tuple => tuple.symbolicLinks).ToHashSet(); } // Remove old links for file. var videos = LibraryManager .GetItemList( new() { SourceTypes = [SourceType.Library], HasAnyProviderId = new Dictionary<string, string> { { ProviderNames.ShokoFile, 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); locationsToNotify.AddRange(vfsSymbolicLinks); break; } } } } aLabelToReduceNesting:; 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, fileId.ToString()); foreach (var location in locationsToNotify) { Logger.LogTrace("Notifying Jellyfin about changes to {Location}. (File={FileId})", location, fileId.ToString()); LibraryMonitor.ReportFileSystemChanged(location); } Logger.LogDebug("Notified Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); } 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 { var file = await ApiClient.GetFile(fileId.ToString()).ConfigureAwait(false); if (file is null) return new HashSet<string>(); 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(); } // 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.GetSeriesIdsForShokoSeries(seriesId).ConfigureAwait(false); if (await ApiManager.GetPathSetForSeries(primaryId).ConfigureAwait(false) is { Count: > 0 }) { filteredSeriesIds.Add(seriesId); } else if (extraIds.Count > 0) { foreach (var extraId in extraIds) { if (await ApiManager.GetPathSetForSeries(extraId).ConfigureAwait(false) is { Count: > 0 }) { filteredSeriesIds.Add(seriesId); break; } } } } // Return all series if we only have this file for all of them, // otherwise return only the series where we have other files that are // not linked to other series. return filteredSeriesIds.Count is 0 ? seriesIds : filteredSeriesIds; } private async Task<string?> GetNewSourceLocation(int managedFolderId, string managedFolderSubPath, int fileId, string relativePath, string mediaFolderPath) { // Check if the file still exists, and if it has any other locations we can use. var file = await ApiClient.GetFile(fileId.ToString()).ConfigureAwait(false); if (file is null) return null; var usableLocation = file.Locations .Where(loc => loc.ManagedFolderId == managedFolderId && (string.IsNullOrEmpty(managedFolderSubPath) || relativePath.StartsWith(managedFolderSubPath + Path.DirectorySeparatorChar)) && loc.RelativePath != relativePath) .FirstOrDefault(); if (usableLocation is null) return null; var sourceLocation = Path.Join(mediaFolderPath, usableLocation.RelativePath[managedFolderSubPath.Length..]); if (!File.Exists(sourceLocation)) return null; return sourceLocation; } private void RemoveSymbolicLink(string filePath) { // TODO: If this works better, then move it to an utility and also use it in the VFS if needed, or remove this comment if that'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); } } #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, [], 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> events, Guid trackerId) { try { var tasks = new List<Task>(); if (events.Where(e => e.IsImageUpdate).ToList() is { Count: > 0 } imageEvents) tasks.Add(ProcessImageUpdateEvents(metadataId, imageEvents)); if (events.Where(e => e.IsMetadataUpdate).ToList() is { Count: > 0 } metadataEvents) tasks.Add(ProcessMetadataUpdateEvents(metadataId, metadataEvents)); await Task.WhenAll(tasks).ConfigureAwait(false); } finally { Plugin.Instance.Tracker.Remove(trackerId); } } private async Task ProcessImageUpdateEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) { try { if (!changes.Any(e => e.Kind is BaseItemKind.Episode or BaseItemKind.Movie && e.EpisodeIds.Count > 0 || e.Kind is BaseItemKind.Series && e.SeriesIds.Count > 0)) { Logger.LogDebug("Skipped processing {EventCount} image change events because no series or episode ids to use. (Metadata={ProviderUniqueId})", changes.Count, metadataId); return; } var allSeriesIds = changes.SelectMany(e => e.SeriesIds).ToHashSet(); var seasonInfoDict = new Dictionary<string, SeasonInfo>(); var seriesIdDict = new Dictionary<int, string[]>(); foreach (var seriesId in allSeriesIds) { var seasonInfoList = await ApiManager.GetSeasonInfosForShokoSeries(seriesId.ToString()).ConfigureAwait(false); foreach (var seasonInfo in seasonInfoList) { seasonInfoDict.Add(seasonInfo.Id, seasonInfo); } seriesIdDict.Add(seriesId, seasonInfoList.Select(s => s.Id).ToArray()); } if (seasonInfoDict.Count is 0) { Logger.LogDebug("Unable to find season info for series id. (Metadata={ProviderUniqueId})", metadataId); return; } var showInfoList = (await Task.WhenAll(seasonInfoDict.Values.Select(s => ApiManager.GetShowInfoBySeasonId(s.Id))).ConfigureAwait(false)) .WhereNotNull() .DistinctBy(s => s.Id) .ToList(); if (showInfoList.Count is 0) { Logger.LogDebug("Unable to find show info for series id. (Metadata={ProviderUniqueId})", metadataId); return; } Logger.LogInformation("Processing {EventCount} image change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); var updateCount = 0; var refreshFieldsMask = MetadataRefreshField.Images | MetadataRefreshField.PreferredImages; foreach (var showInfo in showInfoList) updateCount += await ProcessSeriesEvents(showInfo, changes, seriesIdDict, refreshFieldsMask).ConfigureAwait(false); foreach (var seasonInfo in seasonInfoDict.Values) updateCount += await ProcessMovieEvents(seasonInfo, changes, refreshFieldsMask).ConfigureAwait(false); Logger.LogInformation("Scheduled {UpdateCount} image updates for {EventCount} image change events. (Metadata={ProviderUniqueId})", updateCount, changes.Count, metadataId); } catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} image change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); } } private async Task ProcessMetadataUpdateEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) { 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 or BaseItemKind.Movie && e.EpisodeIds.Count > 0 || e.Kind is BaseItemKind.Series && e.SeriesIds.Count > 0)) { Logger.LogDebug("Skipped processing {EventCount} metadata change events because no series or episode ids to use. (Metadata={ProviderUniqueId})", changes.Count, metadataId); return; } var allSeriesIds = changes.SelectMany(e => e.SeriesIds).ToHashSet(); var seasonInfoDict = new Dictionary<string, SeasonInfo>(); var seriesIdDict = new Dictionary<int, string[]>(); foreach (var seriesId in allSeriesIds) { var seasonInfoList = await ApiManager.GetSeasonInfosForShokoSeries(seriesId.ToString()).ConfigureAwait(false); foreach (var seasonInfo in seasonInfoList) { seasonInfoDict.Add(seasonInfo.Id, seasonInfo); } seriesIdDict.Add(seriesId, seasonInfoList.Select(s => s.Id).ToArray()); } if (seasonInfoDict.Count is 0) { Logger.LogDebug("Unable to find season info for series id. (Metadata={ProviderUniqueId})", metadataId); return; } var showInfoList = (await Task.WhenAll(seasonInfoDict.Values.Select(s => ApiManager.GetShowInfoBySeasonId(s.Id))).ConfigureAwait(false)) .WhereNotNull() .DistinctBy(s => s.Id) .ToList(); if (showInfoList.Count is 0) { Logger.LogDebug("Unable to find show info for series id. (Metadata={ProviderUniqueId})", metadataId); return; } Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); var updateCount = 0; var refreshFieldsMask = ~(MetadataRefreshField.Images | MetadataRefreshField.PreferredImages); foreach (var showInfo in showInfoList) updateCount += await ProcessSeriesEvents(showInfo, changes, seriesIdDict, refreshFieldsMask).ConfigureAwait(false); foreach (var seasonInfo in seasonInfoDict.Values) updateCount += await ProcessMovieEvents(seasonInfo, changes, refreshFieldsMask).ConfigureAwait(false); Logger.LogInformation("Scheduled {UpdateCount} metadata 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); } } private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes, IReadOnlyDictionary<int, string[]> seriesIdDict, MetadataRefreshField refreshFieldsMask) { // Update the series if we got a series event. var updateCount = 0; if (changes.Find(e => e.Kind is BaseItemKind.Series) is not null) { var shows = LibraryManager .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Series], SourceTypes = [SourceType.Library], HasAnyProviderId = new Dictionary<string, string> { { ShokoInternalId.Name, showInfo.InternalId } }, DtoOptions = new(true), }) .DistinctBy(s => s.Id) .OfType<TvSeries>() .ToList(); foreach (var show in shows) { if (!RecentlyUpdatedEntitiesDict.TryAdd(show.Id, true)) { Logger.LogTrace("Show {ShowName} is already being updated. (Show={ShowId},Series={SeriesId})", show.Name, show.Id, showInfo.Id); continue; } Logger.LogInformation("Refreshing show {ShowName}. (Show={ShowId},Series={SeriesId})", show.Name, show.Id, showInfo.Id); await MetadataRefreshService.RefreshSeries(show, refreshFieldsMask).ConfigureAwait(false); updateCount++; } } // Otherwise update all season/episodes where appropriate. else { var episodeIds = changes .Where(e => e.EpisodeIds.Count > 0 && e.Reason is not UpdateReason.MetadataRemoved) .SelectMany(e => new List<string>([ ..e.EpisodeIds.Select(eI => eI.ToString()), ..(e.Kind is BaseItemKind.Movie && e.ProviderName is ProviderName.TMDB) ? [IdPrefix.TmdbMovie + e.ProviderId] : Array.Empty<string>(), ..(e.Kind is BaseItemKind.Episode && e.ProviderName is ProviderName.TMDB) ? [IdPrefix.TmdbShow + e.ProviderId] : Array.Empty<string>(), ])) .ToHashSet(); var seasonIds = changes .Where(e => e.EpisodeIds.Count > 0 && e.SeriesIds.Count > 0 && e.Reason is UpdateReason.MetadataRemoved) .SelectMany(e => e.SeriesIds.SelectMany(s => seriesIdDict[s])) .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 = [BaseItemKind.Season], SourceTypes = [SourceType.Library], HasAnyProviderId = new Dictionary<string, string> { { ShokoInternalId.Name, seasonInfo.InternalId } }, DtoOptions = new(true), }) .DistinctBy(s => s.Id) .OfType<TvSeason>() .ToList(); foreach (var season in seasons) { var showId = season.SeriesId; if (RecentlyUpdatedEntitiesDict.ContainsKey(showId)) { Logger.LogTrace("Show is already being updated. (Check=1,Show={ShowId},TvSeason={SeasonId},Season={SeasonId})", showId, season.Id, seasonInfo.Id); continue; } if (!RecentlyUpdatedEntitiesDict.TryAdd(season.Id, true)) { Logger.LogTrace("Season is already being updated. (Check=2,Show={ShowId},TvSeason={SeasonId},Season={SeasonId})", showId, season.Id, seasonInfo.Id); continue; } Logger.LogInformation("Refreshing season {SeasonName}. (TvSeason={SeasonId},Season={SeasonId},ExtraSeries={ExtraIds})", season.Name, season.Id, seasonInfo.Id, seasonInfo.ExtraIds); await MetadataRefreshService.RefreshSeason(season, refreshFieldsMask).ConfigureAwait(false); 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 = [BaseItemKind.Episode], SourceTypes = [SourceType.Library], HasAnyProviderId = new Dictionary<string, string> { { ProviderNames.ShokoEpisode, episodeInfo.Id } }, DtoOptions = new(true), }) .DistinctBy(e => e.Id) .OfType<TvEpisode>() .ToList(); foreach (var episode in episodes) { var showId = episode.SeriesId; var seasonId = episode.SeasonId; if (RecentlyUpdatedEntitiesDict.ContainsKey(showId)) { Logger.LogTrace("Show is already being updated. (Check=1,Show={ShowId},Season={SeasonId},Episode={EpisodeId},Episode={EpisodeId},Season={SeasonId})", showId, seasonId, episode.Id, episodeInfo.Id, episodeInfo.SeasonId); continue; } if (RecentlyUpdatedEntitiesDict.ContainsKey(seasonId)) { Logger.LogTrace("Season is already being updated. (Check=2,Show={ShowId},Season={SeasonId},Episode={EpisodeId},Episode={EpisodeId},Season={SeasonId})", showId, seasonId, episode.Id, episodeInfo.Id, episodeInfo.SeasonId); continue; } if (!RecentlyUpdatedEntitiesDict.TryAdd(episode.Id, true)) { Logger.LogTrace("Episode is already being updated. (Check=3,Show={ShowId},Season={SeasonId},Episode={EpisodeId},Episode={EpisodeId},Season={SeasonId})", showId, seasonId, episode.Id, episodeInfo.Id, episodeInfo.SeasonId); continue; } Logger.LogInformation("Refreshing episode {EpisodeName}. (Episode={EpisodeId},Episode={EpisodeId},Season={SeasonId})", episode.Name, episode.Id, episodeInfo.Id, episodeInfo.SeasonId); await MetadataRefreshService.RefreshEpisode(episode, refreshFieldsMask).ConfigureAwait(false); updateCount++; } } } return updateCount; } private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadataUpdatedEventArgs> changes, MetadataRefreshField refreshFieldsMask) { // Find movies and refresh them. var updateCount = 0; var episodeIds = changes .Where(e => e.EpisodeIds.Count > 0 && e.Reason is not UpdateReason.MetadataRemoved) .SelectMany(e => new List<string>([ ..e.EpisodeIds.Select(eI => eI.ToString()), ..(e.Kind is BaseItemKind.Movie && e.ProviderName is ProviderName.TMDB) ? [IdPrefix.TmdbMovie + e.ProviderId.ToString()] : Array.Empty<string>(), ..(e.Kind is BaseItemKind.Episode && e.ProviderName is ProviderName.TMDB) ? [IdPrefix.TmdbShow + e.ProviderId.ToString()] : Array.Empty<string>(), ])) .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 = [BaseItemKind.Movie], SourceTypes = [SourceType.Library], HasAnyProviderId = new Dictionary<string, string> { { ProviderNames.ShokoEpisode, episodeInfo.Id } }, DtoOptions = new(true), }) .DistinctBy(e => e.Id) .OfType<Movie>() .ToList(); foreach (var movie in movies) { if (!RecentlyUpdatedEntitiesDict.TryAdd(movie.Id, true)) { Logger.LogTrace("Movie is already being updated. (Movie={MovieId},Episode={EpisodeId},Season={SeasonId},ExtraSeasons={ExtraIds})", movie.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); continue; } Logger.LogInformation("Refreshing movie {MovieName}. (Movie={MovieId},Episode={EpisodeId},Season={SeasonId},ExtraSeasons={ExtraIds})", movie.Name, movie.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); await MetadataRefreshService.RefreshMovie(movie, refreshFieldsMask).ConfigureAwait(false); updateCount++; } } return updateCount; } #endregion } ================================================ FILE: Shokofin/Events/Interfaces/IFileEventArgs.cs ================================================ 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 managed folder the event was detected in. /// </summary> /// <value></value> int ManagedFolderId { get; } /// <summary> /// The relative path from the base of the <see cref="ManagedFolder"/> 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; } } } ================================================ FILE: Shokofin/Events/Interfaces/IFileRelocationEventArgs.cs ================================================ namespace Shokofin.Events.Interfaces; public interface IFileRelocationEventArgs : IFileEventArgs { /// <summary> /// The ID of the previous managed folder the event was detected in. /// </summary> /// <value></value> int PreviousManagedFolderId { get; } /// <summary> /// The relative path from the previous base of the /// <see cref="ManagedFolder"/> to where the <see cref="File"/> previously /// lied, with a leading slash applied at the start. /// </summary> string PreviousRelativePath { get; } } ================================================ FILE: Shokofin/Events/Interfaces/IMetadataUpdatedEventArgs.cs ================================================ 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> /// Indicates if this is an unknown update. /// </summary> bool IsUnknownUpdate => Reason is UpdateReason.None; /// <summary> /// Indicates if this is a metadata update. /// </summary> bool IsMetadataUpdate => Reason is UpdateReason.MetadataAdded or UpdateReason.MetadataUpdated or UpdateReason.MetadataRemoved; /// <summary> /// Indicates if this is an image update. /// </summary> bool IsImageUpdate => Reason is UpdateReason.ImageAdded or UpdateReason.ImageRemoved or UpdateReason.ImageUpdated; /// <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> string 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> /// Shoko episode ids affected by this update. /// </summary> IReadOnlyList<int> EpisodeIds { get; } /// <summary> /// Shoko series ids affected by this update. /// </summary> IReadOnlyList<int> SeriesIds { get; } } ================================================ FILE: Shokofin/Events/Interfaces/IReleaseSavedEventArgs.cs ================================================ namespace Shokofin.Events.Interfaces; public interface IReleaseSavedEventArgs { /// <summary> /// Shoko file id. /// </summary> int FileId { get; } } ================================================ FILE: Shokofin/Events/Interfaces/ProviderName.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.Events.Interfaces; [JsonConverter(typeof(JsonStringEnumConverter))] public enum ProviderName { None = 0, Shoko = 1, AniDB = 2, TMDB = 3, } ================================================ FILE: Shokofin/Events/Interfaces/UpdateReason.cs ================================================ using System.Text.Json.Serialization; namespace Shokofin.Events.Interfaces; [JsonConverter(typeof(JsonStringEnumConverter))] public enum UpdateReason { /// <summary> /// No reason specified. /// </summary> None = 0, /// <summary> /// Metadata was added. /// </summary> MetadataAdded = 1, /// <summary> /// Alias for <see cref="MetadataAdded"/>. /// </summary> Added = MetadataAdded, /// <summary> /// Metadata was updated. /// </summary> MetadataUpdated = 2, /// <summary> /// Alias for <see cref="MetadataUpdated"/>. /// </summary> Updated = MetadataUpdated, /// <summary> /// Metadata was removed. /// </summary> MetadataRemoved = 3, /// <summary> /// Alias for <see cref="MetadataRemoved"/>. /// </summary> Removed = MetadataRemoved, /// <summary> /// Images were added for the metadata. /// </summary> ImageAdded = 4, /// <summary> /// Images were updated for the metadata. /// </summary> ImageUpdated = 5, /// <summary> /// Images were removed for the metadata. /// </summary> ImageRemoved = 6, } ================================================ FILE: Shokofin/Events/MetadataRefreshService.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; using Shokofin.ExternalIds; using Shokofin.Providers; using ImageType = MediaBrowser.Model.Entities.ImageType; namespace Shokofin.Events; public class MetadataRefreshService { private BoxSetProvider? _boxSetProvider = null; private CustomBoxSetProvider? _customBoxSetProvider = null; private MovieProvider? _movieProvider = null; private CustomMovieProvider? _customMovieProvider = null; private SeriesProvider? _seriesProvider = null; private CustomSeriesProvider? _customSeriesProvider = null; private SeasonProvider? _seasonProvider = null; private CustomSeasonProvider? _customSeasonProvider = null; private EpisodeProvider? _episodeProvider = null; private CustomEpisodeProvider? _customEpisodeProvider = null; private TrailerProvider? _trailerProvider = null; private VideoProvider? _videoProvider = null; private readonly ILogger<MetadataRefreshService> _logger; private readonly IServerApplicationHost _applicationHost; private readonly ILibraryManager _libraryManager; private readonly IDirectoryService _directoryService; private readonly ShokoIdLookup _lookup; public MetadataRefreshService( ILogger<MetadataRefreshService> logger, IServerApplicationHost applicationHost, ILibraryManager libraryManager, IDirectoryService directoryService, ShokoIdLookup lookup ) { _logger = logger; _applicationHost = applicationHost; _libraryManager = libraryManager; _directoryService = directoryService; _lookup = lookup; } public async Task<bool> RefreshCollection(BoxSet boxSet, MetadataRefreshField refreshFieldsMask = MetadataRefreshField.None, CancellationToken cancellationToken = default) { var refreshFields = Plugin.Instance.Configuration.MetadataRefresh.Collection & refreshFieldsMask; return await RefreshInternal(boxSet, refreshFields, async () => { var updated = false; _boxSetProvider ??= _applicationHost.GetExports<BoxSetProvider>().First(); var metadataResult = await _boxSetProvider.GetMetadata(new() { Name = boxSet.Name, Path = boxSet.Path, MetadataLanguage = boxSet.GetPreferredMetadataLanguage(), MetadataCountryCode = boxSet.GetPreferredMetadataCountryCode(), IsAutomated = true, ProviderIds = boxSet.ProviderIds.ToDictionary(), }, cancellationToken).ConfigureAwait(false); if (metadataResult is not { HasMetadata: true, Item: { } metadata }) return updated; if (refreshFields.HasFlag(MetadataRefreshField.OwnedItems)) { var extras = boxSet.ExtraIds .Select(extraId => _libraryManager.GetItemById<Video>(extraId)!) .Where(i => i is not null) .ToArray(); foreach (var extra in extras) updated = await RefreshVideo(extra, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } _customBoxSetProvider ??= _applicationHost.GetExports<CustomBoxSetProvider>().First(); updated = await RefreshBaseItem(boxSet, metadata, metadataResult, refreshFields, _customBoxSetProvider, cancellationToken).ConfigureAwait(false) || updated; return updated; }).ConfigureAwait(false); } public async Task<bool> RefreshMovie(Movie movie, MetadataRefreshField refreshFieldsMask = MetadataRefreshField.None, CancellationToken cancellationToken = default) { var refreshFields = Plugin.Instance.Configuration.MetadataRefresh.Movie & refreshFieldsMask; return await RefreshInternal(movie, refreshFields, async () => { var updated = false; _movieProvider ??= _applicationHost.GetExports<MovieProvider>().First(); var metadataResult = await _movieProvider.GetMetadata(new() { Path = movie.Path, Name = movie.Name, MetadataLanguage = movie.GetPreferredMetadataLanguage(), MetadataCountryCode = movie.GetPreferredMetadataCountryCode(), IsAutomated = true, }, cancellationToken).ConfigureAwait(false); if (metadataResult is not { HasMetadata: true, Item: { } metadata }) return updated; if (refreshFields.HasFlag(MetadataRefreshField.OwnedItems)) { var extras = movie.ExtraIds .Select(extraId => _libraryManager.GetItemById<Video>(extraId)!) .Where(i => i is not null) .ToArray(); foreach (var extra in extras) updated = await RefreshVideo(extra, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } _customMovieProvider ??= _applicationHost.GetExports<CustomMovieProvider>().First(); updated = await RefreshBaseItem(movie, metadata, metadataResult, refreshFields, _customMovieProvider, cancellationToken).ConfigureAwait(false) || updated; if (movie.LinkedAlternateVersions.Length > 0) { foreach (var part in movie.LinkedAlternateVersions) { if (_libraryManager.FindByPath(part.Path, isFolder: false) is not Video video) continue; updated = await RefreshVideo(video, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } } if (movie.LocalAlternateVersions.Length > 0) { foreach (var part in movie.LocalAlternateVersions) { if (_libraryManager.FindByPath(part, isFolder: false) is not Video video) continue; updated = await RefreshVideo(video, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } } return updated; }).ConfigureAwait(false); } public async Task<bool> RefreshSeries(Series series, MetadataRefreshField refreshFieldsMask = MetadataRefreshField.None, CancellationToken cancellationToken = default) { var refreshFields = Plugin.Instance.Configuration.MetadataRefresh.Series & refreshFieldsMask; return await RefreshInternal(series, refreshFields, async () => { var updated = false; _seriesProvider ??= _applicationHost.GetExports<SeriesProvider>().First(); var metadataResult = await _seriesProvider.GetMetadata(new() { Path = series.Path, Name = series.Name, MetadataLanguage = series.GetPreferredMetadataLanguage(), MetadataCountryCode = series.GetPreferredMetadataCountryCode(), IsAutomated = true, }, cancellationToken).ConfigureAwait(false); if (metadataResult is not { HasMetadata: true, Item: { } metadata }) return updated; _customSeriesProvider ??= _applicationHost.GetExports<CustomSeriesProvider>().First(); updated = await RefreshBaseItem(series, metadata, metadataResult, refreshFields, _customSeriesProvider, cancellationToken).ConfigureAwait(false) || updated; if (refreshFields.HasFlag(MetadataRefreshField.OwnedItems)) { var extras = series.ExtraIds .Select(extraId => _libraryManager.GetItemById<Video>(extraId)!) .Where(i => i is not null) .ToArray(); foreach (var extra in extras) updated = await RefreshVideo(extra, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } if (refreshFields.HasFlag(MetadataRefreshField.Recursive)) { foreach (var season in series.Children.OfType<Season>()) updated = await RefreshSeason(season, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } return updated; }).ConfigureAwait(false); } public async Task<bool> RefreshSeason(Season season, MetadataRefreshField refreshFieldsMask = MetadataRefreshField.None, CancellationToken cancellationToken = default) { var refreshFields = Plugin.Instance.Configuration.MetadataRefresh.Season & refreshFieldsMask; return await RefreshInternal(season, refreshFields, async () => { var updated = false; if (season.Series is not { } series) return updated; _seasonProvider ??= _applicationHost.GetExports<SeasonProvider>().First(); var metadataResult = await _seasonProvider.GetMetadata(new() { Path = season.Path, Name = season.Name, IndexNumber = season.IndexNumber, MetadataLanguage = season.GetPreferredMetadataLanguage(), MetadataCountryCode = season.GetPreferredMetadataCountryCode(), SeriesProviderIds = series.ProviderIds.ToDictionary(), IsAutomated = true, }, cancellationToken).ConfigureAwait(false); if (metadataResult is not { HasMetadata: true, Item: { } metadata }) return updated; _customSeasonProvider ??= _applicationHost.GetExports<CustomSeasonProvider>().First(); updated = await RefreshBaseItem(season, metadata, metadataResult, refreshFields, _customSeasonProvider, cancellationToken).ConfigureAwait(false) || updated; if (refreshFields.HasFlag(MetadataRefreshField.OwnedItems)) { var extras = season.ExtraIds .Select(extraId => _libraryManager.GetItemById<Video>(extraId)!) .Where(i => i is not null) .ToArray(); foreach (var extra in extras) updated = await RefreshVideo(extra, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } if (refreshFields.HasFlag(MetadataRefreshField.Recursive)) { foreach (var episode in season.Children.OfType<Episode>()) updated = await RefreshEpisode(episode, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } return updated; }).ConfigureAwait(false); } public async Task<bool> RefreshEpisode(Episode episode, MetadataRefreshField refreshFieldsMask = MetadataRefreshField.None, CancellationToken cancellationToken = default) { var refreshFields = Plugin.Instance.Configuration.MetadataRefresh.Episode & refreshFieldsMask; return await RefreshInternal(episode, refreshFields, async () => { var updated = false; _episodeProvider ??= _applicationHost.GetExports<EpisodeProvider>().First(); var metadataResult = await _episodeProvider.GetMetadata(new() { Path = episode.Path, Name = episode.Name, MetadataLanguage = episode.GetPreferredMetadataLanguage(), MetadataCountryCode = episode.GetPreferredMetadataCountryCode(), IsMissingEpisode = episode.IsMissingEpisode, IsAutomated = true, }, cancellationToken).ConfigureAwait(false); if (metadataResult is not { HasMetadata: true, Item: { } metadata }) return updated; _customEpisodeProvider ??= _applicationHost.GetExports<CustomEpisodeProvider>().First(); updated = await RefreshBaseItem(episode, metadata, metadataResult, refreshFields, _customEpisodeProvider, cancellationToken).ConfigureAwait(false) || updated; if (episode.AdditionalParts.Length > 0) { foreach (var part in episode.AdditionalParts) { if (_libraryManager.FindByPath(part, isFolder: false) is not Video video) continue; updated = await RefreshVideo(video, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } } if (refreshFields.HasFlag(MetadataRefreshField.OwnedItems)) { var extras = episode.ExtraIds .Select(extraId => _libraryManager.GetItemById<Video>(extraId)!) .Where(i => i is not null) .ToArray(); foreach (var extra in extras) updated = await RefreshVideo(extra, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } if (episode.LinkedAlternateVersions.Length > 0) { foreach (var part in episode.LinkedAlternateVersions) { if (_libraryManager.FindByPath(part.Path, isFolder: false) is not Video video) continue; updated = await RefreshVideo(video, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } } if (episode.LocalAlternateVersions.Length > 0) { foreach (var part in episode.LocalAlternateVersions) { if (_libraryManager.FindByPath(part, isFolder: false) is not Video video) continue; updated = await RefreshVideo(video, refreshFieldsMask, cancellationToken).ConfigureAwait(false) || updated; } } return updated; }).ConfigureAwait(false); } public async Task<bool> RefreshVideo(Video video, MetadataRefreshField refreshFieldsMask = MetadataRefreshField.None, CancellationToken cancellationToken = default) { var refreshFields = Plugin.Instance.Configuration.MetadataRefresh.Video & refreshFieldsMask; return await RefreshInternal(video, refreshFields, async () => { var updated = false; if (video is Trailer trailer) { _trailerProvider ??= _applicationHost.GetExports<TrailerProvider>().First(); var metadataResult = await _trailerProvider.GetMetadata(new() { Path = trailer.Path, MetadataLanguage = trailer.GetPreferredMetadataLanguage(), MetadataCountryCode = trailer.GetPreferredMetadataCountryCode(), IsAutomated = true, }, cancellationToken).ConfigureAwait(false); if (metadataResult is not { HasMetadata: true, Item: { } metadata }) return updated; updated = await RefreshBaseItem(trailer, metadata, metadataResult, refreshFields, cancellationToken: cancellationToken).ConfigureAwait(false) || updated; } else { _videoProvider ??= _applicationHost.GetExports<VideoProvider>().First(); var metadataResult = await _videoProvider.GetMetadata(new() { Path = video.Path, MetadataLanguage = video.GetPreferredMetadataLanguage(), MetadataCountryCode = video.GetPreferredMetadataCountryCode(), IsAutomated = true, }, cancellationToken); if (metadataResult is not { HasMetadata: true, Item: { } metadata }) return updated; updated = await RefreshBaseItem(video, metadata, metadataResult, refreshFields, cancellationToken: cancellationToken).ConfigureAwait(false) || updated; } return updated; }).ConfigureAwait(false); } private async Task<bool> RefreshInternal(BaseItem item, MetadataRefreshField refreshFields, Func<Task<bool>> refreshLambda) { if (!_lookup.IsEnabledForItem(item)) return await LegacyRefreshMetadata(item, updateImages: true, recursive: true).ConfigureAwait(false); var updated = false; if (refreshFields.HasFlag(MetadataRefreshField.LegacyRefresh)) updated = await LegacyRefreshMetadata(item, refreshFields.HasFlag(MetadataRefreshField.Images), refreshFields.HasFlag(MetadataRefreshField.Recursive)).ConfigureAwait(false); if (refreshFields is MetadataRefreshField.Images or (MetadataRefreshField.Images | MetadataRefreshField.Recursive)) updated = await LegacyRefreshImages(item, refreshFields.HasFlag(MetadataRefreshField.Recursive)).ConfigureAwait(false); if ((refreshFields & ~(MetadataRefreshField.LegacyRefresh | MetadataRefreshField.Images | MetadataRefreshField.Recursive)) is not MetadataRefreshField.None) updated = await refreshLambda().ConfigureAwait(false) || updated; return updated; } private async Task<bool> RefreshBaseItem<T>( T item, T metadata, MetadataResult<T> metadataResult, MetadataRefreshField refreshFields, ICustomMetadataProvider<T>? customMetadataProvider = null, CancellationToken cancellationToken = default ) where T : BaseItem { var updatedFields = new List<string>(); if (refreshFields.HasFlag(MetadataRefreshField.TitlesAndOverview)) { if (!item.LockedFields.Contains(MetadataField.Name) && !string.Equals(metadata.Name, item.Name, StringComparison.Ordinal)) { item.Name = metadata.Name; updatedFields.Add(nameof(BaseItem.Name)); } if (!string.Equals(metadata.OriginalTitle, item.OriginalTitle, StringComparison.Ordinal)) { item.OriginalTitle = metadata.OriginalTitle; updatedFields.Add(nameof(BaseItem.OriginalTitle)); } if (!item.LockedFields.Contains(MetadataField.Overview) && !string.Equals(metadata.Overview, item.Overview, StringComparison.Ordinal)) { item.Overview = metadata.Overview; updatedFields.Add(nameof(BaseItem.Overview)); } } if (refreshFields.HasFlag(MetadataRefreshField.Dates)) { if (item.PremiereDate != metadata.PremiereDate) { item.PremiereDate = metadata.PremiereDate; updatedFields.Add(nameof(BaseItem.PremiereDate)); } if (item.EndDate != metadata.EndDate) { item.EndDate = metadata.EndDate; updatedFields.Add(nameof(BaseItem.EndDate)); } if (item.ProductionYear != metadata.ProductionYear) { item.ProductionYear = metadata.ProductionYear; updatedFields.Add(nameof(BaseItem.ProductionYear)); } if (!item.LockedFields.Contains(MetadataField.Runtime) && metadata is Video { RunTimeTicks: > 0 } && item.RunTimeTicks != metadata.RunTimeTicks) { item.RunTimeTicks = metadata.RunTimeTicks; updatedFields.Add(nameof(BaseItem.RunTimeTicks)); } } if (refreshFields.HasFlag(MetadataRefreshField.TagsAndGenres)) { if (!item.LockedFields.Contains(MetadataField.Tags) && ((item.Tags == null && metadata.Tags != null) || (item.Tags != null && metadata.Tags != null && !item.Tags.SequenceEqual(metadata.Tags)))) { item.Tags = metadata.Tags; updatedFields.Add(nameof(BaseItem.Tags)); } if (!item.LockedFields.Contains(MetadataField.Genres) && ((item.Genres == null && metadata.Genres != null) || (item.Genres != null && metadata.Genres != null && !item.Genres.SequenceEqual(metadata.Genres)))) { item.Genres = metadata.Genres; updatedFields.Add(nameof(BaseItem.Genres)); } } if (refreshFields.HasFlag(MetadataRefreshField.StudiosAndProductionLocations)) { if (!item.LockedFields.Contains(MetadataField.Studios) && ((item.Studios == null && metadata.Studios != null) || (item.Studios != null && metadata.Studios != null && !item.Studios.SequenceEqual(metadata.Studios)))) { item.Studios = metadata.Studios; updatedFields.Add(nameof(BaseItem.Studios)); } if (!item.LockedFields.Contains(MetadataField.ProductionLocations) && (item.ProductionLocations == null && metadata.ProductionLocations != null || item.ProductionLocations != null && metadata.ProductionLocations != null && !item.ProductionLocations.SequenceEqual(metadata.ProductionLocations))) { item.ProductionLocations = metadata.ProductionLocations; updatedFields.Add(nameof(BaseItem.ProductionLocations)); } } if (refreshFields.HasFlag(MetadataRefreshField.ContentRatings)) { if (!item.LockedFields.Contains(MetadataField.OfficialRating) && !string.Equals(metadata.OfficialRating, item.OfficialRating, StringComparison.Ordinal)) { item.OfficialRating = metadata.OfficialRating; updatedFields.Add(nameof(BaseItem.OfficialRating)); } if ((item.CommunityRating == null && metadata.CommunityRating != null) || (item.CommunityRating != null && metadata.CommunityRating != null && item.CommunityRating != metadata.CommunityRating)) { item.CommunityRating = metadata.CommunityRating; updatedFields.Add(nameof(BaseItem.CommunityRating)); } if (!string.Equals(metadata.CustomRating, item.CustomRating, StringComparison.Ordinal)) { item.CustomRating = metadata.CustomRating; updatedFields.Add(nameof(BaseItem.CustomRating)); } } if (refreshFields.HasFlag(MetadataRefreshField.Images) || refreshFields.HasFlag(MetadataRefreshField.PreferredImages)) { if (refreshFields.HasFlag(MetadataRefreshField.Images)) { // TODO: Maybe switch from "legacy" refreshing of images to a custom method only using the Shoko provider? if (await LegacyRefreshImages(item).ConfigureAwait(false)) { updatedFields.Add(nameof(item.ImageInfos)); } } if (refreshFields.HasFlag(MetadataRefreshField.PreferredImages)) { // TODO: Reorder the images so our preferred image is placed first. // As for how. Idk. We don't have any anchors to attach to and use. Since the image infos are local file // system paths, while our preferred image is an id / remote url. We would need to maybe get the size of the preferred image // and compare that against the on-disk images to determine which is the preferred image, or similar. And if it's not in // the list, save it locally and add it to the list, the reorder it to appear first. } } if (refreshFields.HasFlag(MetadataRefreshField.ProviderIds)) { var updatedProviders = false; foreach (var (providerId, expectedValue) in metadata.ProviderIds) { if (!item.ProviderIds.TryGetValue(providerId, out string? currentValue) || currentValue != expectedValue) { item.ProviderIds[providerId] = expectedValue; updatedProviders = true; } } if (updatedProviders) { updatedFields.Add(nameof(BaseItem.ProviderIds)); } } if (refreshFields.HasFlag(MetadataRefreshField.CustomProvider) && customMetadataProvider is not null) { var updatedItemType = await customMetadataProvider.FetchAsync( item, new(_directoryService) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh }, cancellationToken ).ConfigureAwait(false); if (updatedItemType is not ItemUpdateType.None) { updatedFields.Add(nameof(MetadataRefreshField.CustomProvider)); } } if ( metadata.SupportsPeople && refreshFields.HasFlag(MetadataRefreshField.CastAndCrew) && !item.LockedFields.Contains(MetadataField.Cast) && metadataResult.People.Count > 0 ) { await _libraryManager.UpdatePeopleAsync(item, metadataResult.People, cancellationToken).ConfigureAwait(false); updatedFields.Add(nameof(MetadataRefreshField.CastAndCrew)); } #pragma warning disable CA2254 // Template should be a static expression var reason = updatedFields.Count > 0 ? ItemUpdateType.MetadataImport : ItemUpdateType.None; _logger.LogDebug($"Updating fields for {item.GetBaseItemKind()} {{ItemName}} (Id={{Guid}},Reason={{Reason}},UpdatedFields={{UpdatedFieldList}})", item.Name, item.Id, reason, updatedFields); item.DateLastRefreshed = DateTime.UtcNow; await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); _logger.LogDebug($"Updated fields for {item.GetBaseItemKind()} {{ItemName}} (Id={{Guid}},Reason={{Reason}},UpdatedFields={{UpdatedFieldList}})", item.Name, item.Id, reason, updatedFields); #pragma warning restore CA2254 // Template should be a static expression return updatedFields.Count > 0; } private async Task<bool> LegacyRefreshMetadata(BaseItem item, bool updateImages = false, bool recursive = false) { var updateType = await item.RefreshMetadata(new(_directoryService) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ImageRefreshMode = updateImages ? MetadataRefreshMode.FullRefresh : MetadataRefreshMode.None, ReplaceAllMetadata = true, ReplaceAllImages = updateImages, RemoveOldMetadata = true, ReplaceImages = updateImages ? Enum.GetValues<ImageType>().ToArray() : [], IsAutomated = true, EnableRemoteContentProbe = true, RefreshPaths = recursive ? null : [item.Path ?? string.Empty], }, CancellationToken.None).ConfigureAwait(false); return updateType is not ItemUpdateType.None; } private async Task<bool> LegacyRefreshImages(BaseItem item, bool recursive = false) { var updateType = await item.RefreshMetadata(new(_directoryService) { MetadataRefreshMode = MetadataRefreshMode.None, ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllMetadata = false, ReplaceAllImages = true, RemoveOldMetadata = true, ReplaceImages = Enum.GetValues<ImageType>().ToArray(), IsAutomated = true, EnableRemoteContentProbe = true, RefreshPaths = recursive ? null : [item.Path ?? string.Empty], }, CancellationToken.None).ConfigureAwait(false); return updateType is not ItemUpdateType.None; } public async Task AutoRefresh(IProgress<double>? progress = null, CancellationToken cancellationToken = default) { // Get all movies, series, seasons, and episodes and refresh each one based on the configuration's fields per item var refreshFieldsMask = ~MetadataRefreshField.Recursive; var config = Plugin.Instance.Configuration.MetadataRefresh; var movieList = GetMovies(config); var episodeList = GetEpisodes(config); var seasonList = episodeList .DistinctBy(ep => ep.SeasonId) .Select(ep => ep.Season) .Where(season => season is not null) .ToList(); var seriesList = episodeList .DistinctBy(ep => ep.SeriesId) .Select(ep => ep.Series) .Where(series => series is not null) .ToList(); foreach (var movie in movieList) await RefreshMovie(movie, refreshFieldsMask, cancellationToken).ConfigureAwait(false); foreach (var series in seriesList) await RefreshSeries(series, refreshFieldsMask, cancellationToken).ConfigureAwait(false); foreach (var season in seasonList) await RefreshSeason(season, refreshFieldsMask, cancellationToken).ConfigureAwait(false); foreach (var episode in episodeList) await RefreshEpisode(episode, refreshFieldsMask, cancellationToken).ConfigureAwait(false); } private List<Movie> GetMovies(MetadataRefreshConfiguration config) => _libraryManager.GetItemList(new() { IncludeItemTypes = [BaseItemKind.Movie], SourceTypes = [SourceType.Library], HasAnyProviderId = new() { { ProviderNames.ShokoFile, string.Empty } }, IsVirtualItem = config.UpdateUnaired ? null : false, Recursive = true, }) .Where(FilterBaseItem(config)) .Cast<Movie>() .ToList(); private List<Episode> GetEpisodes(MetadataRefreshConfiguration config) => _libraryManager.GetItemList(new() { IncludeItemTypes = [BaseItemKind.Episode], SourceTypes = [SourceType.Library], HasAnyProviderId = config.UpdateUnaired ? new() { { ShokoInternalId.Name, string.Empty } } : new() { { ProviderNames.ShokoFile, string.Empty } }, IsVirtualItem = config.UpdateUnaired ? null : false, Recursive = true, }) .Where(FilterBaseItem(config)) .Cast<Episode>() .ToList(); private Func<BaseItem, bool> FilterBaseItem(MetadataRefreshConfiguration config) { var updateUnaired = config.UpdateUnaired; var upperThreshold = config.UpdateUnaired ? (DateTime?)null : DateTime.UtcNow; var lowerThreshold = config.AutoRefreshRangeInDays > 0 ? DateTime.UtcNow.AddDays(-config.AutoRefreshRangeInDays) : (DateTime?)null; var minAge = config.AntiRefreshDeadZoneInHours > 0 ? DateTime.UtcNow.AddHours(-config.AntiRefreshDeadZoneInHours) : (DateTime?)null; var outOfSync = config.OutOfSyncInDays > 0 ? DateTime.UtcNow.AddDays(-config.OutOfSyncInDays) : (DateTime?)null; if (outOfSync.HasValue && minAge.HasValue && outOfSync < minAge) { minAge = null; } return item => { if (minAge is not null && item.DateLastRefreshed > minAge) { return false; } if (outOfSync is not null && item.DateLastRefreshed < outOfSync) { return !updateUnaired && !item.IsVirtualItem; } return _lookup.IsEnabledForItem(item) && item.PremiereDate is { } premiereDate && premiereDate > lowerThreshold && (upperThreshold is null || premiereDate < upperThreshold); }; } } ================================================ FILE: Shokofin/Events/Stub/FileEventArgsStub.cs ================================================ 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 ManagedFolderId { 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 managedFolderId, string relativePath, IEnumerable<IFileEventArgs.FileCrossReference> xrefs) { FileId = fileId; FileLocationId = fileLocationId; ManagedFolderId = managedFolderId; RelativePath = relativePath; CrossReferences = xrefs.ToList(); } public FileEventArgsStub(File.Location location, File file) { FileId = file.Id; FileLocationId = location.Id; ManagedFolderId = location.ManagedFolderId; 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(); } } ================================================ FILE: Shokofin/Extensions/CollectionTypeExtensions.cs ================================================ using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; namespace Shokofin.Extensions; public static class CollectionTypeExtensions { public static CollectionType? ConvertToCollectionType(this CollectionTypeOptions? collectionType) => collectionType switch { CollectionTypeOptions.movies => CollectionType.movies, CollectionTypeOptions.tvshows => CollectionType.tvshows, CollectionTypeOptions.music => CollectionType.music, CollectionTypeOptions.musicvideos => CollectionType.musicvideos, CollectionTypeOptions.homevideos => CollectionType.homevideos, CollectionTypeOptions.boxsets => CollectionType.boxsets, CollectionTypeOptions.books => CollectionType.books, null => null, _ => CollectionType.unknown, }; } ================================================ FILE: Shokofin/Extensions/EnumerableExtensions.cs ================================================ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; namespace Shokofin.Extensions; public static class EnumerableExtensions { [return: NotNullIfNotNull(nameof(enumerable))] public static IEnumerable<T>? WhereNotNull<T>(this IEnumerable<T?>? enumerable) => enumerable?.Where(a => a is not null).Select(a => a!); [return: NotNullIfNotNull(nameof(enumerable))] public static IEnumerable<T>? WhereNotNull<T>(this IEnumerable<T?>? enumerable) where T : struct => enumerable?.Where(a => a is not null).Select(a => a!.Value); [return: NotNullIfNotNull(nameof(enumerable))] public static IEnumerable<T>? WhereNotNullOrDefault<T>(this IEnumerable<T?>? enumerable) => enumerable?.Where(a => a is not null && !Equals(a, default(T))).Select(a => a!); [return: NotNullIfNotNull(nameof(enumerable))] public static IEnumerable<T>? WhereNotNullOrDefault<T>(this IEnumerable<T?>? enumerable) where T : struct => enumerable?.Where(a => a is not null && !Equals(a, default(T))).Select(a => a!.Value); } ================================================ FILE: Shokofin/Extensions/EpisodeTypeExtensions.cs ================================================ using Shokofin.API.Models; namespace Shokofin.Extensions; public static class EpisodeTypeExtensions { public static string ToShortString(this EpisodeType episodeType) => episodeType switch { EpisodeType.Episode => "E", EpisodeType.Special => "SP", EpisodeType.Trailer => "T", EpisodeType.Other => "O", EpisodeType.Credits => "C", EpisodeType.Parody => "P", _ => "?", }; } ================================================ FILE: Shokofin/Extensions/ListExtensions.cs ================================================ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace Shokofin.Extensions; 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; } public static IEnumerable<T> GetRange<T>(this IReadOnlyList<T> list, int start, int end) { if (start < 0 || start >= list.Count) yield break; for (var index = 0; index < end - start; index++) { yield return list[start + index]; } } } ================================================ FILE: Shokofin/Extensions/MediaFolderConfigurationExtensions.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using MediaBrowser.Controller.Entities; using Shokofin.Configuration; namespace Shokofin.Extensions; 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 managedFolderId, string managedFolderSubPath, IReadOnlyList<string> mediaFolderPaths)> ToManagedFolderList(this IEnumerable<MediaFolderConfiguration> mediaConfigs) => mediaConfigs .GroupBy(a => (a.ManagedFolderId, a.ManagedFolderRelativePath)) .Select(g => (g.Key.ManagedFolderId, g.Key.ManagedFolderRelativePath, g.Select(a => a.Path).ToList() as IReadOnlyList<string>)) .ToList(); public static IReadOnlyList<(string managedFolderSubPath, bool vfsEnabled, IReadOnlyList<string> mediaFolderPaths)> ToManagedFolderList(this IEnumerable<MediaFolderConfiguration> mediaConfigs, int managedFolderId, string relativePath) => mediaConfigs .Where(a => a.ManagedFolderId == managedFolderId && a.IsEnabledForPath(relativePath)) .GroupBy(a => (a.ManagedFolderId, a.ManagedFolderRelativePath, a.Library.IsVirtualFileSystemEnabled)) .Select(g => (g.Key.ManagedFolderRelativePath, g.Key.IsVirtualFileSystemEnabled, g.Select(a => a.Path).ToList() as IReadOnlyList<string>)) .ToList(); } ================================================ FILE: Shokofin/Extensions/StringExtensions.cs ================================================ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Web; using MediaBrowser.Common.Providers; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Shokofin.ExternalIds; using Video = MediaBrowser.Controller.Entities.Video; using Movie = MediaBrowser.Controller.Entities.Movies.Movie; using MovieInfo = MediaBrowser.Controller.Providers.MovieInfo; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; using TvSeriesInfo = MediaBrowser.Controller.Providers.SeriesInfo; namespace Shokofin.Extensions; public static partial 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()).WhereNotNull().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; } [GeneratedRegex(@"\.pt(?<partNumber>\d+)(?:\.[a-z0-9]+)?$", RegexOptions.IgnoreCase)] private static partial Regex GetPartRegex(); public static bool TryGetAttributeValue(this string text, string attribute, [NotNullWhen(true)] out string? value) { value = GetAttributeValue(text, attribute); // Select the correct id for the part number in the stringified list of file ids. if (!string.IsNullOrEmpty(value) && attribute == ProviderNames.ShokoFile && GetPartRegex().Match(text) is { Success: true } regexResult) { var partNumber = int.Parse(regexResult.Groups["partNumber"].Value); var index = partNumber - 1; value = value.Split(',')[index]; } return !string.IsNullOrEmpty(value); } public static bool TryGetSeasonId(this IHasProviderIds providerIds, [NotNullWhen(true)] out string? seasonId) { if ( providerIds is TvSeries { Path.Length: > 0 } series && series.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar) && series.Path.TryGetAttributeValue(ProviderNames.ShokoSeries, out seasonId) ) { return true; } if ( providerIds is TvSeriesInfo { Path.Length: > 0 } seriesInfo && seriesInfo.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar) && seriesInfo.Path.TryGetAttributeValue(ProviderNames.ShokoSeries, out seasonId) ) { return true; } if (providerIds is Movie { Path.Length: > 0 } movie && movie.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar) && Path.GetDirectoryName(movie.Path) is { Length: > 0 } movieDir && movieDir.TryGetAttributeValue(ProviderNames.ShokoSeries, out seasonId)) { return true; } if (providerIds is MovieInfo { Path.Length: > 0 } movieInfo && movieInfo.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar) && Path.GetDirectoryName(movieInfo.Path) is { Length: > 0 } movieInfoDir && movieInfoDir.TryGetAttributeValue(ProviderNames.ShokoSeries, out seasonId)) { return true; } if (!providerIds.TryGetProviderId(ShokoInternalId.Name, out var internalId)) { seasonId = null; return false; } return TryGetSeasonIdFromInternalId(internalId, out seasonId); } public static bool TryGetSeasonId(this SeasonInfo seasonInfo, [NotNullWhen(true)] out string? seasonId) { if (!seasonInfo.SeriesProviderIds.TryGetValue(ShokoInternalId.Name, out var internalId) || string.IsNullOrEmpty(internalId)) { seasonId = null; return false; } return TryGetSeasonIdFromInternalId(internalId, out seasonId); } public static bool TryGetSeasonIdFromInternalId(this string internalId, [NotNullWhen(true)] out string? seasonId) { if (internalId.StartsWith(ShokoInternalId.SeriesNamespace, StringComparison.OrdinalIgnoreCase)) { seasonId = internalId[ShokoInternalId.SeriesNamespace.Length..]; // Fix for dev users with the double namespace issue. if (!string.IsNullOrEmpty(seasonId) && seasonId.StartsWith(ShokoInternalId.SeriesNamespace, StringComparison.OrdinalIgnoreCase)) seasonId = seasonId[ShokoInternalId.SeriesNamespace.Length..]; return !string.IsNullOrEmpty(seasonId); } if (internalId.StartsWith(ShokoInternalId.FileNamespace, StringComparison.OrdinalIgnoreCase)) { var uri = new Uri(internalId); var query = HttpUtility.ParseQueryString(uri.Query); seasonId = query["seasonId"]; return !string.IsNullOrEmpty(seasonId); } seasonId = null; return false; } public static bool TryGetEpisodeId(this IHasProviderIds providerIds, [NotNullWhen(true)] out string? episodeId) { if (providerIds is Movie { Path.Length: > 0 } movie && movie.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar) && Path.GetDirectoryName(movie.Path) is { Length: > 0 } movieDir && movieDir.TryGetAttributeValue(ProviderNames.ShokoEpisode, out episodeId)) { return true; } if (!providerIds.TryGetProviderId(ShokoInternalId.Name, out var internalId) || string.IsNullOrEmpty(internalId)) { // TODO: Remove this backwards compatibility in the next major version. if (providerIds.TryGetProviderId(ProviderNames.ShokoEpisode, out episodeId)) { return true; } episodeId = null; return false; } episodeId = null; return false; } public static bool TryGetEpisodeIds(this IHasProviderIds providerIds, [NotNullWhen(true)] out List<string>? episodeIds) { if (!providerIds.TryGetProviderId(ShokoInternalId.Name, out var internalId) || string.IsNullOrEmpty(internalId)) { episodeIds = null; return false; } if (internalId.StartsWith(ShokoInternalId.FileNamespace, StringComparison.OrdinalIgnoreCase)) { var uri = new Uri(internalId); var query = HttpUtility.ParseQueryString(uri.Query); episodeIds = query["episodeIds"]?.Split(',').ToList(); return episodeIds is { Count: > 0 }; } if (internalId.StartsWith(ShokoInternalId.EpisodeNamespace, StringComparison.OrdinalIgnoreCase)) { var episodeId = internalId[ShokoInternalId.EpisodeNamespace.Length..]; episodeIds = string.IsNullOrEmpty(episodeId) ? null : [episodeId]; return episodeIds is { Count: > 0 }; } episodeIds = null; return false; } public static bool TryGetFileAndSeriesId(this IHasProviderIds providerIds, [NotNullWhen(true)] out string? fileId, [NotNullWhen(true)] out string? seriesId, bool vfsOnly = false) { if ( providerIds is Video { Path.Length: > 0 } video && video.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar) && Path.GetFileNameWithoutExtension(video.Path) is { Length: > 0 } filename && filename.TryGetAttributeValue(ProviderNames.ShokoSeries, out seriesId) && filename.TryGetAttributeValue(ProviderNames.ShokoFile, out fileId) ) { return true; } // Abort now if we only want to check videos in the VFS. if (vfsOnly) { fileId = null; seriesId = null; return false; } if (!providerIds.TryGetProviderId(ShokoInternalId.Name, out var internalId) || string.IsNullOrEmpty(internalId)) { // TODO: Remove this backwards compatibility in the next major version. if (providerIds.TryGetProviderId(ProviderNames.ShokoFile, out fileId) && providerIds.TryGetProviderId(ProviderNames.ShokoSeries, out seriesId)) return true; fileId = null; seriesId = null; return false; } if (internalId.StartsWith(ShokoInternalId.FileNamespace, StringComparison.OrdinalIgnoreCase)) { var uri = new Uri(internalId); var query = HttpUtility.ParseQueryString(uri.Query); fileId = uri.Segments[^1]; seriesId = query["seriesId"]; return !string.IsNullOrEmpty(fileId) && !string.IsNullOrEmpty(seriesId); } fileId = null; seriesId = null; return false; } } ================================================ FILE: Shokofin/Extensions/SyncExtensions.cs ================================================ using System; using MediaBrowser.Controller.Entities; using Shokofin.API.Models; namespace Shokofin.Extensions; 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 bool CopyFrom(this UserItemData userData, UserItemData otherUserData) { var updated = false; if (!userData.Rating.HasValue && otherUserData.Rating.HasValue || userData.Rating.HasValue && otherUserData.Rating.HasValue && userData.Rating != otherUserData.Rating) { userData.Rating = otherUserData.Rating; updated = true; } if (userData.PlaybackPositionTicks != otherUserData.PlaybackPositionTicks) { userData.PlaybackPositionTicks = otherUserData.PlaybackPositionTicks; updated = true; } if (userData.PlayCount != otherUserData.PlayCount) { userData.PlayCount = otherUserData.PlayCount; updated = true; } if (!userData.IsFavorite != otherUserData.IsFavorite) { userData.IsFavorite = otherUserData.IsFavorite; updated = true; } if (!userData.LastPlayedDate.HasValue && otherUserData.LastPlayedDate.HasValue || userData.LastPlayedDate.HasValue && otherUserData.LastPlayedDate.HasValue && userData.LastPlayedDate < otherUserData.LastPlayedDate) { userData.LastPlayedDate = otherUserData.LastPlayedDate; updated = true; } if (userData.Played != otherUserData.Played) { userData.Played = otherUserData.Played; updated = true; } if (!userData.AudioStreamIndex.HasValue && otherUserData.AudioStreamIndex.HasValue || userData.AudioStreamIndex.HasValue && otherUserData.AudioStreamIndex.HasValue && userData.AudioStreamIndex != otherUserData.AudioStreamIndex) { userData.AudioStreamIndex = otherUserData.AudioStreamIndex; updated = true; } if (!userData.SubtitleStreamIndex.HasValue && otherUserData.SubtitleStreamIndex.HasValue || userData.SubtitleStreamIndex.HasValue && otherUserData.SubtitleStreamIndex.HasValue && userData.SubtitleStreamIndex != otherUserData.SubtitleStreamIndex) { userData.SubtitleStreamIndex = otherUserData.SubtitleStreamIndex; updated = true; } if (!userData.Likes.HasValue && otherUserData.Likes.HasValue || userData.Likes.HasValue && otherUserData.Likes.HasValue && userData.Likes != otherUserData.Likes) { userData.Likes = otherUserData.Likes; updated = true; } return updated; } 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) => new() { Key = video.GetUserDataKeys()[0], Played = userStats.LastWatchedAt.HasValue, PlayCount = userStats.WatchedCount, PlaybackPositionTicks = userStats.ResumePosition?.Ticks ?? 0, LastPlayedDate = userStats.ResumePosition.HasValue ? userStats.LastUpdatedAt : userStats.LastWatchedAt ?? userStats.LastUpdatedAt, }; } ================================================ FILE: Shokofin/ExternalIds/AnidbAnimeId.cs ================================================ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; namespace Shokofin.ExternalIds; public class AnidbAnimeId : IExternalId { #region IExternalId Implementation string IExternalId.ProviderName => ProviderNames.Anidb; string IExternalId.Key => ProviderNames.Anidb; ExternalIdMediaType? IExternalId.Type => ExternalIdMediaType.Series; public bool Supports(IHasProviderIds item) => item is Series or Season; #if NET9_0 #else string? IExternalId.UrlFormatString => null; #endif #endregion } ================================================ FILE: Shokofin/ExternalIds/AnidbCreatorId.cs ================================================ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; namespace Shokofin.ExternalIds; public class AnidbCreatorId : IExternalId { #region IExternalId Implementation string IExternalId.ProviderName => ProviderNames.Anidb; string IExternalId.Key => ProviderNames.Anidb; ExternalIdMediaType? IExternalId.Type => ExternalIdMediaType.Person; public bool Supports(IHasProviderIds item) => item is Person; #if NET9_0 #else string? IExternalId.UrlFormatString => null; #endif #endregion } ================================================ FILE: Shokofin/ExternalIds/AnidbEpisodeId.cs ================================================ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; namespace Shokofin.ExternalIds; public class AnidbEpisodeId : IExternalId { #region IExternalId Implementation string IExternalId.ProviderName => ProviderNames.Anidb; string IExternalId.Key => ProviderNames.Anidb; ExternalIdMediaType? IExternalId.Type => ExternalIdMediaType.Episode; public bool Supports(IHasProviderIds item) => item is Episode; #if NET9_0 #else string? IExternalId.UrlFormatString => null; #endif #endregion } ================================================ FILE: Shokofin/ExternalIds/ProviderNames.cs ================================================ namespace Shokofin.ExternalIds; public struct ProviderNames { public const string Anidb = "AniDB"; public const string Tmdb = "TheMovieDb"; public const string Tvdb = "TheTVDB"; public const string Shoko = "Shoko"; public const string ShokoGroup = "Shoko Group"; public const string ShokoSeries = "Shoko Series"; public const string ShokoEpisode = "Shoko Episode"; public const string ShokoFile = "Shoko File"; public const string ShokoCollectionForGroup = "ShokoGroup"; public const string ShokoCollectionForSeries = "ShokoSeries"; } ================================================ FILE: Shokofin/ExternalIds/ProviderUrls.cs ================================================ namespace Shokofin.ExternalIds; public struct ProviderUrls { public const string Anidb = "https://anidb.net"; public const string Tmdb = "https://www.themoviedb.org"; public const string Tvdb = "https://thetvdb.com"; } ================================================ FILE: Shokofin/ExternalIds/ShokoExternalUrlHandler.cs ================================================ using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Shokofin.API; using Shokofin.Extensions; namespace Shokofin.ExternalIds; public class ShokoExternalUrlHandler(ShokoIdLookup lookup) : IExternalUrlProvider { #region IExternalUrlProvider Implementation private readonly Queue<string> _nextNames = new(); private readonly object _lock = new(); string IExternalUrlProvider.Name => _nextNames.TryDequeue(out var name) ? name : "Shoko"; /// <inheritdoc/> IEnumerable<string> IExternalUrlProvider.GetExternalUrls(BaseItem item) { if (!lookup.IsEnabledForItem(item)) yield break; var list = GetExternalUrls(item); lock (_lock) { _nextNames.Clear(); foreach (var (name, url1) in list) { _nextNames.Enqueue(name); yield return url1; } } } #endregion #region Url Helpers private static IReadOnlyCollection<(string, string)> GetExternalUrls(BaseItem item) => (item, item.GetProviderId(ProviderNames.Shoko)) switch { (_, string deflatedUrls) => [..InflateInfoUrls(deflatedUrls).Distinct()], (BoxSet boxSet, _) => [..GetCollectionUrls(boxSet)], (Person person, _) => [..GetPersonUrls(person)], _ => [], }; private static IEnumerable<(string Name, string Url)> GetCollectionUrls(BoxSet boxSet) { var config = Plugin.Instance.Configuration; var url = config.WebUrl; if (boxSet.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForGroup, out var collectionId)) { yield return ($"{ProviderNames.ShokoGroup} (g{collectionId})", $"{url}/collection/group/{collectionId}"); } if (boxSet.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForSeries, out var seasonId)) { if (seasonId[0] is not IdPrefix.TmdbMovie and not IdPrefix.TmdbMovieCollection and not IdPrefix.TmdbShow) yield return ($"{ProviderNames.ShokoSeries} (s{seasonId})", $"{url}/collection/series/{seasonId}"); } } private static IEnumerable<(string Name, string Url)> GetPersonUrls(Person person) { if (person.TryGetProviderId(ProviderNames.Anidb, out var creatorId)) { yield return ($"{ProviderNames.Anidb} (c{creatorId})", $"{ProviderUrls.Anidb}/creator/{creatorId}"); } } #endregion #region Inflate / Deflate private static byte[] Deflate(byte[] data) { using var ms = new MemoryStream(); using (var ds = new DeflateStream(ms, CompressionLevel.SmallestSize, true)) ds.Write(data, 0, data.Length); return ms.ToArray(); } private static byte[] Inflate(byte[] compressed) { using var input = new MemoryStream(compressed); using var ds = new DeflateStream(input, CompressionMode.Decompress); using var output = new MemoryStream(); ds.CopyTo(output); return output.ToArray(); } private static string DeflateInfoUrls(IEnumerable<(string ProviderName, string Extras, string UrlPathname)> urls) => Convert.ToBase64String(Deflate(Encoding.UTF8.GetBytes(string.Join('\n', urls.Select(x => $"{x.ProviderName}|{x.Extras}|{x.UrlPathname}"))))); private static IEnumerable<(string Name, string Url)> InflateInfoUrls(string deflatedUrls) { string? data; try { data = Encoding.UTF8.GetString(Inflate(Convert.FromBase64String(deflatedUrls))); } catch { yield break; } var shokoUrl = Plugin.Instance.Configuration.WebUrl; foreach (var line in data.Split('\n')) { var (ns, extra, urlPathname) = line.Split('|'); if (string.IsNullOrWhiteSpace(extra) || string.IsNullOrWhiteSpace(urlPathname)) continue; var baseUrl = ns switch { ProviderNames.Shoko => shokoUrl, ProviderNames.Anidb => ProviderUrls.Anidb, ProviderNames.Tmdb => ProviderUrls.Tmdb, ProviderNames.Tvdb => ProviderUrls.Tvdb, _ => null, }; if (baseUrl is null) continue; extra = string.IsNullOrEmpty(extra) ? ns : $"{ns} {extra}"; yield return (extra, baseUrl + urlPathname); } } #endregion #region Public Methods public static string GetShowInfoUrls(API.Info.ShowInfo showInfo) { var result = new List<(string ProviderName, string Extras, string UrlPathname)>(); AddShowInfoUrls(ref result, showInfo); return DeflateInfoUrls(result); } public static string GetSeasonInfoUrls(API.Info.SeasonInfo seasonInfo) { var result = new List<(string ProviderName, string Extras, string UrlPathname)>(); AddSeasonInfoUrls(ref result, seasonInfo); return DeflateInfoUrls(result); } public static string GetEpisodeInfoUrls(API.Info.EpisodeInfo episodeInfo) { var result = new List<(string ProviderName, string Extras, string UrlPathname)>(); AddEpisodeInfoUrls(ref result, episodeInfo); return DeflateInfoUrls(result); } public static string GetFileInfoUrls(API.Info.FileInfo fileInfo) { var result = new List<(string ProviderName, string Extras, string UrlPathname)> { ( ProviderNames.Shoko, $"(f{fileInfo.Id}) (s{fileInfo.SeriesId})", $"/collection/series/{fileInfo.SeriesId}/files?fileId={fileInfo.Id}" ), }; foreach (var (episodeInfo, _, _) in fileInfo.EpisodeList) AddEpisodeInfoUrls(ref result, episodeInfo); return DeflateInfoUrls(result); } #endregion #region Add Urls Helpers private static void AddShowInfoUrls(ref List<(string ProviderName, string Extras, string UrlPathname)> result, API.Info.ShowInfo showInfo) { foreach (var shokoInfo in showInfo.ShokoSeries) result.Add(( ProviderNames.Shoko, $"(s{shokoInfo.ShokoSeriesId}) (g{shokoInfo.ShokoGroupId})", $"/collection/series/{shokoInfo.ShokoSeriesId}" )); foreach (var anidbInfo in showInfo.AnidbAnime) result.Add(( ProviderNames.Anidb, $"(a{anidbInfo.AnidbAnimeId})", $"/anime/{anidbInfo.AnidbAnimeId}" )); foreach (var tmdbInfo in showInfo.TmdbShows) { if (tmdbInfo.UsesAlternateOrdering) result.Add(( ProviderNames.Tmdb, $"(tv{tmdbInfo.TmdbShowId} > g{tmdbInfo.TmdbAlternateOrderingId})", $"/tv/{tmdbInfo.TmdbShowId}/episode_group/{tmdbInfo.TmdbAlternateOrderingId}" )); else result.Add(( ProviderNames.Tmdb, $"(tv{tmdbInfo.TmdbShowId})", $"/tv/{tmdbInfo.TmdbShowId}" )); if (!string.IsNullOrEmpty(tmdbInfo.TvdbShowId)) result.Add(( ProviderNames.Tvdb, $"(tv{tmdbInfo.TvdbShowId})", $"/?tab=series&id={tmdbInfo.TvdbShowId}" )); } foreach (var tmdbInfo in showInfo.TmdbMovies) { result.Add(( ProviderNames.Tmdb, $"(m{tmdbInfo.TmdbMovieId})", $"/movie/{tmdbInfo.TmdbMovieId}" )); if (!string.IsNullOrEmpty(tmdbInfo.TmdbMovieCollectionId)) result.Add(( ProviderNames.Tmdb, $"(c{tmdbInfo.TmdbMovieCollectionId})", $"/collection/{tmdbInfo.TmdbMovieCollectionId}" )); } } private static void AddSeasonInfoUrls(ref List<(string ProviderName, string Extras, string UrlPathname)> result, API.Info.SeasonInfo seasonInfo) { foreach (var shokoInfo in seasonInfo.ShokoSeries) result.Add(( ProviderNames.Shoko, $"(s{shokoInfo.ShokoSeriesId}) (g{shokoInfo.ShokoGroupId})", $"/collection/series/{shokoInfo.ShokoSeriesId}" )); foreach (var anidbInfo in seasonInfo.AnidbAnime) result.Add(( ProviderNames.Anidb, $"(a{anidbInfo.AnidbAnimeId})", $"/anime/{anidbInfo.AnidbAnimeId}" )); foreach (var tmdbInfo in seasonInfo.TmdbSeasons) { if (tmdbInfo.UsesAlternateOrdering) result.Add(( ProviderNames.Tmdb, $"(tv{tmdbInfo.TmdbShowId} > g{tmdbInfo.TmdbAlternateOrderingId} > S{tmdbInfo.SeasonNumber})", $"/tv/{tmdbInfo.TmdbShowId}/episode_group/{tmdbInfo.TmdbAlternateOrderingId}/group/{tmdbInfo.TmdbSeasonId}" )); else result.Add(( ProviderNames.Tmdb, $"(s{tmdbInfo.TmdbSeasonId}) (tv{tmdbInfo.TmdbShowId} > S{tmdbInfo.SeasonNumber})", $"/tv/{tmdbInfo.TmdbShowId}/season/{tmdbInfo.SeasonNumber}" )); } foreach (var tmdbInfo in seasonInfo.TmdbMovies) { result.Add(( ProviderNames.Tmdb, $"(m{tmdbInfo.TmdbMovieId})", $"/movie/{tmdbInfo.TmdbMovieId}" )); if (!string.IsNullOrEmpty(tmdbInfo.TmdbMovieCollectionId)) result.Add(( ProviderNames.Tmdb, $"(c{tmdbInfo.TmdbMovieCollectionId})", $"/collection/{tmdbInfo.TmdbMovieCollectionId}" )); } } private static void AddEpisodeInfoUrls(ref List<(string ProviderName, string Extras, string UrlPathname)> result, API.Info.EpisodeInfo episodeInfo) { foreach (var shokoInfo in episodeInfo.ShokoEpisodes) result.Add(( ProviderNames.Shoko, $"(e{shokoInfo.ShokoEpisodeId}) (s{shokoInfo.ShokoSeriesId})", $"/collection/series/{shokoInfo.ShokoSeriesId}/episodes?episodeId={shokoInfo.ShokoEpisodeId}" )); foreach (var anidbInfo in episodeInfo.AnidbEpisodes) result.Add(( ProviderNames.Anidb, $"(e{anidbInfo.AnidbEpisodeId}) (a{anidbInfo.AnidbAnimeId} > {anidbInfo.GetEpisodeNumberText()})", $"/episode/{anidbInfo.AnidbEpisodeId}" )); foreach (var tmdbInfo in episodeInfo.TmdbEpisodes) { if (tmdbInfo.UsesAlternateOrdering) result.Add(( ProviderNames.Tmdb, $"(e{tmdbInfo.TmdbEpisodeId}) (tv{tmdbInfo.TmdbShowId} > g{tmdbInfo.TmdbAlternateOrderingId} > S{tmdbInfo.SeasonNumber}E{tmdbInfo.EpisodeNumber})", $"/tv/{tmdbInfo.TmdbShowId}/season/{tmdbInfo.OriginalSeasonNumber}/episode/{tmdbInfo.OriginalEpisodeNumber}" )); else result.Add(( ProviderNames.Tmdb, $"(e{tmdbInfo.TmdbEpisodeId}) (tv{tmdbInfo.TmdbShowId} > S{tmdbInfo.SeasonNumber}E{tmdbInfo.EpisodeNumber})", $"/tv/{tmdbInfo.TmdbShowId}/season/{tmdbInfo.SeasonNumber}/episode/{tmdbInfo.EpisodeNumber}" )); if (!string.IsNullOrEmpty(tmdbInfo.TvdbEpisodeId)) result.Add(( ProviderNames.Tvdb, $"(e{tmdbInfo.TvdbEpisodeId})", $"/?tab=episode&id={tmdbInfo.TvdbEpisodeId}" )); } foreach (var tmdbInfo in episodeInfo.TmdbMovies) { result.Add(( ProviderNames.Tmdb, $"(m{tmdbInfo.TmdbMovieId})", $"/movie/{tmdbInfo.TmdbMovieId}" )); if (!string.IsNullOrEmpty(tmdbInfo.TmdbMovieCollectionId)) result.Add(( ProviderNames.Tmdb, $"(c{tmdbInfo.TmdbMovieCollectionId})", $"/collection/{tmdbInfo.TmdbMovieCollectionId}" )); } } #endregion } ================================================ FILE: Shokofin/ExternalIds/ShokoInternalId.cs ================================================ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; namespace Shokofin.ExternalIds; public class ShokoInternalId : IExternalId { public static string Name => MetadataProvider.Custom.ToString(); public const string SeriesNamespace = "shoko://series/"; public const string EpisodeNamespace = "shoko://episode/"; public const string FileNamespace = "shoko://file/"; #region IExternalId Implementation string IExternalId.ProviderName => Name; string IExternalId.Key => Name; ExternalIdMediaType? IExternalId.Type => null; bool IExternalId.Supports(IHasProviderIds item) => item is BoxSet or Series or Season or Video; #if NET9_0 #else string? IExternalId.UrlFormatString => null; #endif #endregion } ================================================ FILE: Shokofin/MergeVersions/MergeVersionManager.cs ================================================ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; 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.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; using FileInfo = Shokofin.API.Info.FileInfo; using ReleaseSource = Shokofin.API.Models.ReleaseSource; 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> /// Wait time before logging it's in use. /// </summary> private const int LockWaitMS = 100; /// <summary> /// Logger. /// </summary> private readonly ILogger<MergeVersionsManager> _logger; /// <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 ShokoIdLookup _lookup; /// <summary> /// Used to lookup the file info for each video. /// </summary> private readonly ShokoApiManager _apiManager; /// <summary> /// Used to clear the <see cref="_runGuard"/> when the /// <see cref="UsageTracker.Stalled"/> event is ran. /// </summary> private readonly UsageTracker _usageTracker; public MergeVersionsManager(ILogger<MergeVersionsManager> logger, ILibraryManager libraryManager, ShokoIdLookup lookup, ShokoApiManager apiManager, UsageTracker usageTracker) { _logger = logger; _libraryManager = libraryManager; _lookup = lookup; _apiManager = apiManager; _usageTracker = usageTracker; _usageTracker.Stalled += OnUsageTrackerStalled; } ~MergeVersionsManager() { _usageTracker.Stalled -= OnUsageTrackerStalled; } private void OnUsageTrackerStalled(object? sender, EventArgs e) { Clear(); } public void Clear() { _logger.LogDebug("Clearing data…"); Task.Factory.StartNew(SplitAndMergeQueuedEpisodes, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach); Task.Factory.StartNew(SplitAndMergeQueuedMovies, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach); } #region Episodes /// <summary> /// Used to lock access to retrieving data from and clearing /// <see cref="_episodeIds"/>. /// </summary> private readonly SemaphoreSlim _episodeLock = new(1, 1); /// <summary> /// Bag of episode IDs encountered during last scan or refresh. Will be used /// after the stalled event is fired to merge the episodes because we cannot /// do it during the scan or refresh. /// </summary> private readonly ConcurrentBag<string> _episodeIds = []; public async Task SplitAndMergeAllEpisodes(IProgress<double>? progress, CancellationToken? cancellationToken) { try { cancellationToken?.ThrowIfCancellationRequested(); if (!await _episodeLock.WaitAsync(LockWaitMS, cancellationToken ?? default).ConfigureAwait(false)) { _logger.LogDebug("Episode lock is taken, waiting for our turn."); await _episodeLock.WaitAsync(cancellationToken ?? default).ConfigureAwait(false); } cancellationToken?.ThrowIfCancellationRequested(); _episodeIds.Clear(); var episodes = GetEpisodesFromLibrary(); _logger.LogDebug("Checking {Count} episodes if they need to be split or merged.", episodes.Count); await SplitAndMergeVideos(episodes, progress, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Finished checking {Count} episodes if they need to be split or merged.", episodes.Count); progress?.Report(100d); } finally { _episodeLock.Release(); } } public async Task SplitAllEpisodes(IProgress<double>? progress, CancellationToken? cancellationToken) { try { cancellationToken?.ThrowIfCancellationRequested(); if (!await _episodeLock.WaitAsync(LockWaitMS, cancellationToken ?? default).ConfigureAwait(false)) { _logger.LogDebug("Episode lock is taken, waiting for our turn."); await _episodeLock.WaitAsync(cancellationToken ?? default).ConfigureAwait(false); } cancellationToken?.ThrowIfCancellationRequested(); _episodeIds.Clear(); var episodes = GetEpisodesFromLibrary(); _logger.LogDebug("Checking {Count} episodes if they need to be split.", episodes.Count); await SplitVideos(episodes, progress, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Finished checking {Count} episodes if they need to be split.", episodes.Count); progress?.Report(100d); } finally { _episodeLock.Release(); } } private async Task SplitAndMergeQueuedEpisodes() { try { if (!await _episodeLock.WaitAsync(LockWaitMS).ConfigureAwait(false)) { _logger.LogDebug("Episode lock is taken, waiting for our turn."); await _episodeLock.WaitAsync().ConfigureAwait(false); } var episodeIds = _episodeIds.ToArray(); _episodeIds.Clear(); if (episodeIds.Length is 0) return; var episodes = episodeIds .Distinct() .SelectMany(GetEpisodesFromLibrary) .ToList(); _logger.LogDebug("Checking {Count} episodes if they need to be split or merged.", episodes.Count); IProgress<double> progress = new Progress<double>(report => _logger.LogDebug("Episode Progress: {Progress}", report)); await SplitAndMergeVideos(episodes, progress).ConfigureAwait(false); _logger.LogDebug("Finished checking {Count} episodes if they need to be split or merged.", episodes.Count); progress.Report(100d); } catch (Exception ex) { _logger.LogError(ex, "Encountered an error splitting or merging episodes."); } finally { _episodeLock.Release(); } } public void ScheduleSplitAndMergeEpisodesByEpisodeId(string episodeId) => _episodeIds.Add(episodeId); #endregion #region Movies /// <summary> /// Used to lock access to retrieving data from and clearing /// <see cref="_movieIds"/>. /// </summary> private readonly SemaphoreSlim _movieLock = new(1, 1); /// <summary> /// Bag of episode IDs encountered during last scan or refresh. Will be used /// after the stalled event is fired to merge the movies because we cannot /// do it during the scan or refresh. /// </summary> private readonly ConcurrentBag<string> _movieIds = []; public async Task SplitAndMergeAllMovies(IProgress<double>? progress, CancellationToken? cancellationToken) { try { cancellationToken?.ThrowIfCancellationRequested(); if (!await _movieLock.WaitAsync(LockWaitMS, cancellationToken ?? default).ConfigureAwait(false)) { _logger.LogDebug("Movie lock is taken, waiting for our turn."); await _movieLock.WaitAsync(cancellationToken ?? default).ConfigureAwait(false); } cancellationToken?.ThrowIfCancellationRequested(); var movies = GetMoviesFromLibrary(); _movieIds.Clear(); _logger.LogDebug("Checking {Count} movies if they need to be split or merged.", movies.Count); await SplitAndMergeVideos(movies, progress, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Finished checking {Count} movies if they need to be split or merged.", movies.Count); progress?.Report(100d); } finally { _movieLock.Release(); } } public async Task SplitAllMovies(IProgress<double>? progress, CancellationToken? cancellationToken) { try { cancellationToken?.ThrowIfCancellationRequested(); if (!await _movieLock.WaitAsync(LockWaitMS, cancellationToken ?? default).ConfigureAwait(false)) { _logger.LogDebug("Movie lock is taken, waiting for our turn."); await _movieLock.WaitAsync(cancellationToken ?? default).ConfigureAwait(false); } cancellationToken?.ThrowIfCancellationRequested(); var movies = GetMoviesFromLibrary(); _movieIds.Clear(); _logger.LogDebug("Checking {Count} movies if they need to be split.", movies.Count); await SplitVideos(GetMoviesFromLibrary(), progress, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Finished checking {Count} movies if they need to be split.", movies.Count); progress?.Report(100d); } finally { _movieLock.Release(); } } private async Task SplitAndMergeQueuedMovies() { try { if (!await _movieLock.WaitAsync(LockWaitMS).ConfigureAwait(false)) { _logger.LogDebug("Movie lock is taken, waiting for our turn."); await _movieLock.WaitAsync().ConfigureAwait(false); } var movieEpisodeIds = _movieIds.ToArray(); _movieIds.Clear(); if (movieEpisodeIds.Length is 0) return; var movies = movieEpisodeIds .Distinct() .SelectMany(GetMoviesFromLibrary) .ToList(); _logger.LogDebug("Checking {Count} movies if they need to be split or merged.", movies.Count); IProgress<double> progress = new Progress<double>(report => _logger.LogDebug("Movie Progress: {Progress:0.00F}%", report)); await SplitAndMergeVideos(movies, progress).ConfigureAwait(false); _logger.LogDebug("Finished checking {Count} movies if they need to be split or merged.", movies.Count); progress.Report(100d); } catch (Exception ex) { _logger.LogError(ex, "Encountered an error splitting or merging movies."); } finally { _movieLock.Release(); } } public void ScheduleSplitAndMergeMoviesByEpisodeId(string movieId) => _movieIds.Add(movieId); #endregion #region Shared Methods /// <summary> /// Get all movies with a Shoko Episode ID set across all libraries. /// </summary> /// <param name="episodeId">Optional. The episode id if we want to filter to only movies with a given Shoko Episode ID.</param> /// <returns>A list of all movies with the given <paramref name="episodeId"/> set.</returns> public IReadOnlyList<Movie> GetMoviesFromLibrary(string episodeId = "") => _libraryManager .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Movie], SourceTypes = [SourceType.Library], IsVirtualItem = false, Recursive = true, HasAnyProviderId = new Dictionary<string, string> { {ProviderNames.ShokoEpisode, episodeId } }, }) .OfType<Movie>() .Where(_lookup.IsEnabledForItem) .ToList(); /// <summary> /// Get all episodes with a Shoko Episode ID set across all libraries. /// </summary> /// <param name="episodeId">Optional. The episode id if we want to filter to only episodes with a given Shoko Episode ID.</param> /// <returns>A list of all episodes with a Shoko Episode ID set.</returns> public IReadOnlyList<Episode> GetEpisodesFromLibrary(string episodeId = "") => _libraryManager .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Episode], SourceTypes = [SourceType.Library], HasAnyProviderId = new Dictionary<string, string> { {ProviderNames.ShokoEpisode, episodeId } }, IsVirtualItem = false, Recursive = true, }) .Cast<Episode>() .Where(_lookup.IsEnabledForItem) .ToList(); /// <summary> /// 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<bool> SplitAndMergeVideos<TVideo>( IReadOnlyList<TVideo> videos, IProgress<double>? progress = null, CancellationToken? cancellationToken = null ) where TVideo : Video { // Split up any existing merged videos. double currentCount = 0d; double totalCount = videos.Count; var visitedVideos = new HashSet<Guid>(); var duplicationGroups = videos .GroupBy(video => (video.GetTopParent()?.Path, video.GetProviderId(ProviderNames.ShokoEpisode))) .Where(groupBy => groupBy.Count() > 1) .ToList(); var processVideos = duplicationGroups .SelectMany(groupBy => groupBy) .Select(video => video.Id) .ToHashSet(); foreach (var video in videos) { // Handle cancellation and update progress. cancellationToken?.ThrowIfCancellationRequested(); var percent = currentCount++ / totalCount * 50d; progress?.Report(percent); // Remove all alternate sources linked to the videos we're not processing. await CleanVideo(video, visitedVideos, toSkip: processVideos).ConfigureAwait(false); } // Correctly merge all videos with more than one version available. currentCount = 0d; totalCount = duplicationGroups.Count; foreach (var videoGroup in duplicationGroups) { // Handle cancellation and update progress. cancellationToken?.ThrowIfCancellationRequested(); var percent = 50d + (currentCount++ / totalCount * 50d); progress?.Report(percent); // Link the videos together as alternate sources. await MergeVideos(videoGroup).ConfigureAwait(false); } progress?.Report(100); return true; } /// <summary> /// Split up all existing 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 SplitVideos<TVideo>(IReadOnlyList<TVideo> videos, IProgress<double>? progress, CancellationToken? cancellationToken) where TVideo : Video { // Split up any existing merged videos. double currentCount = 0d; double totalMovies = videos.Count; var toSkipVideos = new HashSet<Guid>(); var visitedVideos = new HashSet<Guid>(); foreach (var video in videos) { // Handle cancellation and update progress. cancellationToken?.ThrowIfCancellationRequested(); var percent = currentCount++ / totalMovies * 100d; progress?.Report(percent); // Remove all alternate sources linked to the video. await CleanVideo(video, visitedVideos, toSkipVideos).ConfigureAwait(false); } progress?.Report(100); } /// <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 async Task MergeVideos<TVideo>(IEnumerable<TVideo> input) where TVideo : Video { if (input is not List<TVideo> videos) videos = [.. input]; if (videos is not { Count: > 1 }) return; var orderedVideos = await OrderVideos(videos).ConfigureAwait(false); var (primaryVideo, primarySortName) = orderedVideos[0]; // Process the other videos and link them to the primary video if // they're not already linked. var updated = false; var alternateVersions = new List<LinkedChild>(); foreach (var (video, sortName) in orderedVideos.Skip(1)) { if (alternateVersions.Any(i => string.Equals(i.Path, video.Path, StringComparison.OrdinalIgnoreCase))) { _logger.LogTrace("Skipping already linked alternate version. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", primaryVideo.Id, video.Id); continue; } // Conditionally save the changes back to the repository. _logger.LogTrace("Found a new linked alternate version. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", primaryVideo.Id, video.Id); alternateVersions.Add(new() { Path = video.Path, ItemId = video.Id, }); updated = false; if (video.PrimaryVersionId != primaryVideo.Id.ToString("N", CultureInfo.InvariantCulture)) { video.SetPrimaryVersionId(primaryVideo.Id.ToString("N", CultureInfo.InvariantCulture)); updated = true; } if (!string.Equals(video.ForcedSortName, sortName, StringComparison.Ordinal)) { video.ForcedSortName = sortName; updated = true; } if (video.LocalAlternateVersions.Length > 0) { video.LocalAlternateVersions = []; updated = true; } if (video.LinkedAlternateVersions.Length > 0) { video.LinkedAlternateVersions = []; updated = true; } if (updated) { _logger.LogDebug("Saving linked video changes. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", primaryVideo.Id, video.Id); await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } } // Order the alternate sources by path, to make sure we always have it // in the same order. The UI list is (partially) ordered by the forced // sort name, so this won't affect that. alternateVersions = [.. alternateVersions.OrderBy(i => i.Path)]; // Conditionally save the changes back to the repository. _logger.LogTrace("Found primary video with {Count} linked alternate versions. (PrimaryVideo={PrimaryVideoId})", alternateVersions.Count, primaryVideo.Id); updated = false; if (primaryVideo.PrimaryVersionId is not null) { primaryVideo.SetPrimaryVersionId(null); updated = true; } if (!string.Equals(primaryVideo.ForcedSortName, primarySortName, StringComparison.Ordinal)) { primaryVideo.ForcedSortName = primarySortName; updated = true; } if (primaryVideo.LocalAlternateVersions.Length > 0) { primaryVideo.LocalAlternateVersions = []; updated = true; } if (primaryVideo.LinkedAlternateVersions.Length != alternateVersions.Count || !primaryVideo.LinkedAlternateVersions.SequenceEqual(alternateVersions, LinkedChildComparer.Instance)) { primaryVideo.LinkedAlternateVersions = [..alternateVersions]; updated = true; } if (updated) { _logger.LogDebug("Saving primary video changes with {Count} linked alternate versions. (PrimaryVideo={PrimaryVideoId})", alternateVersions.Count, primaryVideo.Id); await primaryVideo.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="video">The primary video to clean up.</param> /// <param name="visited">A set of video IDs that have already been visited.</param> /// <param name="depth">The current depth of recursion. Used for logging.</param> /// <typeparam name="TVideo">The type of the video.</typeparam> /// <returns>A task that completes when all alternate video sources have been /// removed.</returns> private async Task CleanVideo<TVideo>(TVideo? video, HashSet<Guid> visited, HashSet<Guid> toSkip, int depth = 0) where TVideo : Video { if (video is null) return; // Only visit a video once per run. if (!visited.Add(video.Id)) { _logger.LogTrace("Skipping already visited video. (Video={VideoId},Depth={Depth})", video.Id, depth); return; } // Visit the primary video if this is not the primary video. if (video.PrimaryVersionId is not null) { var primaryVideo = _libraryManager.GetItemById(video.PrimaryVersionId) as TVideo; if (primaryVideo is not null) { _logger.LogTrace("Found primary video to clean up first. (Video={VideoId},Depth={Depth})", primaryVideo.Id, depth); await CleanVideo(primaryVideo, visited, toSkip, depth + 1).ConfigureAwait(false); } } // Visit every linked video. if (video.GetLinkedAlternateVersions().ToList() is { Count: > 0 } linkedAlternateVersions) { _logger.LogTrace("Removing {Count} linked alternate sources for video. (Video={VideoId},Depth={Depth})", linkedAlternateVersions.Count, video.Id, depth); foreach (var linkedVideo in linkedAlternateVersions) { await CleanVideo(linkedVideo, visited, toSkip, depth + 1).ConfigureAwait(false); } } // Visit every local linked video. if (video.GetLocalAlternateVersionIds().Select(id => _libraryManager.GetItemById(id) as TVideo).WhereNotNull().ToList() is { Count: > 0 } localAlternateVersions) { _logger.LogTrace("Removing {Count} local alternate sources for video. (Video={VideoId},Depth={Depth})", localAlternateVersions.Count, video.Id, depth); foreach (var linkedVideo in localAlternateVersions) { await CleanVideo(linkedVideo, visited, toSkip, depth + 1).ConfigureAwait(false); } } // Skip cleaning this video if it's in the skip list. if (toSkip?.Contains(video.Id) ?? false) { _logger.LogTrace("Skipped cleaning video. (Video={VideoId},Depth={Depth})", video.Id, depth); return; } // Clean the current video if it's not already clean. if (!string.IsNullOrEmpty(video.PrimaryVersionId) || video.ForcedSortName is not null || video.LinkedAlternateVersions.Length > 0 || video.LocalAlternateVersions.Length > 0) { _logger.LogTrace("Cleaning up video. (PrimaryVideo={PrimaryVideoId},Video={VideoId},Depth={Depth})", video.PrimaryVersionId, video.Id, depth); video.SetPrimaryVersionId(null); video.ForcedSortName = null; video.LocalAlternateVersions = []; video.LinkedAlternateVersions = []; await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } else { _logger.LogTrace("Video is already clean. (PrimaryVideo={PrimaryVideoId},Video={VideoId},Depth={Depth})", video.PrimaryVersionId, video.Id, depth); } } private static MergeVersionSortSelector[] GetOrderedSelectors() => Plugin.Instance.Configuration.MergeVersionSortSelectorOrder.Where((t) => Plugin.Instance.Configuration.MergeVersionSortSelectorList.Contains(t)).ToArray(); private async Task<IList<(TVideo video, string? sortName)>> OrderVideos<TVideo>(IList<TVideo> list) where TVideo : Video { var selectors = GetOrderedSelectors(); return (await Task.WhenAll(list.Select(async video => (video, sortName: await GetSortName(video, selectors)))).ConfigureAwait(false)) .OrderBy(tuple => tuple.sortName is null) .ThenBy(tuple => tuple.sortName) .ThenBy(tuple => tuple.video.Path) .ToList(); } private async Task<string?> GetSortName<TVideo>(TVideo video, IList<MergeVersionSortSelector> selectors) where TVideo : Video { if (selectors.Count is 0) return null; var (fileInfo, _, _) = await _apiManager.GetFileInfoByPath(video.Path).ConfigureAwait(false); if (fileInfo is null) return null; return selectors .Select(selector => GetSelectedSortValue(video, fileInfo, selector)) .Join("."); } private string GetSelectedSortValue<TVideo>(TVideo video, FileInfo fileInfo, MergeVersionSortSelector selector) where TVideo : Video => selector switch { MergeVersionSortSelector.ImportedAt => (fileInfo.Shoko.ImportedAt ?? fileInfo.Shoko.CreatedAt).ToUniversalTime().ToString("O"), MergeVersionSortSelector.CreatedAt => fileInfo.Shoko.CreatedAt.ToString("O"), MergeVersionSortSelector.Resolution => video.GetDefaultVideoStream() is { } videoStream ? ((int)Math.Ceiling(((decimal)(videoStream.Width ?? 1) * (videoStream.Height ?? 1)) / 100)).ToString("00000000") : "99999999", MergeVersionSortSelector.ReleaseGroupName => fileInfo.Shoko.Release?.Group is { } releaseGroup ? ( !string.IsNullOrEmpty(releaseGroup.ShortName) ? releaseGroup.ShortName : !string.IsNullOrEmpty(releaseGroup.Name) ? releaseGroup.Name : $"_____Release group {releaseGroup.Id}" ).ReplaceInvalidPathCharacters() : "_____No Group", MergeVersionSortSelector.FileSource => fileInfo.Shoko.Release?.Source switch { ReleaseSource.BluRay => "01", ReleaseSource.Web => "02", ReleaseSource.DVD => "03", ReleaseSource.VCD => "04", ReleaseSource.LaserDisc => "05", ReleaseSource.TV => "06", ReleaseSource.VHS => "07", ReleaseSource.Camera => "08", ReleaseSource.Other => "09", _ => "FF", }, MergeVersionSortSelector.FileVersion => (10 - fileInfo.Shoko.Release?.Version ?? 1).ToString("0"), MergeVersionSortSelector.RelativeDepth => fileInfo.Shoko.Locations .Select(i => i.RelativePath.Split(Path.DirectorySeparatorChar).Length) .Max() .ToString("00"), MergeVersionSortSelector.NoVariation => fileInfo.Shoko.IsVariation ? "1" : "0", _ => string.Empty, }; internal class LinkedChildComparer : IEqualityComparer<LinkedChild> { private static LinkedChildComparer? _instance; public static LinkedChildComparer Instance => _instance ??= new LinkedChildComparer(); public bool Equals(LinkedChild? x, LinkedChild? y) => x is not null && y is not null && GetHashCode(x) == GetHashCode(y); public int GetHashCode([DisallowNull] LinkedChild obj) => HashCode.Combine(obj.Path, obj.LibraryItemId, obj.Type, obj.ItemId); } #endregion Shared Methods } ================================================ FILE: Shokofin/MergeVersions/MergeVersionSortSelector.cs ================================================ namespace Shokofin.MergeVersions; /// <summary> /// Defines how versions of the same video are sorted when merged in the UI. /// </summary> public enum MergeVersionSortSelector { /// <summary> /// Versions are sorted using the import date of the file. /// </summary> ImportedAt = 1, /// <summary> /// Versions are sorted using the creation date of the file. /// </summary> CreatedAt = 2, /// <summary> /// Versions are sorted by the resolution of the video, with the highest resolution first. /// </summary> Resolution = 3, /// <summary> /// Versions are sorted alphabetically based on the release group name. /// </summary> ReleaseGroupName = 4, /// <summary> /// Versions are sorted using the file source (e.g. Blu-Ray, Web, DVD, etc...) /// </summary> FileSource = 5, /// <summary> /// Versions are sorted using the file version, if available. /// </summary> FileVersion = 6, /// <summary> /// Versions are sorted using the relative folder depth. Deeper files last. /// </summary> RelativeDepth = 7, /// <summary> /// Versions are sorted so files not marked as a variation come before files /// marked as a variation. /// </summary> NoVariation = 8, } ================================================ FILE: Shokofin/Pages/Dummy.html ================================================ <div data-role="page" class="page type-interior pluginConfigurationPage withTabs" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/Shoko.Utilities.Dummy.js"> <div data-role="content"> <div class="content-primary"> Dummy. </div> </div> </div> ================================================ FILE: Shokofin/Pages/Scripts/Common.js ================================================ /** * Example page showcasing the different view events we can use and their * details. */ //#region Dashboard /** * @type {DashboardPrototype} */ export const Dashboard = globalThis.Dashboard; /** * * @callback GenericFunction * @returns {void} */ /** * Prototype for the dashboard. * * @typedef {Object} DashboardPrototype * @property {DashboardConfirm1 | DashboardConfirm2} confirm Show a confirm dialog. * @property {DashboardAlert} alert Alert a message. * @property {ApiClientGetUrl} getPluginUrl The internal URL of the plugin resource. * @property {GenericFunction} showLoadingMsg Show a loading message. * @property {GenericFunction} hideLoadingMsg Hide a loading message. * @property {GenericFunction} processPluginConfigurationUpdateResult Process a plugin configuration update. * @property {DashboardNavigate} navigate Navigate to a route. * @property {new() => DirectoryBrowser} DirectoryBrowser Directory Browser class. Used to create a new instance of the directory browser. * // TODO: Add the rest here if needed. */ /** * @typedef {{ * callback: (path: string, networkSharePath: string) => void; * }} DirectoryBrowserShowOptions */ /** * @callback DirectoryBrowserShow * @param {DirectoryBrowserShowOptions} options * @returns {void} */ /** * Directory browser. * * @typedef {Object} DirectoryBrowser * @property {DirectoryBrowserShow} show Show the directory browser. * @property {GenericFunction} close Close the directory browser. */ /** * Show a confirm dialog. * * @callback DashboardConfirm1 * @param {string} message The message to show. * @param {string} title The title of the confirm dialog. * @returns {Promise<void>} */ /** * Show a confirm dialog. * * @callback DashboardConfirm2 * @param {{ * }} options The message to show. * @returns {Promise<void>} */ /** * Alert message options. * * @typedef {Object} DashboardAlertOptions * @property {string} message The message to show. * @property {string} [title] The title of the alert. * @property {GenericFunction} [callback] The callback to call when the alert is closed. */ /** * Show an alert message. * * @callback DashboardAlert * @param {string | DashboardAlertOptions} message The message to show, or an options object for the alert to show. * @returns {void} */ /** * Navigate to a url. * * @callback DashboardNavigate * @param {string} url - The url to navigate to. * @param {boolean} [preserveQueryString] - A flag to indicate the current query string should be appended to the new url. * @returns {Promise<void>} */ //#endregion //#region API Client /** * @type {ApiClientPrototype} */ export const ApiClient = globalThis.ApiClient; /** * @typedef {Object} User * @property {string} Id The user id. * @property {string} Name The user name. */ /** * @callback ApiClientGetUsers * @returns {Promise<User[]>} The users. */ /** * @typedef {Object} ApiClientPrototype * @property {ApiClientGetPluginConfiguration} getPluginConfiguration Get a plugin configuration. * @property {ApiClientUpdatePluginConfiguration} updatePluginConfiguration Update a plugin configuration. * @property {ApiClientGetUsers} getUsers Get the current user. * @property {ApiClientGetUrl} getUrl Get an API url. * @property {ApiClientFetch} fetch Fetch an API call. * // TODO: Add the rest here if needed. */ /** * @typedef {Object} ApiClientGetPluginConfiguration * @property {string} id The plugin id. * @returns {Promise<T>} The plugin configuration. * @template T The type of the plugin configuration. */ /** * @callback ApiClientUpdatePluginConfiguration * @param {string} id The plugin id. * @param {T} config The plugin configuration. * @returns {Promise<any>} Some sort of result we don't really care about. * @template T */ /** * @callback ApiClientGetUrl * @param {string} url The url of the API call. * @returns {string} The modified url of the API call. */ /** * @typedef {Object} ApiClientFetchOptions * @property {"json"} dataType The data type of the API call. * @property {"GET" | "POST"} [type] The HTTP method of the API call. * @property {string | FormData | Blob} [data] The data of the API call. * @property {Record<string, string>} [headers] The headers of the API call. * @property {string} url The url of the API call. */ /** * Fetch an API call. * * @callback ApiClientFetch * @param {Object} options The options of the API call. * @returns {Promise<T>} The result of the API call. * @template T */ //#endregion //#region Library Menu /** * @type {LibraryMenuPrototype} */ export const LibraryMenu = globalThis.LibraryMenu; /** * @typedef {Object} LibraryMenuPrototype * @property {LibraryMenuSetTabs} setTabs Set the tabs. */ /** * @typedef {Object} LibraryMenuTab * @property {string} name The display name of the tab. * @property {string} href The url of the tab in the react router. */ /** * @callback LibraryMenuSetTabsFactory * @returns {LibraryMenuTab[]} The tabs. */ /** * @callback LibraryMenuSetTabs * @param {string} tabSetName The name of the tab set. * @param {number} index The index of the tab to select. * @param {LibraryMenuSetTabsFactory} factory The factory function to create the tabs. * @returns {void} Void. */ //#endregion //#region API Client /** * @typedef {{ * IsUsable: boolean; * IsActive: boolean; * State: "Disconnected" | "Connected" | "Connecting" | "Reconnecting"; * }} SignalRStatus */ /** * @typedef {"Shoko" | "AniDB" | "TMDB"} GenericProvider */ /** * @typedef {"Shoko" | "AniDB" | "TvDB" | "TMDB"} DescriptionProvider */ /** * @typedef {"Disabled" | "PlainText" | "Markdown"} DescriptionConversionMode */ /** * @typedef {"Shoko_Default" | "AniDB_Default" | "AniDB_LibraryLanguage" | "AniDB_CountryOfOrigin" | "TMDB_Default" | "TMDB_LibraryLanguage" | "TMDB_CountryOfOrigin"} TitleProvider */ /** * @typedef {"ContentIndicators" | "Dynamic" | "DynamicCast" | "DynamicEnding" | "Elements" | "ElementsPornographyAndSexualAbuse" | "ElementsTropesAndMotifs" | "Fetishes" | "OriginProduction" | "OriginDevelopment" | "SettingPlace" | "SettingTimePeriod" | "SettingTimeSeason" | "SourceMaterial" | "TargetAudience" | "TechnicalAspects" | "TechnicalAspectsAdaptions" | "TechnicalAspectsAwards" | "TechnicalAspectsMultiAnimeProjects" | "Themes" | "ThemesDeath" | "ThemesTales" | "Ungrouped" | "Unsorted" | "CustomTags"} TagSource */ /** * @typedef {"Parent" | "Child" | "Abstract" | "Weightless" | "Weighted" | "GlobalSpoiler" | "LocalSpoiler"} TagIncludeFilter */ /** * @typedef {"Weightless" | "One" | "Two" | "Three" | "Four" | "Five" | "Six"} TagWeight */ /** * @typedef {0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10} TagDepth */ /** * @typedef {"None" | "Movies" | "Shared"} CollectionCreationType */ /** * @typedef {"Default" | "ReleaseDate" | "Chronological" | "ChronologicalIgnoreIndirect"} SeasonOrderType */ /** * @typedef {"Default" | "Excluded" | "AfterSeason" | "InBetweenSeasonByAirDate" | "InBetweenSeasonByOtherData" | "InBetweenSeasonMixed"} SpecialOrderType */ /** * @typedef {"Default" | "Cache" | "Custom"} VirtualRootLocation */ /** * @typedef {"VFS" | "Strict" | "Lax"} LibraryOperationMode */ /** * @typedef {"ImportedAt" | "CreatedAt" | "Resolution" | "ReleaseGroupName" | "FileSource" | "FileVersion" | "RelativeDepth" | "NoVariation"} MergeVersionSortSelector */ /** * @typedef {"None" | "Metadata" | "Original" | "English"} ImageLanguageType */ /** * @typedef {"Unknown" | "Other" | "TV" | "TVSpecial" | "Web" | "Movie" | "OVA" | "MusicVideo"} SeriesType */ /** * @typedef {"AniDB_Anime" | "Shoko_Groups" | "TMDB_SeriesAndMovies"} SeriesStructureType */ /** * @typedef {"None" | "NoMerge" | "MergeForward" | "MergeBackward" | "MergeWithMainStory" | "MergeGroupATarget" | "MergeGroupASource" | "MergeGroupBTarget" | "MergeGroupBSource" | "MergeGroupCTarget" | "MergeGroupCSource" | "MergeGroupDTarget" | "MergeGroupDSource"} SeasonMergingBehavior */ /** * @typedef {"None" | "EpisodesAsSpecials" | "SpecialsAsEpisodes" | "SpecialsAsExtraFeaturettes"} SeriesEpisodeConversion */ /** * @typedef {"Default" | "ShokoCollection" | "TmdbCollection" | "AnidbMovie" | "ShokoMovie" | "TmdbMovie" | "AnidbAnime" | "ShokoSeries" | "TmdbShow" | "AnidbSeason" | "ShokoSeason" | "TmdbSeason" | "AnidbEpisode" | "ShokoEpisode"} AllConfigurationTypes */ /** * @typedef {{ * UserId: string; * EnableSynchronization: boolean; * SyncUserDataAfterPlayback: boolean; * SyncUserDataUnderPlayback: boolean; * SyncUserDataUnderPlaybackLive: boolean; * SyncUserDataInitialSkipEventCount: number; * SyncUserDataUnderPlaybackAtEveryXTicks: number; * SyncUserDataUnderPlaybackLiveThreshold: number; * SyncUserDataOnImport: boolean; * SyncRestrictedVideos: boolean; * Username: string; * Token: string; * }} UserConfig */ /** * @typedef {{ * Id: string; * Name: string; * IsFileEventsEnabled: boolean; * IsRefreshEventsEnabled: boolean; * LibraryOperationMode: LibraryOperationMode; * IterativeVfsGeneration_Enabled: boolean; * IterativeVfsGeneration_NoCache: boolean; * IterativeVfsGeneration_LastGeneratedAt: string | null; * IterativeVfsGeneration_CurrentCount: number; * IterativeVfsGeneration_MaxCount: number; * IterativeVfsGeneration_ForceFullGenerationOnNextRefresh: boolean; * }} LibraryConfig */ /** * @typedef {{ * LibraryId: string; * Path: string; * ManagedFolderId: number; * ManagedFolderName: string | null; * ManagedFolderRelativePath: string; * IsIgnored: boolean; * IsMapped: boolean; * NeedsRefresh: boolean; * }} MediaFolderConfig */ /** * @typedef {{ * Version: string; * Commit: string | null; * ReleaseChannel: "Stable" | "Dev" | "Debug" | null; * ReleaseDate: string | null; * }} ServerInformation */ /** * @typedef {{ * List: TitleProvider[]; * Order: TitleProvider[]; * AllowAny: boolean; * }} TitleConfiguration */ /** * @typedef {{ * RemoveDuplicates: boolean; * MainTitle: TitleConfiguration; * AlternateTitles: [TitleConfiguration, ...TitleConfiguration[]]; * }} TitlesConfiguration */ /** * @typedef {TitleConfiguration & { * Enabled: boolean * }} ToggleTitlesConfiguration */ /** * @typedef {{ * Default: TitlesConfiguration; * ShokoCollection: ToggleTitlesConfiguration; * TmdbCollection: ToggleTitlesConfiguration; * AnidbMovie: ToggleTitlesConfiguration; * ShokoMovie: ToggleTitlesConfiguration; * TmdbMovie: ToggleTitlesConfiguration; * AnidbAnime: ToggleTitlesConfiguration; * ShokoSeries: ToggleTitlesConfiguration; * TmdbShow: ToggleTitlesConfiguration; * AnidbSeason: ToggleTitlesConfiguration; * ShokoSeason: ToggleTitlesConfiguration; * TmdbSeason: ToggleTitlesConfiguration; * AnidbEpisode: ToggleTitlesConfiguration; * ShokoEpisode: ToggleTitlesConfiguration; *}} AllTitlesConfiguration */ /** * @typedef {{ * Type: SeriesType | "None"; * StructureType: SeriesStructureType | "None"; * SeasonOrdering: SeasonOrderType | "None"; * SpecialsPlacement: SpecialOrderType | "None"; * SeasonMergingBehavior: SeasonMergingBehavior; * EpisodeConversion: SeriesEpisodeConversion; * OrderByAirdate: boolean; * }} SeriesConfiguration */ /** * @typedef {{ * AddNotes: boolean; * List: DescriptionProvider[]; * Order: DescriptionProvider[]; * }} DescriptionConfiguration */ /** * @typedef {DescriptionConfiguration &{ * Enabled: boolean; * }} ToggleDescriptionConfiguration */ /** * @typedef {{ * Default: DescriptionConfiguration; * ShokoCollection: ToggleDescriptionConfiguration; * TmdbCollection: ToggleDescriptionConfiguration; * AnidbMovie: ToggleDescriptionConfiguration; * ShokoMovie: ToggleDescriptionConfiguration; * TmdbMovie: ToggleDescriptionConfiguration; * AnidbAnime: ToggleDescriptionConfiguration; * ShokoSeries: ToggleDescriptionConfiguration; * TmdbShow: ToggleDescriptionConfiguration; * AnidbSeason: ToggleDescriptionConfiguration; * ShokoSeason: ToggleDescriptionConfiguration; * TmdbSeason: ToggleDescriptionConfiguration; * AnidbEpisode: ToggleDescriptionConfiguration; * ShokoEpisode: ToggleDescriptionConfiguration; * }} AllDescriptionsConfiguration */ /** * @typedef {{ * UsePreferred: boolean; * UseCommunityRating: boolean; * UseDimensions: boolean; * PosterList: ImageLanguageType[]; * PosterOrder: ImageLanguageType[]; * LogoList: ImageLanguageType[]; * LogoOrder: ImageLanguageType[]; * BackdropList: ImageLanguageType[]; * BackdropOrder: ImageLanguageType[]; * }} ImageConfiguration */ /** * @typedef {ImageConfiguration & { * Enabled: boolean; * }} ToggleImageConfiguration */ /** * @typedef {{ * DebugMode: boolean; * Default: ImageConfiguration; * ShokoCollection: ToggleImageConfiguration; * TmdbCollection: ToggleImageConfiguration; * AnidbMovie: ToggleImageConfiguration; * ShokoMovie: ToggleImageConfiguration; * TmdbMovie: ToggleImageConfiguration; * AnidbAnime: ToggleImageConfiguration; * ShokoSeries: ToggleImageConfiguration; * TmdbShow: ToggleImageConfiguration; * AnidbSeason: ToggleImageConfiguration; * ShokoSeason: ToggleImageConfiguration; * TmdbSeason: ToggleImageConfiguration; * AnidbEpisode: ToggleImageConfiguration; * ShokoEpisode: ToggleImageConfiguration; * }} AllImagesConfiguration */ /** * @typedef {{ * ShowInUI: boolean; * UsageTrackerStalledTimeInSeconds: number; * MaxInFlightRequests: number; * SeriesPageSize: number; * AutoClearClientCache: boolean; * AutoClearManagerCache: boolean; * AutoClearVfsCache: boolean; * ExpirationScanFrequencyInMinutes: number; * SlidingExpirationInMinutes: number; * AbsoluteExpirationRelativeToNowInMinutes: number; * }} DebugConfiguration */ /** * @typedef {{ * CanCreateSymbolicLinks: boolean; * Url: string; * PublicUrl: string; * WebPrefix: string; * ServerVersion: ServerInformation | null; * Username: string; * ApiKey: string; * ThirdPartyIdProviderList: Except<DescriptionProvider, "Shoko">[]; * Title: AllTitlesConfiguration; * MarkSpecialsWhenGrouped: boolean; * DescriptionConversionMode: DescriptionConversionMode; * Description: AllDescriptionsConfiguration; * TagSources: TagSource[]; * TagIncludeFilters: TagIncludeFilter[]; * TagMinimumWeight: TagWeight; * TagMaximumDepth: TagDepth; * TagExcludeList: string[]; * GenreSources: TagSource[]; * GenreIncludeFilters: TagIncludeFilter[]; * GenreMinimumWeight: TagWeight; * GenreMaximumDepth: TagDepth; * GenreExcludeList: string[]; * HideUnverifiedTags: boolean; * Metadata_StudioOnlyAnimationWorks: boolean; * ContentRatingList: GenericProvider[]; * ContentRatingOrder: GenericProvider[]; * ProductionLocationList: GenericProvider[]; * ProductionLocationOrder: GenericProvider[]; * Image: AllImagesConfiguration; * UserList: UserConfig[]; * AutoMergeVersions: boolean; * MergeVersionSortSelectorList: MergeVersionSortSelector[]; * MergeVersionSortSelectorOrder: MergeVersionSortSelector[]; * UseGroupsForShows: boolean; * SeparateMovies: boolean; * FilterMovieLibraries: boolean; * MovieSpecialsAsExtraFeaturettes: boolean; * AddTrailers: boolean; * AddCreditsAsThemeVideos: boolean; * AddCreditsAsSpecialFeatures: boolean; * DefaultLibraryStructure: SeriesStructureType; * DefaultSeasonOrdering: SeasonOrderType; * DefaultSpecialsPlacement: SpecialOrderType; * AddMissingMetadata: boolean; * IgnoredFolders: string[]; * AutoReconstructCollections: boolean; * CollectionGrouping: CollectionCreationType; * CollectionMinSizeOfTwo: boolean; * DefaultLibraryOperationMode: LibraryOperationMode; * VFS_Threads: number; * VFS_AddReleaseGroup: boolean; * VFS_AddResolution: boolean; * VFS_ResolveLinks: boolean; * VFS_UseSourceFileAsVersionIdentifier: boolean; * VFS_MaxTotalExceptionsBeforeAbort: number; * VFS_MaxSeriesExceptionsBeforeAbort: number; * VFS_UseSemaphore: boolean; * VFS_CollectAndSort: boolean; * VFS_Location: VirtualRootLocation; * VFS_CustomLocation: string; * VFS_IterativeGenerationEnabled: boolean; * VFS_IterativeGenerationMaxCount: number; * Libraries: LibraryConfig[]; * LibraryFolders: MediaFolderConfig[]; * SignalR_AutoConnectEnabled: boolean; * SignalR_AutoReconnectInSeconds: number[]; * SignalR_RefreshEnabled: boolean; * SignalR_FileEvents: boolean; * SignalR_EventSources: GenericProvider[]; * SeasonMerging_Enabled: boolean; * SeasonMerging_DefaultBehavior: SeasonMergingBehavior; * SeasonMerging_SeriesTypes: SeriesType[]; * SeasonMerging_MergeWindowInDays: number; * Misc_ShowInMenu: boolean; * AdvancedMode: boolean; * Debug: DebugConfiguration; * }} PluginConfiguration */ /** * @typedef {{ * Id: number; * AnidbId: number; * Title: string; * DefaultTitle: string; * }} SimpleSeries */ /** * Shoko API client. */ export const ShokoApiClient = { /** * The plugin ID. * * @private */ pluginId: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", /** * Get the plugin configuration. * * @public * @returns {Promise<PluginConfiguration>} The plugin configuration. */ getConfiguration() { return ApiClient.getPluginConfiguration(ShokoApiClient.pluginId); }, /** * Update the plugin configuration. * * @public * @param {PluginConfiguration} config - The plugin configuration to update. * @returns {Promise<any>} Some sort of result we don't really care about. */ updateConfiguration(config) { return ApiClient.updatePluginConfiguration(ShokoApiClient.pluginId, config); }, /** * Get an API key for the username and password combo. Optionally get an * user key instead of a plugin key. * * @public * @param {string} username - The username. * @param {string} password - The password. * @param {boolean?} userKey - Optional. Whether to get a user key or a plugin key. * @returns {Promise<{ apikey: string; }>} The API key. */ 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("Shokofin/Host/GetApiKey"), }); }, /** * Get the list of series. * * @public * @param {string} query - The query to search for. * @returns {Promise<SimpleSeries[]>} The list of series. */ getSeriesList(query = "") { return ApiClient.fetch({ dataType: "json", type: "GET", url: query.trim() ? ApiClient.getUrl(`Shokofin/Utility/Series?query=${query}`) : ApiClient.getUrl("Shokofin/Utility/Series"), }); }, /** * Get the configuration for a series. * * @public * @param {string} seriesId - The series ID. * @returns {Promise<SeriesConfiguration>} The API key. */ getSeriesConfiguration(seriesId) { return ApiClient.fetch({ dataType: "json", type: "GET", url: ApiClient.getUrl(`Shokofin/Utility/Series/${seriesId}/Configuration`), }); }, /** * Get the configuration for a series. * * @public * @param {string} seriesId - The series ID. * @param {Partial<SeriesConfiguration>} partialSeriesConfiguration - The series configuration. * @returns {Promise<SeriesConfiguration>} The API key. */ updateSeriesConfiguration(seriesId, partialSeriesConfiguration = { }) { return ApiClient.fetch({ dataType: "json", data: JSON.stringify(partialSeriesConfiguration), headers: { "Content-Type": "application/json", "Accept": "application/json", }, type: "POST", url: ApiClient.getUrl(`Shokofin/Utility/Series/${seriesId}/Configuration`), }); }, /** * Check the status of the SignalR connection. * * @private * @returns {Promise<SignalRStatus>} The SignalR status. */ getSignalrStatus() { return ApiClient.fetch({ dataType: "json", type: "GET", url: ApiClient.getUrl("Shokofin/SignalR/Status"), }); }, /** * Connects to the SignalR stream on the server. * * @public * @returns {Promise<SignalRStatus>} The SignalR status. */ async signalrConnect() { await ApiClient.fetch({ type: "POST", url: ApiClient.getUrl("Shokofin/SignalR/Connect"), }); return ShokoApiClient.getSignalrStatus(); }, /** * Disconnects from the SignalR stream on the server. * * @public * @returns {Promise<SignalRStatus>} The SignalR status. */ async signalrDisconnect() { await ApiClient.fetch({ type: "POST", url: ApiClient.getUrl("Shokofin/SignalR/Disconnect"), }); return ShokoApiClient.getSignalrStatus(); }, }; globalThis.ShokoApiClient = ShokoApiClient; //#endregion //#region State /** * @type {{ * config: PluginConfiguration | null; * metadata: { * title: AllConfigurationTypes; * description: AllConfigurationTypes; * image: AllConfigurationTypes; * } * seriesId: string; * seriesQuery: string; * seriesList: SimpleSeries[] | null; * seriesTimeout: number | null; * currentTab: TabType; * clickCounter: number; * advancedMode: boolean; * debugMode: boolean; * connected: boolean; * timeout: number | null; * }} */ export const State = window["SHOKO_STATE_OBJECT"] || (window["SHOKO_STATE_OBJECT"] = { config: null, metadata: { title: "Default", description: "Default", image: "Default", }, seriesId: "", seriesQuery: "", seriesList: null, seriesTimeout: null, currentTab: "connection", clickCounter: 0, advancedMode: false, debugMode: false, connected: false, timeout: null, }); //#endregion //#region Tabs /** * @typedef {"connection" | "metadata" | "library" | "vfs" | "users" | "series" | "signalr" | "misc" | "utilities"} TabType */ /** * @typedef {Object} ShokoTab * @property {TabType} id The tab id. * @property {string} href The tab href. * @property {string} helpHref The tab help href. * @property {string} name The tab name. * @property {boolean?} connected Optional. Whether the tab is only rendered when or when not connected. * @property {boolean?} advancedMode Optional. Whether the tab is only rendered when in or not in expert mode. */ const DefaultHelpLink = "https://docs.shokoanime.com/jellyfin/configuring-shokofin/"; /** * @type {readonly ShokoTab[]} */ const Tabs = [ { id: "connection", href: getConfigurationPageUrl("Shoko.Settings", "connection"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#connecting-to-shoko-server", name: "Connection", }, { id: "metadata", href: getConfigurationPageUrl("Shoko.Settings", "metadata"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#metadata", name: "Metadata", connected: true, }, { id: "library", href: getConfigurationPageUrl("Shoko.Settings", "library"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#library", name: "Library", connected: true, }, { id: "vfs", href: getConfigurationPageUrl("Shoko.Settings", "vfs"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#vfs", name: "VFS", connected: true, }, { id: "users", href: getConfigurationPageUrl("Shoko.Settings", "users"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#users", name: "Users", connected: true, }, { id: "series", href: getConfigurationPageUrl("Shoko.Settings", "series"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#series", name: "Series", connected: true, }, { id: "signalr", href: getConfigurationPageUrl("Shoko.Settings", "signalr"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#signalr", name: "SignalR", connected: true, }, { id: "misc", href: getConfigurationPageUrl("Shoko.Settings", "misc"), helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#misc", name: "Misc", connected: true, advancedMode: true, }, // { // id: "utilities", // href: getConfigurationPageUrl("Shoko.Settings", "utilities"), // helpHref: "https://docs.shokoanime.com/jellyfin/utilities", // name: "Utilities", // advancedMode: true, // }, ]; /** * Responsible for updating the tabs at the top of the page. * * @param {HTMLElement} view - The view element. * @param {TabType} [tabName] - Optional. Change the current tab. */ export function updateTabs(view, tabName) { if (tabName) { State.currentTab = tabName; } const tabs = Tabs.filter(tab => tab.id === State.currentTab || (tab.connected === undefined || tab.connected === State.connected) && (tab.advancedMode === undefined || tab.advancedMode === State.advancedMode)); let index = tabs.findIndex((tab => tab.id === State.currentTab)); if (index === -1) { index = 0; } LibraryMenu.setTabs("shoko", index, () => tabs); const helpLink = view.querySelector(".sectionTitleContainer > a.headerHelpButton"); if (helpLink) { const currentTab = Tabs.find(tab => tab.id === State.currentTab); if (currentTab) { helpLink.setAttribute("href", currentTab.helpHref); } else { helpLink.setAttribute("href", DefaultHelpLink); } } } //#endregion //#region Constants const Messages = { UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it, and if it doesn't, then reach out to support or debug it yourself. Your call.", }; //#endregion //#region Event Lifecycle /** * Possible properties. * * @typedef {"fullscreen"} Property */ /** * View extra options. * * @typedef {Object} ViewExtraOptions * @property {boolean} supportsThemeMedia Supports theme media. * @property {boolean} enableMediaControls Enables media controls. */ /** * Minimal event details. * * @typedef {Object} MinimalDetails * @property {string} type The request route type. * @property {Property[]} properties The properties that are available in the event. */ /** * Full event details. * * @typedef {Object} FullDetails * @property {string?} type The request route type. * @property {Property[]} properties The properties that are available in the event. * @property {Record<string, string>} params The search query parameters of the current view, from the React's router's POV. * @property {boolean} [isRestored] Whether the current view is restored from a previous hidden state or a brand new view. * @property {any?} state The state of the current view, from the React's router's POV. * @property {ViewExtraOptions} options - The options of the current view. */ /** * First event that's triggered when the page is initialized. * * @callback onViewInit * @this {HTMLDivElement} - The view element. * @param {CustomEvent<{}>} event - The event with the minimal details. * @returns {void} Void. */ /** * Triggered after the init event and when the page is restored from a previous * hidden state, but right before the view is shown. * * @callback onViewBeforeShow * @this {HTMLDivElement} - The view element. * @param {CustomEvent<FullDetails>} event - The event with the full details. * @returns {void} Void. */ /** * Triggered after the init event and when the page is restored from a previous * hidden state, when the view is shown. * * @callback onViewShow * @this {HTMLDivElement} - The view element. * @param {CustomEvent<FullDetails>} event - The event with the full details. * @returns {void} Void. */ /** * Triggered right before the view is hidden. Can be used to cancel the * hiding process by calling {@link Event.preventDefault event.preventDefault()}. * * @callback onViewBeforeHide * @this {HTMLDivElement} - The view element. * @param {CustomEvent<MinimalDetails>} event - The event with the minimal details. * @returns {void} Void. */ /** * Triggered right after the view is hidden. Can be used for clearing up state * before the view is shown again or before it's destroyed. * * @callback onViewHide * @this {HTMLDivElement} - The view element. * @param {CustomEvent<MinimalDetails>} event - The event with the minimal details. * @returns {void} Void. */ /** * Triggered right before the view is destroyed. This means the view will not * be shown again. If you navigate to and from the page it will instead * re-initialise a new instance of the view if it has already destroyed the * previous instance by the time it should show the view. * * @callback onViewDestroy * @this {HTMLDivElement} - The view element. * @param {CustomEvent<{}>} event - The event with the no details. * @returns {void} Void. */ /** * View lifecycle events in all their glory. * * @typedef {Object} ViewLifecycleEvents * @property {onViewInit} onInit * * First event that's triggered when the page is initialized. * * @property {onViewBeforeShow} onBeforeShow * * Triggered after the init event and when the page is restored from a previous * hidden state, but right before the view is shown. * * @property {onViewShow} onShow * * Triggered after the init event and when the page is restored from a previous * hidden state, when the view is shown. * * @property {onViewBeforeHide} onBeforeHide * * Triggered right before the view is hidden. Can be used to cancel the * hiding process by calling {@link Event.preventDefault event.preventDefault()}. * * @property {onViewHide} onHide * * Triggered right after the view is hidden. Can be used for clearing up state * before the view is shown again or before it's destroyed. * * @property {onViewDestroy} onDestroy * * Triggered right before the view is destroyed. This means the view will not * be shown again. If you navigate to and from the page it will instead * re-initialise a new instance of the view if it has already destroyed the * previous instance by the time it should show the view. */ /** * @param {HTMLDivElement} view - The view element. * @param {ViewLifecycleEvents} events - The events. * @param {TabType} [initialTab] - The initial tab. * @param {boolean} [hide] - Whether to hide the view immediately. * @param {boolean} [show] - Whether to show the view immediately. * @returns {void} Void. */ export function setupEvents(view, events, initialTab = "connection", hide = false, show = false) { if (events.onBeforeShow) { view.addEventListener("viewbeforeshow", events.onBeforeShow.bind(view)); } if (events.onShow) { view.addEventListener("viewshow", async (event) => { try { // Clear the current timeout if there is one. if (State.timeout) { clearTimeout(State.timeout); State.timeout = null; } // Set the current tab if the current view supports tabs. if (view.classList.contains("withTabs")) { State.currentTab = new URLSearchParams(window.location.href.split("#").slice(1).join("#").split("?").slice(1).join("?")).get("tab") || initialTab; // And update the tabs if the state is already initialised. if (State.config) { updateTabs(view); } } // Initialise the state now if it's not yet initialised. if (!State.config) { Dashboard.showLoadingMsg(); State.config = await ShokoApiClient.getConfiguration(); State.clickCounter = 0; State.advancedMode = State.config.AdvancedMode; State.debugMode = State.config.Debug.ShowInUI; State.connected = Boolean(State.config.ApiKey); } // Show the view. await events.onShow.call(view, event); if (view.classList.contains("withTabs")) { updateTabs(view); } } catch (err) { // Show an error message if we failed to render the view. Dashboard.alert(Messages.UnableToRender); console.error(Messages.UnableToRender, err); } finally { // Hide the loading message if there is one. Dashboard.hideLoadingMsg(); } }); } if (events.onBeforeHide) { view.addEventListener("viewbeforehide", events.onBeforeHide.bind(view)); } if (events.onHide) { view.addEventListener("viewhide", (event) => { // Clear the current timeout if there is one. if (State.timeout) { clearTimeout(State.timeout); State.timeout = null; } // Hide the view. events.onHide.call(view, event); // Reset the state after the view is hidden if we're not switching // to another view. State.timeout = setTimeout(() => { State.config = null; State.metadata = { title: "Default", description: "Default", image: "Default", }; State.currentTab = initialTab; State.clickCounter = 0; State.advancedMode = false; State.debugMode = false; State.connected = false; State.timeout = null; }, 100); }); } if (events.onDestroy) { view.addEventListener("viewdestroy", events.onDestroy.bind(view)); } // Override any links with link redirection set. view.querySelectorAll("a.link-redirection").forEach(overrideLink); view.querySelectorAll("div[is=\"sortable-checkbox-list\"]").forEach(overrideSortableCheckboxList); // The view event is only send if a controller factory is not provided… // which is not the case here, since we're running in the controller factory // right now. So just send the init event now. if (events.onInit) { const initEvent = new CustomEvent("viewinit", { detail: {}, bubbles: true, cancelable: false }); events.onInit.call(view, initEvent); // Do nothing if both show and hide are requested. if (hide && show) return; // Show the view if requested. if (show) { const eventDetails = { /** @type {FullDetails} */ detail: { type: view.getAttribute("data-type") || null, params: Object.fromEntries(new URLSearchParams(window.location.hash.split("#").slice(1).join("#").split("?").slice(1).join("?"))), properties: (view.getAttribute("data-properties") || "").split(","), isRestored: undefined, state: null, options: { supportsThemeMedia: false, enableMediaControls: true, }, }, bubbles: true, cancelable: false, } view.dispatchEvent(new CustomEvent("viewbeforeshow", eventDetails)); view.dispatchEvent(new CustomEvent("viewshow", eventDetails)); } // Hide the view if requested. if (hide) { const eventDetails = { /** @type {MinimalDetails} */ detail: { type: view.getAttribute("data-type") || null, properties: (view.getAttribute("data-properties") || "").split(","), }, bubbles: true, cancelable: false, }; view.dispatchEvent(new CustomEvent("viewbeforehide", { ...eventDetails, cancelable: true })); view.dispatchEvent(new CustomEvent("viewhide", eventDetails)); } } } //#endregion //#region Controller Factory /** * A factory responsible for creating a new view and setting up its events as * needed. * * @callback controllerFactoryFn * @param {HTMLDivElement} view - The view element. * @returns {void} Void. */ /** * Controller factory options. * * @typedef {Object} controllerFactoryOptions * @property {ViewLifecycleEvents} events The lifecycle events for the view. * @property {TabType} [initialTab] - The initial tab. * @property {boolean} [show] - Whether to show the view immediately. * @property {boolean} [hide] - Whether to hide the view immediately. */ /** * Create a new view and set up its events as needed. * * @param {controllerFactoryOptions} options - The controller factory options. * @returns {controllerFactoryFn} The controller factory. */ export function createControllerFactory(options) { const { events, initialTab, hide, show } = options; return function(view) { setupEvents(view, events, initialTab, hide, show); } } //#endregion //#region Helpers //#region Helpers - Handle Error /** * Handle an error during a configuration update. * * @param {any} err - The error. */ export function handleError(err) { console.error(err); Dashboard.alert(`An error occurred; ${err.message}`); Dashboard.hideLoadingMsg(); } //#endregion //#region Helpers - Override Link /** * Construct the URL for a tab on the configuration page. * * @param {string} page * @param {string} [tab] * @returns {string} */ function getConfigurationPageUrl(page, tab = "") { const urlSearch = new URLSearchParams(); urlSearch.set("name", page); if (tab) { urlSearch.set("tab", tab); } return "configurationpage?" + urlSearch.toString(); } /** * Redirect a link to the configuration page through React instead of the * browser. * * @param {HTMLAnchorElement} event */ function onLinkRedirectClick(event) { event.preventDefault(); Dashboard.navigate(getConfigurationPageUrl(event.target.dataset.href)); } /** * Override links to the configuration page in the DOM. * * @param {HTMLAnchorElement} target - The link to override. * @returns {void} Void. */ function overrideLink(target) { const page = target.dataset.page; target.href = location.href.split("#")[0] + "#" + getConfigurationPageUrl(page); target.addEventListener("click", onLinkRedirectClick); } //#endregion //#region Helpers - Checkbox List /** * @param {HTMLFormElement} form * @param {string} name * @param {string[]} enabled * @returns {void} **/ export function renderCheckboxList(form, name, enabled) { for (const item of Array.from(form.querySelectorAll(`#${name}[is=\"checkbox-list\"] .listItem input[data-option]`))) { item.checked = enabled.includes(item.dataset.option); } } /** * Retrieve the enabled state from a simple list. * * @param {HTMLFormElement} form * @param {string} name - Name of the selector list to retrieve. * @returns {string[]} **/ export function retrieveCheckboxList(form, name) { return Array.from(form.querySelectorAll(`#${name}[is=\"checkbox-list\"] .listItem input[data-option]`)) .filter(item => item.checked) .map(item => item.dataset.option) .sort(); } //#endregion //#region Helpers - Sortable Checkbox List /** * Handle the click event on the buttons within a sortable list. * * @param {PointerEvent} event - The click event. **/ function onSortableContainerClick(event) { const btnSortable = getParentWithClass(event.target, "btnSortable"); if (!btnSortable) return; const listItem = getParentWithClass(btnSortable, "sortableOption"); if (!listItem) return; const list = getParentWithClass(listItem, "paperList"); if (!list) return; 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 index = 0; for (const option of list.querySelectorAll(".sortableOption")) { adjustSortableListElement(option, index++); } } /** * Override the click event on the buttons within a sortable list. * * @param {HTMLDivElement} element */ export function overrideSortableCheckboxList(element) { element.addEventListener("click", onSortableContainerClick); } /** * Adjust the sortable list element. * * @param {HTMLElement} element - The element. * @param {number} index - The index of the element. */ 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"); } } /** * Get the parent element with the given class, or null if not found. * * @param {HTMLElement} element - The element. * @param {string} className - The class name. * @returns {HTMLElement | null} The parent element with the given class, or * null if not found. */ export function getParentWithClass(element, className) { return element.parentElement.classList.contains(className) ? element.parentElement : null; } /** * Render a sortable checkbox list. * * @param {HTMLFormElement} form * @param {string} name * @param {string[]} enabled * @param {string[]} order * @returns {void} */ export function renderSortableCheckboxList(form, name, enabled, order) { let index = 0; const list = form.querySelector(`#${name}[is=\"sortable-checkbox-list\"] .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, })); if (order.length === 0) { order = listItems.map((item) => item.option); } list.innerHTML = ""; for (const option of order) { const { item, checkbox, isSortable } = listItems.find((item) => item.option === option) || {}; if (!item) continue; list.append(item); checkbox.checked = enabled.includes(option); if (isSortable) adjustSortableListElement(item, index++); } } /** * Retrieve the enabled state and order list from a sortable list. * * @param {HTMLElement} view - The view element. * @param {string} name - The name of the sortable checkbox list to retrieve. * @returns {[string[], string[]]} */ export function retrieveSortableCheckboxList(view, name) { const titleElements = Array.from(view.querySelectorAll(`#${name}[is=\"sortable-checkbox-list\"] .listItem input[data-option]`)); const getValue = (el) => el.dataset.option; return [ titleElements .filter((el) => el.checked) .map(getValue) .sort(), titleElements .map(getValue), ]; } //#endregion //#endregion //#region Modules //#region Modules - 'escape-html' // NOTE: Included a copy since we can't 'require' or 'import' the version // bundled with the web UI. Also modified it to fit the code style of this // project. /*! * escape-html * Copyright(c) 2012-2013 TJ Holowaychuk * Copyright(c) 2015 Andreas Lubbe * Copyright(c) 2015 Tiancheng "Timothy" Gu * Copyright(c) 2025 Shokofin Project Contributors * MIT Licensed */ const matchHtmlRegExp = /["'&<>]/; /** * Escape special characters in the given string of html. * * @param {string} string The string to escape for inserting into HTML * @return {string} * @public */ export function escapeHtml(string) { let str = "" + string; let match = matchHtmlRegExp.exec(str); if (!match) return str; let escape; let html = ""; let index = 0; let lastIndex = 0; for (index = match.index; index < str.length; index++) { switch (str.charCodeAt(index)) { case 34: // " escape = """; break; case 38: // & escape = "&"; break; case 39: // ' escape = "'"; break; case 60: // < escape = "<"; break; case 62: // > escape = ">"; break; default: continue; } if (lastIndex !== index) { html += str.substring(lastIndex, index); } lastIndex = index + 1; html += escape; } return lastIndex !== index ? html + str.substring(lastIndex, index) : html; } //#endregion //#endregion ================================================ FILE: Shokofin/Pages/Scripts/Dummy.js ================================================ export default function (view) { let show = false; let hide = false; view.addEventListener("viewshow", () => show = true); view.addEventListener("viewhide", () => hide = true); /** * @type {import("./Common.js").ApiClientPrototype} */ const ApiClient = globalThis.ApiClient; /** * @type {import("./Common.js").DashboardPrototype} */ const Dashboard = globalThis.Dashboard; /** * @type {Promise<import("./Common.js")>} */ const promise = import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); promise.then(({ State, createControllerFactory }) => { createControllerFactory({ show, hide, initialTab: "utilities", events: { onShow(event) { const content = this.querySelector(".content-primary"); const { isRestored = false } = event.detail; if (isRestored) { State.timeout = setTimeout(() => { content.innerHTML = "Baka baka!"; }, 2000); } else { State.timeout = setTimeout(() => { content.innerHTML = "Baka!"; }, 2000); } }, onHide() { const content = this.querySelector(".content-primary"); content.innerHTML = "Dummy."; }, }, })(view); }); } ================================================ FILE: Shokofin/Pages/Scripts/Settings.js ================================================ export default function (view) { let show = false; let hide = false; view.addEventListener("viewshow", () => show = true); view.addEventListener("viewhide", () => hide = true); /** * @type {import("./Common.js").ApiClientPrototype} */ const ApiClient = globalThis.ApiClient; /** * @type {import("./Common.js").DashboardPrototype} */ const Dashboard = globalThis.Dashboard; /** * @type {Promise<import("./Common.js")>} */ const promise = import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); promise.then(({ ShokoApiClient, State, createControllerFactory, escapeHtml, getParentWithClass, handleError, overrideSortableCheckboxList, renderCheckboxList, renderSortableCheckboxList, retrieveCheckboxList, retrieveSortableCheckboxList, updateTabs, }) => { //#region Constants /** * @typedef {"Connection" | "Metadata_Title" | "Metadata_Description" | "Metadata_TagGenre" | "Metadata_Image" | "Metadata_Misc" | "Library_Basic" | "Library_Collection" | "Library_MultipleVersions" | "Library_MediaFolder" | "Library_SeasonMerging" | "VFS_Basic" | "VFS_Location" | "User" | "Series" | "SignalR_Connection" | "SignalR_Basic" | "SignalR_Library_New" | "SignalR_Library_Existing" | "Misc" | "Debug" | "Utilities"} SectionType */ const MaxDebugPresses = 7; /** * @type {SectionType[]} */ const Sections = [ "Connection", "Metadata_Title", "Metadata_Description", "Metadata_TagGenre", "Metadata_Image", "Metadata_Misc", "Library_Basic", "Library_Collection", "Library_MultipleVersions", "Library_MediaFolder", "Library_SeasonMerging", "VFS_Basic", "VFS_Location", "User", "Series", "SignalR_Connection", "SignalR_Basic", "SignalR_Library_New", "SignalR_Library_Existing", "Misc", "Debug", "Utilities", ]; const Messages = { ViewModeCountdown: "Press <count> more times to <toggle> view mode.", ExpertModeEnabled: "Advanced mode enabled.", ExpertModeDisabled: "Advanced mode disabled.", DebugModeEnabled: "Debug mode enabled.", DebugModeDisabled: "Debug mode disabled.", ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", ConnectedToShoko: "Connection established.", DisconnectedToShoko: "Connection has been reset.", InvalidCredentials: "An error occurred while trying to authenticating the user using the provided credentials.", }; let alternateTitleListTemplate = ""; //#endregion //#region Controller Logic createControllerFactory({ show, hide, events: { onInit() { const view = this; const form = view.querySelector("form"); if (alternateTitleListTemplate === "") { alternateTitleListTemplate = form.querySelector("#TitleAlternateListContainer").innerHTML; form.querySelector("#TitleAlternateListContainer").innerHTML = ""; } form.querySelector("#ServerVersion").addEventListener("click", async function onVersionClick() { if (++State.clickCounter === MaxDebugPresses) { State.clickCounter = 0; State.advancedMode = !State.advancedMode; State.debugMode = false; // Reset the metadata views if we're disabling expert mode. if (!State.advancedMode) { State.metadata.title = "Default"; State.metadata.description = "Default"; State.metadata.image = "Default"; } const config = await toggleExpertMode(State.advancedMode, State.debugMode); await updateView(view, form, config); return; } if (State.clickCounter >= 3) Dashboard.alert(Messages.ViewModeCountdown.replace("<count>", MaxDebugPresses - State.clickCounter).replace("<toggle>", State.advancedMode ? "disable" : "enable")); }); form.querySelector(".sectionTitleContainer > a").addEventListener("click", async function(event) { if ((State.clickCounter + 1) === MaxDebugPresses) { event.preventDefault(); event.stopImmediatePropagation(); State.clickCounter = 0; State.advancedMode = !State.advancedMode; State.debugMode = State.advancedMode; // Reset the metadata views if we're disabling expert mode. if (!State.advancedMode) { State.metadata.title = "Default"; State.metadata.description = "Default"; State.metadata.image = "Default"; } const config = await toggleExpertMode(State.advancedMode, State.debugMode); await updateView(view, form, config); return; } }); form.querySelector("#UserSelector").addEventListener("change", function () { applyUserConfigToForm(form, this.value); }); form.querySelector("#SeriesSearch").addEventListener("input", function () { const value = this.value.trim(); if (State.seriesQuery === value && State.seriesTimeout) { return; } if (State.seriesTimeout) { clearTimeout(State.seriesTimeout); } const timeout = State.seriesTimeout = setTimeout(async () => { if (State.seriesTimeout !== timeout) { return; } console.log("Series Search: " + value); State.seriesQuery = value; applySeriesConfigToForm(form, ""); form.querySelector("#SeriesSelector").setAttribute("disabled", ""); try { State.seriesList = await ShokoApiClient.getSeriesList(value); } catch (error) { console.log(error, "Got an error attempting to search for a series."); form.querySelector("#SeriesSelector").value = ""; form.querySelector("#SeriesSelector").innerHTML = `<option value="">Failed to load series!</option>`; form.querySelector("#SeriesSelector").removeAttribute("disabled"); return; } if (State.seriesTimeout !== timeout) { console.log("Returned too late for series search: " + value); return; } const series = State.seriesList; const seriesId = value && series.length > 0 ? series[0].Id.toString() : ""; State.seriesTimeout = null; form.querySelector("#SeriesSelector").innerHTML = `<option value="">Click here to select a series</option>` + series.map((s) => `<option value="${s.Id}">${s.Title.length >= 50 ? `${s.Title.substring(0, 47)}...` : s.Title} (a${s.AnidbId})</option>`).join(""); form.querySelector("#SeriesSelector").removeAttribute("disabled"); form.querySelector("#SeriesSelector").value = seriesId; applySeriesConfigToForm(form, seriesId); }, 250); }); form.querySelector("#SeriesSelector").addEventListener("change", function () { applySeriesConfigToForm(form, this.value); }); form.querySelectorAll("#SeriesSeasonMergingBehavior input").forEach(input => input.addEventListener("change", onSeasonMergingBehaviorChange)); function onSeasonMergingBehaviorChange() { const option = this.getAttribute("data-option"); const value = this.checked; if (option === "NoMerge") { if (value) { form.querySelectorAll("#SeriesSeasonMergingBehavior input").forEach((input) => { if (input !== this) { input.checked = false; } }); } return; } if (value) { const input = form.querySelector("#SeriesSeasonMergingBehavior input[data-option=\"NoMerge\"]"); if (input.getAttribute("data-option") === "NoMerge" && input.checked) { input.checked = false; } } if (option.startsWith("MergeGroup")) { const reverse = option.slice(0, 11) + (option.slice(11) === "Target" ? "Source" : "Target"); const reversedInput = form.querySelector(`#SeriesSeasonMergingBehavior input[data-option="${reverse}"]`); if (value) { reversedInput.checked = false; } } } form.querySelector("#Title_ConfigureFor").addEventListener("change", function () { applyTitleFormToConfig(form, State.config); State.metadata.title = this.value; applyConfigToForm(form, State.config); }); form.querySelector("#Description_ConfigureFor").addEventListener("change", function () { applyDescriptionFormToConfig(form, State.config); State.metadata.description = this.value; applyConfigToForm(form, State.config); }); form.querySelector("#Image_ConfigureFor").addEventListener("change", function () { applyImageFormToConfig(form, State.config); State.metadata.image = this.value; applyConfigToForm(form, State.config); }); form.querySelector("#MediaFolderSelector").addEventListener("change", function () { applyLibraryConfigToForm(form, this.value); }); form.querySelector("#MediaFolderLibraryOperationMode").addEventListener("change", function () { const libraryId = form.querySelector("#MediaFolderSelector").value; if (!libraryId) return; const value = this.value; const mediaFolders = State.config.LibraryFolders.filter((c) => c.LibraryId === libraryId); renderFolderList(form, value !== "VFS", "MediaFolderManagedFolderMapping", mediaFolders.map(mediaFolderConfigToString)); }); form.querySelector("#MediaFolderManagedFolderMapping .btnAddFolder").addEventListener("click", function () { const libraryId = form.querySelector("#MediaFolderSelector").value; if (!libraryId) return; const picker = new Dashboard.DirectoryBrowser(); picker.show({ callback: function (path) { if (path) { addMediaFolder(form, libraryId, State.config, path); } picker.close(); } }); }); form.querySelector("#MediaFolderManagedFolderMapping .folderList").addEventListener("click", function (e) { const libraryId = form.querySelector("#MediaFolderSelector").value; if (!libraryId) return; const button = getParentWithClass(e.target, "listItemButton"); if (!button) return; const name = button.getAttribute("name"); const listItem = getParentWithClass(button, "listItem"); if (!listItem || !name) return; const index = parseInt(listItem.getAttribute("data-index"), 10); if (Number.isNaN(index)) return; switch (name) { case "search": toggleRefreshOfMediaFolder(form, libraryId, State.config, index); break; case "ignore": toggleIgnoredMediaFolder(form, libraryId, State.config, index); break; case "remove-path": removeMediaFolder(form, libraryId, State.config, index); break; } }); form.querySelector("#SignalRMediaFolderSelector").addEventListener("change", function () { applySignalrLibraryConfigToForm(form, 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("#VFS_Location").addEventListener("change", function () { form.querySelector("#VFS_CustomLocation").disabled = this.value !== "Custom"; if (this.value === "Custom") { form.querySelector("#VFS_CustomLocationContainer").removeAttribute("hidden"); } else { form.querySelector("#VFS_CustomLocationContainer").setAttribute("hidden", ""); } }); form.addEventListener("submit", function (event) { event.preventDefault(); if (!event.submitter) return; switch (event.submitter.name) { case "settings": Dashboard.showLoadingMsg(); syncSettings(form) .then((config) => updateView(view, form, config)) .catch(handleError); break; case "unlink-user": removeUserConfig(form) .then((config) => updateView(view, form, config)) .catch(handleError); break; case "remove-alternate-title": removeAlternateTitle(form, parseInt(event.submitter.dataset.index, 10)) .then((config) => updateView(view, form, config)) .catch(handleError); break; case "add-alternate-title": addAlternateTitle(form) .then((config) => updateView(view, form, config)) .catch(handleError); break; case "signalr-connect": ShokoApiClient.signalrConnect() .then((status) => updateSignalrStatus(form, status)) .catch(handleError); break; case "signalr-disconnect": ShokoApiClient.signalrDisconnect() .then((status) => updateSignalrStatus(form, status)) .catch(handleError); break; case "reset-connection": Dashboard.showLoadingMsg(); resetConnection(form) .then((config) => updateView(view, form, config)) .catch(handleError); break; default: case "establish-connection": Dashboard.showLoadingMsg(); defaultSubmit(form) .then((config) => updateView(view, form, config)) .catch(handleError); break; } return false; }); }, async onShow() { const view = this; const form = view.querySelector("form"); // Apply the configuration to the form. await applyConfigToForm(form, State.config); // Update the view. await updateView(view, form, State.config); // Show the alert if we're not connected. if (!State.connected) { Dashboard.alert(Messages.ConnectToShoko); } }, onHide() { const form = this.querySelector("form"); applyFormToConfig(form, State.config); }, } })(view); /** * Update the view to reflect the current state. * * @param {HTMLDivElement} view - The view element. * @param {HTMLFormElement} form - The form element. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<void>} */ async function updateView(view, form, config) { State.config = config; State.clickCounter = 0; State.advancedMode = config.AdvancedMode; State.debugMode = config.Debug.ShowInUI; State.connected = Boolean(config.ApiKey); if (State.advancedMode) { form.classList.add("advanced-mode"); } else { form.classList.remove("advanced-mode"); } if (State.debugMode) { form.classList.add("debug-mode"); } else { form.classList.remove("debug-mode"); } if (!config.CanCreateSymbolicLinks) { form.querySelector("#WindowsSymLinkWarning1").removeAttribute("hidden"); form.querySelector("#WindowsSymLinkWarning2").removeAttribute("hidden"); } if (State.connected) { form.querySelector("#Url").removeAttribute("required"); form.querySelector("#Username").removeAttribute("required"); } else { form.querySelector("#Url").setAttribute("required", ""); form.querySelector("#Username").setAttribute("required", ""); } /** * @type {SectionType[]} */ const activeSections = []; switch (State.currentTab) { case "connection": activeSections.push("Connection"); 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 (State.connected) { form.querySelector("#Url").removeAttribute("required"); form.querySelector("#Username").removeAttribute("required"); 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"); } else { form.querySelector("#Url").setAttribute("required", ""); form.querySelector("#Username").setAttribute("required", ""); 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", ""); } break; case "metadata": activeSections.push("Metadata_Title", "Metadata_Description", "Metadata_TagGenre", "Metadata_Image", "Metadata_Misc"); if (form.querySelector("#Title_ConfigureFor").value !== State.metadata.title) { form.querySelector("#Title_ConfigureFor").value = State.metadata.title; } if (form.querySelector("#Description_ConfigureFor").value !== State.metadata.description) { form.querySelector("#Description_ConfigureFor").value = State.metadata.description; } if (form.querySelector("#Image_ConfigureFor").value !== State.metadata.image) { form.querySelector("#Image_ConfigureFor").value = State.metadata.image; } if (form.querySelectorAll("#TitleAlternateListContainer > fieldset").length >= 5) { form.querySelector("button[name=\"add-alternate-title\"]").setAttribute("disabled", ""); } else { form.querySelector("button[name=\"add-alternate-title\"]").removeAttribute("disabled"); } break; case "library": activeSections.push("Library_Basic", "Library_Collection", "Library_MultipleVersions", "Library_MediaFolder", "Library_SeasonMerging"); await applyLibraryConfigToForm(form, form.querySelector("#MediaFolderSelector").value, config); break; case "vfs": activeSections.push("VFS_Basic", "VFS_Location"); break; case "users": activeSections.push("User"); await applyUserConfigToForm(form, form.querySelector("#UserSelector").value, config); break; case "series": activeSections.push("Series"); await applySeriesConfigToForm(form, form.querySelector("#SeriesSelector").value, config); break; case "signalr": activeSections.push("SignalR_Connection", "SignalR_Basic", "SignalR_Library_New", "SignalR_Library_Existing"); await applySignalrLibraryConfigToForm(form, form.querySelector("#SignalRMediaFolderSelector").value, config); break; case "misc": activeSections.push("Misc", "Debug"); break; case "utilities": activeSections.push("Utilities"); break; } for (const sectionName of Sections) { const id = `#${sectionName}_Section`; const active = activeSections.includes(sectionName); if (active) { form.querySelector(id).removeAttribute("hidden"); } else { form.querySelector(id).setAttribute("hidden", ""); } } updateTabs(view); } /** * Update the SignalR status. * * @param {HTMLFormElement} form - The form element. * @param {SignalRStatus} status - The SignalR status. */ function updateSignalrStatus(form, 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", ""); } } //#endregion //#region Form → Configuration /** * Apply a form to a configuration object. * * @param {HTMLFormElement} form - The form element. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. */ function applyFormToConfig(form, config) { switch (State.currentTab) { case "metadata": { config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; applyTitleFormToConfig(form, config); config.DescriptionConversionMode = form.querySelector("#DescriptionConversionMode").value; applyDescriptionFormToConfig(form, config); const tagExcludeList = filterTags(form.querySelector("#TagExcludeList").value); const genreExcludeList = filterTags(form.querySelector("#GenreExcludeList").value); config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; config.TagSources = retrieveCheckboxList(form, "TagSources").join(", "); config.TagIncludeFilters = retrieveCheckboxList(form, "TagIncludeFilters").join(", "); config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; config.TagMaximumDepth = parseInt(form.querySelector("#TagMaximumDepth").value, 10); config.TagExcludeList = tagExcludeList; form.querySelector("#TagExcludeList").value = tagExcludeList.join(", "); config.GenreSources = retrieveCheckboxList(form, "GenreSources").join(", "); config.GenreIncludeFilters = retrieveCheckboxList(form, "GenreIncludeFilters").join(", "); config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value; config.GenreMaximumDepth = parseInt(form.querySelector("#GenreMaximumDepth").value, 10); config.GenreExcludeList = genreExcludeList; form.querySelector("#GenreExcludeList").value = genreExcludeList.join(", "); config.Image.DebugMode = form.querySelector("#Image_DebugMode").checked; applyImageFormToConfig(form, config); config.Metadata_StudioOnlyAnimationWorks = form.querySelector("#Metadata_StudioOnlyAnimationWorks").checked; ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableCheckboxList(form, "Metadata_ContentRatingList")); ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableCheckboxList(form, "Metadata_ProductionLocationList")); config.ThirdPartyIdProviderList = retrieveCheckboxList(form, "Metadata_ThirdPartyIdProviderList"); break; } case "library": { const libraryId = form.querySelector("#MediaFolderSelector").value; const libraries = libraryId ? config.Libraries.filter((m) => m.Id === libraryId) : undefined; const seasonMergeWindow = sanitizeNumber(form.querySelector("#SeasonMerging_MergeWindowInDays").value); const vfsIterativeGenerationMaxCount = sanitizeNumber(form.querySelector("#VFS_IterativeGenerationMaxCount").value, 0, 100); config.DefaultLibraryStructure = form.querySelector("#DefaultLibraryStructure").value; config.DefaultSeasonOrdering = form.querySelector("#DefaultSeasonOrdering").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.FilterMovieLibraries = !form.querySelector("#DisableFilterMovieLibraries").checked; config.DefaultSpecialsPlacement = form.querySelector("#DefaultSpecialsPlacement").value; config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; config.AutoReconstructCollections = form.querySelector("#AutoReconstructCollections").checked; config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.CollectionMinSizeOfTwo = form.querySelector("#CollectionMinSizeOfTwo").checked; config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; ([config.MergeVersionSortSelectorList, config.MergeVersionSortSelectorOrder] = retrieveSortableCheckboxList(form, "MergeVersionSortSelectorList")); config.DefaultLibraryOperationMode = form.querySelector("#DefaultLibraryOperationMode").value; config.VFS_IterativeGenerationEnabled = form.querySelector("#VFS_IterativeGenerationEnabled").checked; config.VFS_IterativeGenerationMaxCount = vfsIterativeGenerationMaxCount; form.querySelector("#VFS_IterativeGenerationMaxCount").value = vfsIterativeGenerationMaxCount; if (libraries) { for (const c of libraries) { const maxCount = sanitizeNumber(form.querySelector("#MediaFolderLibraryIterativeGenerationMaxCount").value, 0, 100); c.LibraryOperationMode = form.querySelector("#MediaFolderLibraryOperationMode").value; c.IterativeVfsGeneration_Enabled = form.querySelector("#MediaFolderLibraryIterativeGenerationEnabled").checked; c.IterativeVfsGeneration_NoCache = form.querySelector("#MediaFolderLibraryIterativeGenerationNoCache").checked; c.IterativeVfsGeneration_MaxCount = maxCount; form.querySelector("#MediaFolderLibraryIterativeGenerationMaxCount").value = maxCount; c.IterativeVfsGeneration_ForceFullGenerationOnNextRefresh = form.querySelector("#MediaFolderLibraryForceFullGenerationOnNextRefresh").checked; } } config.SeasonMerging_Enabled = form.querySelector("#SeasonMerging_Enabled").checked; config.SeasonMerging_DefaultBehavior = form.querySelector("#SeasonMerging_AutoMerge").checked ? "None" : "NoMerge"; config.SeasonMerging_SeriesTypes = retrieveCheckboxList(form, "SeasonMerging_SeriesTypes"); config.SeasonMerging_MergeWindowInDays = seasonMergeWindow; form.querySelector("#SeasonMerging_MergeWindowInDays").value = seasonMergeWindow; break; } case "vfs": { const vfsTreads = sanitizeNumber(form.querySelector("#VFS_Threads").value, -1); const vfsMaxTotalExceptionsBeforeAbort = sanitizeNumber(form.querySelector("#VFS_MaxTotalExceptionsBeforeAbort").value, 0, 10_000); const vfsMaxSeriesExceptionsBeforeAbort = sanitizeNumber(form.querySelector("#VFS_MaxSeriesExceptionsBeforeAbort").value, 0, 1_000); config.AddTrailers = form.querySelector("#AddTrailers").checked; config.AddCreditsAsThemeVideos = form.querySelector("#AddCreditsAsThemeVideos").checked; config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; config.VFS_UseSourceFileAsVersionIdentifier = form.querySelector("#VFS_UseSourceFileAsVersionIdentifier").checked; config.VFS_Threads = vfsTreads; form.querySelector("#VFS_Threads").value = vfsTreads; config.VFS_ResolveLinks = form.querySelector("#VFS_ResolveLinks").checked; config.VFS_MaxTotalExceptionsBeforeAbort = vfsMaxTotalExceptionsBeforeAbort; form.querySelector("#VFS_MaxTotalExceptionsBeforeAbort").value = vfsMaxTotalExceptionsBeforeAbort; config.VFS_MaxSeriesExceptionsBeforeAbort = vfsMaxSeriesExceptionsBeforeAbort; form.querySelector("#VFS_MaxSeriesExceptionsBeforeAbort").value = vfsMaxSeriesExceptionsBeforeAbort; config.VFS_UseSemaphore = form.querySelector("#VFS_UseSemaphore").checked; config.VFS_CollectAndSort = form.querySelector("#VFS_CollectAndSort").checked; config.VFS_Location = form.querySelector("#VFS_Location").value; config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; break; } case "users": { 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); } 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; if (!userConfig.Token) { const username = form.querySelector("#UserUsername").value; userConfig.Username = username; } } break; } case "signalr": { const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); const libraryId = form.querySelector("#SignalRMediaFolderSelector").value; const libraries = libraryId ? config.Libraries.filter((m) => m.Id === libraryId) : undefined; config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; config.SignalR_AutoReconnectInSeconds = reconnectIntervals; form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); config.SignalR_EventSources = retrieveCheckboxList(form, "SignalREventSources"); config.SignalR_FileEvents = form.querySelector("#SignalRDefaultFileEvents").checked; config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; if (libraries) { for (const c of libraries) { c.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; c.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; } } break; } case "misc": { const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); const stallTime = sanitizeNumber(form.querySelector("#Debug_UsageTrackerStalledTimeInSeconds").value, 1, 10800); const maxRequests = sanitizeNumber(form.querySelector("#Debug_MaxInFlightRequests").value, 1, 1000); const seriesPageSize = sanitizeNumber(form.querySelector("#Debug_SeriesPageSize").value, 0, 10_000); const expirationScanFrequency = sanitizeNumber(form.querySelector("#Debug_ExpirationScanFrequencyInMinutes").value, 1, 180); const slidingExpiration = sanitizeNumber(form.querySelector("#Debug_SlidingExpirationInMinutes").value, 1, 180); const absoluteExpiration = sanitizeNumber(form.querySelector("#Debug_AbsoluteExpirationRelativeToNowInMinutes").value, 1, 1440); config.Misc_ShowInMenu = form.querySelector("#Misc_ShowInMenu").checked; config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(", "); config.Debug.UsageTrackerStalledTimeInSeconds = stallTime; form.querySelector("#Debug_UsageTrackerStalledTimeInSeconds").value = config.Debug.UsageTrackerStalledTimeInSeconds; config.Debug.MaxInFlightRequests = maxRequests; form.querySelector("#Debug_MaxInFlightRequests").value = config.Debug.MaxInFlightRequests; config.Debug.SeriesPageSize = seriesPageSize; form.querySelector("#Debug_SeriesPageSize").value = config.Debug.SeriesPageSize; config.Debug.AutoClearClientCache = form.querySelector("#Debug_AutoClearClientCache").checked; config.Debug.AutoClearManagerCache = form.querySelector("#Debug_AutoClearManagerCache").checked; config.Debug.AutoClearVfsCache = form.querySelector("#Debug_AutoClearVfsCache").checked; config.Debug.ExpirationScanFrequencyInMinutes = expirationScanFrequency; form.querySelector("#Debug_ExpirationScanFrequencyInMinutes").value = config.Debug.ExpirationScanFrequencyInMinutes; config.Debug.SlidingExpirationInMinutes = slidingExpiration; form.querySelector("#Debug_SlidingExpirationInMinutes").value = config.Debug.SlidingExpirationInMinutes; config.Debug.AbsoluteExpirationRelativeToNowInMinutes = absoluteExpiration; form.querySelector("#Debug_AbsoluteExpirationRelativeToNowInMinutes").value = config.Debug.AbsoluteExpirationRelativeToNowInMinutes; break; } } } /** * Apply the title settings from a form to a configuration object. * * @param {HTMLFormElement} form - The form element. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. */ function applyTitleFormToConfig(form, config) { if (config.Title[State.metadata.title].Enabled !== undefined) { config.Title[State.metadata.title].Enabled = form.querySelector("#Title_ConfigureFor_Enabled").checked; } config.Title[State.metadata.title].RemoveDuplicates = form.querySelector("#RemoveDuplicateTitles").checked; ([config.Title[State.metadata.title].MainTitle.List, config.Title[State.metadata.title].MainTitle.Order] = retrieveSortableCheckboxList(form, "TitleMainList")); config.Title[State.metadata.title].MainTitle.AllowAny = form.querySelector("#TitleMainAllowAny").checked; config.Title[State.metadata.title].AlternateTitles = []; const alternateTitles = form.querySelectorAll("#TitleAlternateListContainer > fieldset"); for (let i = 1; i <= alternateTitles.length; i++) { const [list, order] = retrieveSortableCheckboxList(form, `TitleAlternateList_${i}`); config.Title[State.metadata.title].AlternateTitles.push({ List: list, Order: order, AllowAny: form.querySelector(`#TitleAlternateAllowAny_${i}`).checked, }); } } /** * Apply the description settings from a form to a configuration object. * * @param {HTMLFormElement} form - The form element. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. */ function applyDescriptionFormToConfig(form, config) { if (config.Description[State.metadata.description].Enabled !== undefined) { config.Description[State.metadata.description].Enabled = form.querySelector("#Description_ConfigureFor_Enabled").checked; } config.Description[State.metadata.description].AddNotes = form.querySelector("#Description_AddNotes").checked; ([config.Description[State.metadata.description].List, config.Description[State.metadata.description].Order] = retrieveSortableCheckboxList(form, "DescriptionSourceList")); } /** * Apply the image settings from a form to a configuration object. * * @param {HTMLFormElement} form - The form element. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. */ function applyImageFormToConfig(form, config) { if (config.Image[State.metadata.image].Enabled !== undefined) { config.Image[State.metadata.image].Enabled = form.querySelector("#Image_ConfigureFor_Enabled").checked; } config.Image[State.metadata.image].UsePreferred = form.querySelector("#Image_UsePreferred").checked; config.Image[State.metadata.image].UseCommunityRating = form.querySelector("#Image_UseCommunityRating").checked; config.Image[State.metadata.image].UseDimensions = form.querySelector("#Image_UseDimensions").checked; ([config.Image[State.metadata.image].PosterList, config.Image[State.metadata.image].PosterOrder] = retrieveSortableCheckboxList(form, "Image_PosterList")); ([config.Image[State.metadata.image].LogoList, config.Image[State.metadata.image].LogoOrder] = retrieveSortableCheckboxList(form, "Image_LogoList")); ([config.Image[State.metadata.image].BackdropList, config.Image[State.metadata.image].BackdropOrder] = retrieveSortableCheckboxList(form, "Image_BackdropList")); } //#endregion //#region Configuration → Form /** * Apply the given configuration to the form. * * @param {HTMLFormElement} form - The form element. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. */ async function applyConfigToForm(form, config) { switch (State.currentTab) { case "connection": { form.querySelector("#Url").value = config.Url; form.querySelector("#PublicUrl").value = config.PublicUrl; form.querySelector("#Username").value = config.Username; form.querySelector("#Password").value = ""; break; } case "metadata": { form.querySelector("#Title_ConfigureFor_Enabled").checked = config.Title[State.metadata.title].Enabled !== false; form.querySelector("#Title_ConfigureFor_Enabled").disabled = config.Title[State.metadata.title].Enabled === undefined; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; form.querySelector("#RemoveDuplicateTitles").checked = config.Title[State.metadata.title].RemoveDuplicates; renderSortableCheckboxList(form, "TitleMainList", config.Title[State.metadata.title].MainTitle.List, config.Title[State.metadata.title].MainTitle.Order); form.querySelector("#TitleMainAllowAny").checked = config.Title[State.metadata.title].MainTitle.AllowAny; const configAlternateTitles = [...config.Title[State.metadata.title].AlternateTitles]; if (configAlternateTitles.length === 0) { configAlternateTitles.push({ List: [], Order: [], AllowAny: false }); } renderAlternateTitles(form, configAlternateTitles); form.querySelector("#Description_ConfigureFor_Enabled").checked = config.Description[State.metadata.description].Enabled !== false; form.querySelector("#Description_ConfigureFor_Enabled").disabled = config.Description[State.metadata.description].Enabled === undefined; form.querySelector("#Description_AddNotes").checked = config.Description[State.metadata.description].AddNotes; renderSortableCheckboxList(form, "DescriptionSourceList", config.Description[State.metadata.description].List, config.Description[State.metadata.description].Order); form.querySelector("#DescriptionConversionMode").value = config.DescriptionConversionMode; form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; renderCheckboxList(form, "TagSources", config.TagSources.split(",").map(s => s.trim()).filter(s => s)); renderCheckboxList(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(); form.querySelector("#TagExcludeList").value = config.TagExcludeList.join(", "); renderCheckboxList(form, "GenreSources", config.GenreSources.split(",").map(s => s.trim()).filter(s => s)); renderCheckboxList(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(); form.querySelector("#GenreExcludeList").value = config.GenreExcludeList.join(", "); form.querySelector("#Image_ConfigureFor_Enabled").checked = config.Image[State.metadata.image].Enabled !== false; form.querySelector("#Image_ConfigureFor_Enabled").disabled = config.Image[State.metadata.image].Enabled === undefined; form.querySelector("#Image_UsePreferred").checked = config.Image[State.metadata.image].UsePreferred; form.querySelector("#Image_UseCommunityRating").checked = config.Image[State.metadata.image].UseCommunityRating; form.querySelector("#Image_UseDimensions").checked = config.Image[State.metadata.image].UseDimensions; renderSortableCheckboxList(form, "Image_PosterList", config.Image[State.metadata.image].PosterList, config.Image[State.metadata.image].PosterOrder); renderSortableCheckboxList(form, "Image_LogoList", config.Image[State.metadata.image].LogoList, config.Image[State.metadata.image].LogoOrder); renderSortableCheckboxList(form, "Image_BackdropList", config.Image[State.metadata.image].BackdropList, config.Image[State.metadata.image].BackdropOrder); form.querySelector("#Image_DebugMode").checked = config.Image.DebugMode; form.querySelector("#Metadata_StudioOnlyAnimationWorks").checked = config.Metadata_StudioOnlyAnimationWorks; renderSortableCheckboxList(form, "Metadata_ContentRatingList", config.ContentRatingList, config.ContentRatingOrder); renderSortableCheckboxList(form, "Metadata_ProductionLocationList", config.ProductionLocationList, config.ProductionLocationOrder); renderCheckboxList(form, "Metadata_ThirdPartyIdProviderList", config.ThirdPartyIdProviderList.map(s => s.trim()).filter(s => s)); break; } case "library": { form.querySelector("#DefaultLibraryStructure").value = config.DefaultLibraryStructure; form.querySelector("#DefaultSeasonOrdering").value = config.DefaultSeasonOrdering; form.querySelector("#SeparateMovies").checked = config.SeparateMovies; form.querySelector("#DisableFilterMovieLibraries").checked = !config.FilterMovieLibraries; form.querySelector("#DefaultSpecialsPlacement").value = config.DefaultSpecialsPlacement === "Default" ? "Excluded" : config.DefaultSpecialsPlacement; form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes; form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata; form.querySelector("#AutoReconstructCollections").checked = config.AutoReconstructCollections; form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo; form.querySelector("#AutoMergeVersions").checked = config.AutoMergeVersions || false; renderSortableCheckboxList(form, "MergeVersionSortSelectorList", config.MergeVersionSortSelectorList, config.MergeVersionSortSelectorOrder); form.querySelector("#DefaultLibraryOperationMode").value = config.DefaultLibraryOperationMode; form.querySelector("#VFS_IterativeGenerationEnabled").checked = config.VFS_IterativeGenerationEnabled; form.querySelector("#VFS_IterativeGenerationMaxCount").value = config.VFS_IterativeGenerationMaxCount; form.querySelector("#MediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + config.Libraries .map((library) => `<option value="${library.Id}">${library.Name}${State.advancedMode ? ` (${library.Id})` : ""}</option>`) .join(""); form.querySelector("#SeasonMerging_Enabled").checked = config.SeasonMerging_Enabled; form.querySelector("#SeasonMerging_AutoMerge").checked = config.SeasonMerging_DefaultBehavior === "None"; renderCheckboxList(form, "SeasonMerging_SeriesTypes", config.SeasonMerging_SeriesTypes); form.querySelector("#SeasonMerging_MergeWindowInDays").value = config.SeasonMerging_MergeWindowInDays; break; } case "vfs": { form.querySelector("#AddTrailers").checked = config.AddTrailers; form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos; form.querySelector("#AddCreditsAsSpecialFeatures").checked = config.AddCreditsAsSpecialFeatures; form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; form.querySelector("#VFS_UseSourceFileAsVersionIdentifier").checked = config.VFS_UseSourceFileAsVersionIdentifier; form.querySelector("#VFS_Threads").value = config.VFS_Threads; form.querySelector("#VFS_ResolveLinks").checked = config.VFS_ResolveLinks; form.querySelector("#VFS_MaxTotalExceptionsBeforeAbort").value = config.VFS_MaxTotalExceptionsBeforeAbort; form.querySelector("#VFS_MaxSeriesExceptionsBeforeAbort").value = config.VFS_MaxSeriesExceptionsBeforeAbort; form.querySelector("#VFS_UseSemaphore").checked = config.VFS_UseSemaphore; form.querySelector("#VFS_CollectAndSort").checked = config.VFS_CollectAndSort; form.querySelector("#VFS_Location").value = config.VFS_Location; form.querySelector("#VFS_CustomLocation").value = config.VFS_CustomLocation || ""; form.querySelector("#VFS_CustomLocation").disabled = config.VFS_Location !== "Custom"; if (config.VFS_Location === "Custom") { form.querySelector("#VFS_CustomLocationContainer").removeAttribute("hidden"); } else { form.querySelector("#VFS_CustomLocationContainer").setAttribute("hidden", ""); } break; } case "signalr": { Dashboard.showLoadingMsg(); const signalrStatus = await ShokoApiClient.getSignalrStatus(); updateSignalrStatus(form, signalrStatus); form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); renderCheckboxList(form, "SignalREventSources", config.SignalR_EventSources); form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; form.querySelector("#SignalRMediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + config.Libraries .map((library) => `<option value="${library.Id}">${library.Name}${State.advancedMode ? ` (${library.Id})` : ""}</option>`) .join(""); break; } case "users": { Dashboard.showLoadingMsg(); const users = await ApiClient.getUsers(); form.querySelector("#UserSelector").innerHTML = `<option value="">Click here to select a user</option>` + users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); break; } case "series": { const series = State.seriesList || []; form.querySelector("#SeriesSelector").innerHTML = `<option value="">${State.seriesList ? "Click here to select a series" : "Loading series list"}</option>` + series.map((s) => `<option value="${s.Id}">${s.Title.length >= 50 ? `${s.Title.substring(0, 47)}...` : s.Title} (a${s.AnidbId})</option>`).join(""); form.querySelector("#SeriesSearch").value = State.seriesQuery; if (State.seriesList && !State.seriesTimeout) { form.querySelector("#SeriesSelector").disabled = false; if (State.seriesId) { form.querySelector("#SeriesSelector").value = State.seriesId; form.querySelector("#SeriesSelector").dispatchEvent(new Event("change")); } } else { form.querySelector("#SeriesSearch").dispatchEvent(new Event("input")); } break; } case "misc": { form.querySelector("#Misc_ShowInMenu").checked = config.Misc_ShowInMenu; form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); form.querySelector("#Debug_UsageTrackerStalledTimeInSeconds").value = config.Debug.UsageTrackerStalledTimeInSeconds; form.querySelector("#Debug_MaxInFlightRequests").value = config.Debug.MaxInFlightRequests; form.querySelector("#Debug_SeriesPageSize").value = config.Debug.SeriesPageSize; form.querySelector("#Debug_AutoClearClientCache").checked = config.Debug.AutoClearClientCache; form.querySelector("#Debug_AutoClearManagerCache").checked = config.Debug.AutoClearManagerCache; form.querySelector("#Debug_AutoClearVfsCache").checked = config.Debug.AutoClearVfsCache; form.querySelector("#Debug_ExpirationScanFrequencyInMinutes").value = config.Debug.ExpirationScanFrequencyInMinutes; form.querySelector("#Debug_SlidingExpirationInMinutes").value = config.Debug.SlidingExpirationInMinutes; form.querySelector("#Debug_AbsoluteExpirationRelativeToNowInMinutes").value = config.Debug.AbsoluteExpirationRelativeToNowInMinutes; break; } } } /** * Load the user configuration for the given user. * * @param {HTMLFormElement} form - The form element. * @param {string} userId - The user ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns */ async function applyUserConfigToForm(form, userId, config = null) { if (!userId) { form.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); form.querySelector("#UserUsername").removeAttribute("required"); return; } // Get the configuration to use. let shouldHide = false; if (!config) { if (State.config) { config = State.config; } else { Dashboard.showLoadingMsg(); config = await ShokoApiClient.getConfiguration(); shouldHide = true; } } // Configure the elements within the user container const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; 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 || ""; form.querySelector("#UserPassword").value = ""; // Synchronization settings 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"); if (shouldHide) { Dashboard.hideLoadingMsg(); } } /** * Load the series configuration for the given series. * * @param {HTMLFormElement} form - The form element. * @param {string} seriesId - The series ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<void>} */ async function applySeriesConfigToForm(form, seriesId, config = null) { State.seriesId = seriesId; if (!seriesId) { form.querySelector("#SeriesSettingsContainer").setAttribute("hidden", ""); return; } let shouldHide = false; if (!config) { if (State.config) { config = State.config; } else { Dashboard.showLoadingMsg(); config = await ShokoApiClient.getConfiguration(); shouldHide = true; } } const seriesConfig = await ShokoApiClient.getSeriesConfiguration(seriesId); form.querySelector("#SeriesType").value = seriesConfig.Type; form.querySelector("#SeriesLibraryStructure").value = seriesConfig.StructureType; form.querySelector("#SeriesSeasonOrdering").value = seriesConfig.SeasonOrdering; form.querySelector("#SeriesSpecialsPlacement").value = seriesConfig.SpecialsPlacement; renderCheckboxList(form, "SeriesSeasonMergingBehavior", seriesConfig.SeasonMergingBehavior.split(",").map(s => s.trim()).filter(s => s && s !== "None")); form.querySelector("#SeriesEpisodeConversion").value = seriesConfig.EpisodeConversion; form.querySelector("#SeriesOrderByAirdate").checked = seriesConfig.OrderByAirdate; form.querySelector("#SeriesSettingsContainer").removeAttribute("hidden"); if (shouldHide) { Dashboard.hideLoadingMsg(); } } /** * Load the VFS library configuration for the given library. * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<void>} */ async function applyLibraryConfigToForm(form, libraryId, config = null) { if (!libraryId) { form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); return; } // Get the configuration to use. let shouldHide = false; if (!config) { if (State.config) { config = State.config; } else { Dashboard.showLoadingMsg(); config = await ShokoApiClient.getConfiguration(); shouldHide = true; } } const libraryConfig = config.Libraries.find((c) => c.Id === libraryId); const mediaFolders = config.LibraryFolders.filter((c) => c.LibraryId === libraryId); renderFolderList(form, libraryConfig.LibraryOperationMode !== "VFS", "MediaFolderManagedFolderMapping", mediaFolders.map(mediaFolderConfigToString)); // Configure the elements within the media folder container form.querySelector("#MediaFolderLibraryOperationMode").value = libraryConfig.LibraryOperationMode; form.querySelector("#MediaFolderLibraryIterativeGenerationEnabled").checked = libraryConfig.IterativeVfsGeneration_Enabled; form.querySelector("#MediaFolderLibraryIterativeGenerationNoCache").checked = libraryConfig.IterativeVfsGeneration_NoCache; form.querySelector("#MediaFolderLibraryIterativeGenerationMaxCount").value = libraryConfig.IterativeVfsGeneration_MaxCount; form.querySelector("#MediaFolderLibraryForceFullGenerationOnNextRefresh").checked = libraryConfig.IterativeVfsGeneration_ForceFullGenerationOnNextRefresh; // Show the media folder settings now if it was previously hidden. form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); if (shouldHide) { Dashboard.hideLoadingMsg(); } } /** * Converts a media folder configuration to a stringified form for display in the UI. * * @param {import("./Common.js").MediaFolderConfig} c - Media Folder Configuration. * @returns {[string, import("./Common.js").MediaFolderConfig]} */ function mediaFolderConfigToString(c) { return [ (c.IsMapped ? `${escapeHtml(c.Path)} | ${c.ManagedFolderName} (${c.ManagedFolderId}) ${c.ManagedFolderRelativePath}`.trimEnd() : `${escapeHtml(c.Path)} | Not Mapped` ) + (!c.IsIgnored && c.NeedsRefresh ? " (Refresh Pending)" : "") + (c.IsIgnored ? " (Ignored)" : ""), c, ]; } /** * Render a folder list. * * @param {HTMLFormElement} form - The form element. * @param {bool} disableButtons - Whether to disable the add/remove buttons. * @param {string} name - The name of the selector list to render. * @param {[string, import("./Common.js").MediaFolderConfig][]} entries - The entries to render * @returns {void} */ function renderFolderList(form, disableButtons, name, entries) { const list = form.querySelector(`#${name} .folderList`); if (disableButtons) { form.querySelector(`#${name} .btnAddFolder`).setAttribute("disabled", true); } else { form.querySelector(`#${name} .btnAddFolder`).removeAttribute("disabled"); } const listItems = entries.map(([entry, mediaFolderConfig], index) => `<div class="listItem listItem-border lnkPath" data-index="${index}">`+ `<div class="listItemBody"><div class="listItemBodyText" dir="ltr">${entry}</div></div>`+ (mediaFolderConfig.NeedsRefresh ? `<button type="button" name="search" is="paper-icon-button-light"" class="listItemButton"${mediaFolderConfig.IsIgnored ? " disabled" : ""}><span class="material-icons search_off" aria-hidden="true"></span></button>` : `<button type="button" name="search" is="paper-icon-button-light"" class="listItemButton"${mediaFolderConfig.IsIgnored ? " disabled" : ""}><span class="material-icons search" aria-hidden="true"></span></button>` ) + (mediaFolderConfig.IsIgnored ? `<button type="button" name="ignore" is="paper-icon-button-light"" class="listItemButton"><span class="material-icons folder" aria-hidden="true"></span></button>` : `<button type="button" name="ignore" is="paper-icon-button-light"" class="listItemButton"><span class="material-icons folder_off" aria-hidden="true"></span></button>` ) + `<button type="button" name="remove-path" is="paper-icon-button-light"" class="listItemButton"${disableButtons ? " disabled" : ""}><span class="material-icons remove_circle" aria-hidden="true"></span></button>`+ `</div>` ); if (entries.length) { list.removeAttribute("hidden"); } else { list.setAttribute("hidden", true); } list.innerHTML = listItems.join(""); } /** * Load the SignalR library configuration for the given library. * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<void>} */ async function applySignalrLibraryConfigToForm(form, libraryId, config = null) { if (!libraryId) { form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); return; } // Get the configuration to use. let shouldHide = false; if (!config) { if (State.config) { config = State.config; } else { Dashboard.showLoadingMsg(); config = await ShokoApiClient.getConfiguration(); shouldHide = true; } } const libraryConfig = config.Libraries.find((c) => c.Id === libraryId); if (!libraryConfig) { form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); if (shouldHide) { Dashboard.hideLoadingMsg(); } return; } // Configure the elements within the user container form.querySelector("#SignalRFileEvents").checked = libraryConfig.IsFileEventsEnabled; form.querySelector("#SignalRRefreshEvents").checked = libraryConfig.IsRefreshEventsEnabled; // Show the user settings now if it was previously hidden. form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); if (shouldHide) { Dashboard.hideLoadingMsg(); } } //#endregion //#region Local Interactions /** * Add a media folder to the list. * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @param {string} path - The path to add. * @returns {Promise<void>} */ function addMediaFolder(form, libraryId, config, path) { const pathLower = path.toLowerCase(); if (config.LibraryFolders.filter(p => p.LibraryId === libraryId && p.Path.toLowerCase() == pathLower).length) return; config.LibraryFolders.push({ LibraryId: libraryId, Path: path, IsIgnored: false, IsMapped: false, ManagedFolderId: 0, ManagedFolderName: null, ManagedFolderRelativePath: "", NeedsRefresh: true, }); const mediaFolders = config.LibraryFolders.filter((c) => c.LibraryId === libraryId); renderFolderList(form, false, "MediaFolderManagedFolderMapping", mediaFolders.map(mediaFolderConfigToString)); } /** * Remove a media folder from the list. * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @param {number} index - The index to remove. * @returns {Promise<void>} */ function removeMediaFolder(form, libraryId, config, index) { const mediaFolders = config.LibraryFolders.filter((c) => c.LibraryId === libraryId); const toRemove = mediaFolders.splice(index, 1); if (toRemove.length === 0) return; const realIndex = config.LibraryFolders.indexOf(toRemove[0]); config.LibraryFolders.splice(realIndex, 1); renderFolderList(form, false, "MediaFolderManagedFolderMapping", mediaFolders.map(mediaFolderConfigToString)); } /** * Toggle search for a media folder in the list. * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @param {number} index - The index to remove. * @returns {Promise<void>} */ function toggleRefreshOfMediaFolder(form, libraryId, config, index) { const libraryConfig = config.Libraries.find((c) => c.Id === libraryId); if (!libraryConfig) return; const mediaFolders = config.LibraryFolders.filter((c) => c.LibraryId === libraryId); const toToggle = mediaFolders[index]; toToggle.NeedsRefresh = !toToggle.NeedsRefresh; renderFolderList(form, libraryConfig.LibraryOperationMode !== "VFS", "MediaFolderManagedFolderMapping", mediaFolders.map(mediaFolderConfigToString)); } /** * Toggle ignore a media folder in the list. * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @param {number} index - The index to remove. * @returns {Promise<void>} */ function toggleIgnoredMediaFolder(form, libraryId, config, index) { const libraryConfig = config.Libraries.find((c) => c.Id === libraryId); if (!libraryConfig) return; const mediaFolders = config.LibraryFolders.filter((c) => c.LibraryId === libraryId); const toToggle = mediaFolders[index]; toToggle.IsIgnored = !toToggle.IsIgnored; if (toToggle.IsIgnored && !toToggle.NeedsRefresh) { toToggle.NeedsRefresh = false; } renderFolderList(form, libraryConfig.LibraryOperationMode !== "VFS", "MediaFolderManagedFolderMapping", mediaFolders.map(mediaFolderConfigToString)); } //#endregion //#region Server Interactions /** * Default submit. Will conditionally sync settings or establish a new * connection based on the current state of the local representation of the * configuration. * * @param {HTMLFormElement} form - The form element. * @returns {Promise<PluginConfiguration>} The updated plugin configuration. */ async function defaultSubmit(form) { let config = State.config || await ShokoApiClient.getConfiguration(); if (config.ApiKey !== "") { return syncSettings(form, config); } // 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); } // Update the url if needed. if (config.Url !== url || config.PublicUrl !== publicUrl) { config.Url = url; config.PublicUrl = publicUrl; form.querySelector("#Url").value = url; form.querySelector("#PublicUrl").value = publicUrl; await ShokoApiClient.updateConfiguration(config); } const username = form.querySelector("#Username").value; const password = form.querySelector("#Password").value; try { const response = await ShokoApiClient.getApiKey(username, password); config = await ShokoApiClient.getConfiguration(); config.Username = username; config.ApiKey = response.apikey; await ShokoApiClient.updateConfiguration(config); Dashboard.hideLoadingMsg(); Dashboard.alert(Messages.ConnectedToShoko); } catch (err) { Dashboard.hideLoadingMsg(); Dashboard.alert(Messages.InvalidCredentials); console.error(err, Messages.InvalidCredentials); } return config; } /** * Reset the connection to Shoko. * * @param {HTMLFormElement} form - The form element. * @returns {Promise<PluginConfiguration>} The updated plugin configuration. */ async function resetConnection(form) { const config = State.config || await ShokoApiClient.getConfiguration(); form.querySelector("#Username").value = config.Username; form.querySelector("#Password").value = ""; // Connection settings config.ApiKey = ""; config.ServerVersion = null; await ShokoApiClient.updateConfiguration(config); Dashboard.hideLoadingMsg(); Dashboard.alert(Messages.DisconnectedToShoko); return config; } /** * Synchronize the settings with the server. *1 * @param {HTMLFormElement} form - The form element. * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<PluginConfiguration>} The updated plugin configuration. */ async function syncSettings(form, config) { if (!config) { config = State.config || await ShokoApiClient.getConfiguration(); } applyFormToConfig(form, config); // 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); } // 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 ShokoApiClient.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 seriesId = form.querySelector("#SeriesSelector").value; if (seriesId) { let seriesConfig = await ShokoApiClient.getSeriesConfiguration(seriesId); if (seriesConfig) { seriesConfig.Type = form.querySelector("#SeriesType").value; seriesConfig.StructureType = form.querySelector("#SeriesLibraryStructure").value; seriesConfig.SeasonOrdering = form.querySelector("#SeriesSeasonOrdering").value; seriesConfig.SpecialsPlacement = form.querySelector("#SeriesSpecialsPlacement").value; seriesConfig.SeasonMergingBehavior = retrieveCheckboxList(form, "SeriesSeasonMergingBehavior").join(",") || "None"; seriesConfig.EpisodeConversion = form.querySelector("#SeriesEpisodeConversion").value; seriesConfig.OrderByAirdate = form.querySelector("#SeriesOrderByAirdate").checked; await ShokoApiClient.updateSeriesConfiguration(seriesId, seriesConfig); } } config.UserList = config.UserList.filter((c) => c.Token); await ShokoApiClient.updateConfiguration(config); Dashboard.processPluginConfigurationUpdateResult(); return config; } /** * Remove a user from the configuration. * * @param {HTMLFormElement} form - The form element. * @returns {Promise<PluginConfiguration>} The updated plugin configuration. */ async function removeUserConfig(form) { const config = State.config || await ShokoApiClient.getConfiguration(); const userId = form.querySelector("#UserSelector").value; if (!userId) return config; const index = config.UserList.findIndex(c => userId === c.UserId); if (index !== -1) { config.UserList.splice(index, 1); } await ShokoApiClient.updateConfiguration(config); Dashboard.processPluginConfigurationUpdateResult(); form.querySelector("#UserSelector").value = ""; return config; } /** * Remove an alternate/original title from the view. * * @param {HTMLFormElement} form - The form element. * @param {number} index - The index of the alternate title to remove. * @returns {Promise<PluginConfiguration>} The updated plugin configuration. */ async function removeAlternateTitle(form, index) { const config = State.config || await ShokoApiClient.getConfiguration(); const alternateTitles = form.querySelectorAll("#TitleAlternateListContainer > fieldset"); const configAlternateTitles = []; let j = 0; for (let i = 1; i <= alternateTitles.length; i++) { const [list, order] = retrieveSortableCheckboxList(form, `TitleAlternateList_${i}`); if (i !== index) { configAlternateTitles[j] = { List: list, Order: order }; configAlternateTitles[j].AllowAny = form.querySelector(`#TitleAlternateAllowAny_${i}`).checked; j++; } } if (configAlternateTitles.length === 0) { configAlternateTitles.push({ List: [], Order: [], AllowAny: false }); } renderAlternateTitles(form, configAlternateTitles); return config; } /** * Add a new alternate/original title to the view. * * @param {HTMLFormElement} form - The form element. */ async function addAlternateTitle(form) { const config = State.config || await ShokoApiClient.getConfiguration(); const alternateTitles = form.querySelectorAll("#TitleAlternateListContainer > fieldset"); if (alternateTitles.length >= 5) { return config; } const configAlternateTitles = []; let j = 0; for (let i = 1; i <= alternateTitles.length; i++) { const [list, order] = retrieveSortableCheckboxList(form, `TitleAlternateList_${i}`); configAlternateTitles[j] = { List: list, Order: order }; configAlternateTitles[j].AllowAny = form.querySelector(`#TitleAlternateAllowAny_${i}`).checked; j++; } configAlternateTitles.push({ List: [], Order: [], AllowAny: false }); renderAlternateTitles(form, configAlternateTitles); return config; } /** * Render the alternate titles. * * @param {HTMLFormElement} form * @param {TitleConfiguration[]} configAlternateTitles * @returns {void} */ function renderAlternateTitles(form, configAlternateTitles) { if (form.querySelectorAll("#TitleAlternateListContainer > fieldset").length !== configAlternateTitles.length) { const container = form.querySelector("#TitleAlternateListContainer"); container.innerHTML = ""; const remaining = Math.max(0, 5 - configAlternateTitles.length); for (let i = 1; i <= configAlternateTitles.length; i++) { const html = alternateTitleListTemplate .replace(/%number%/g, i) .replace(/%number_formatted%/g, configAlternateTitles.length === 1 ? "" : i === 1 ? "1st " : i === 2 ? "2nd " : i === 3 ? "3rd " : `${i}th `) .replace(/%remaining%/g, remaining); container.insertAdjacentHTML("beforeend", html); if (i === 1) { container.querySelector(`#TitleAlternateRemoveButton_${i}`).setAttribute("hidden", ""); if (remaining === 0) { container.querySelector(`#TitleAlternateAddButton_${i}>button`).className = "raised button-alt block emby-button"; container.querySelector(`#TitleAlternateAddButton_${i}>button`).setAttribute("disabled", ""); } } else { container.querySelector(`#TitleAlternateAddButton_${i}`).setAttribute("hidden", ""); } overrideSortableCheckboxList(container.querySelector(`#TitleAlternateList_${i}`)); } } for (let i = 1; i <= configAlternateTitles.length; i++) { const j = i - 1; renderSortableCheckboxList(form, `TitleAlternateList_${i}`, configAlternateTitles[j].List, configAlternateTitles[j].Order); form.querySelector(`#TitleAlternateAllowAny_${i}`).checked = configAlternateTitles[j].AllowAny; } } /** * Toggle expert mode. * * @param {boolean} expertMode - True to enable expert mode, false to disable it. * @param {boolean} debugMode - True to enable debug mode, false to disable it. * @returns {Promise<PluginConfiguration>} The updated plugin configuration. */ async function toggleExpertMode(expertMode = false, debugMode = false) { const config = State.config || await ShokoApiClient.getConfiguration(); const debugChanged = config.Debug.ShowInUI !== debugMode; const expertChanged = config.AdvancedMode !== expertMode; if (!expertChanged && !debugChanged) return config; config.AdvancedMode = expertMode; config.Debug.ShowInUI = debugMode; await ShokoApiClient.updateConfiguration(config); if (debugChanged) { Dashboard.alert(debugMode ? Messages.DebugModeEnabled : Messages.DebugModeDisabled); } else if (expertChanged) { Dashboard.alert(expertMode ? Messages.ExpertModeEnabled : Messages.ExpertModeDisabled); } return config; } //#endregion //#region Helpers /** * 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 non-integer 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) { return value .split(",") .map(str => parseInt(str.trim().toLowerCase(), 10)) .filter(int => !Number.isNaN(int) || !Number.isInteger(int)); } /** * Filter out illegal values and convert from string to number. * @param {string} raw - Stringified value to filter. * @returns {number} A sanitized and filtered value. */ function sanitizeNumber(raw, min = 0, max = Number.MAX_SAFE_INTEGER) { const value = parseInt(raw.trim().toLowerCase(), 10); if (Number.isNaN(value) || value < min) return min; if (value > max) return max; return value; } /** * Filter out duplicates and sanitize list. * @param {string} value - Stringified list of values to filter. * @returns {string[]} An array of sanitized and filtered values. */ function filterTags(value) { return Array.from( new Set( value .split(",") .map(str => str.trim().toLowerCase()) .filter(str => str) ) ); } //#endregion }); } ================================================ FILE: Shokofin/Pages/Scripts/jsconfig.json ================================================ { "include": ["**/*"], "compilerOptions": { "module": "ESNext", "moduleResolution": "Node", "target": "ES2020", "lib": ["ES2020", "DOM"], "allowSyntheticDefaultImports": true, "noImplicitAny": false, "experimentalDecorators": true, "emitDecoratorMetadata": true, "jsx": "react", "sourceMap": true, "outDir": "dist", "baseUrl": ".", }, } ================================================ FILE: Shokofin/Pages/Settings.html ================================================ <div data-role="page" class="page type-interior pluginConfigurationPage withTabs" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/Shoko.Settings.js"> <div data-role="content"> <div class="content-primary"> <style> form:not(.debug-mode) .debug-only, form:not(.advanced-mode) .advanced-only { display: none !important; } .debug-only.inputContainer .inputLabel::after, .debug-only.selectContainer .selectLabel::after, .debug-only.checkboxContainer .checkboxLabel::after, .debug-only[is="checkbox-list"] .checkboxListLabel::after, .debug-only[is="sortable-checkbox-list"] .checkboxListLabel::after, .advanced-only.inputContainer .inputLabel::after, .advanced-only.selectContainer .selectLabel::after, .advanced-only.checkboxContainer .checkboxLabel::after, .advanced-only[is="checkbox-list"] .checkboxListLabel::after, .advanced-only[is="sortable-checkbox-list"] .checkboxListLabel::after { font-size: 50%; color: red; margin-inline-start: 0.25rem; padding-inline: 0.25rem; vertical-align: middle; content: "ADVANCED"; border: red 1px solid; border-radius: 1rem; display: "inline"; } .advanced-only.experimental-setting.inputContainer .inputLabel::after, .advanced-only.experimental-setting.selectContainer .selectLabel::after, .advanced-only.experimental-setting.checkboxContainer .checkboxLabel::after, .advanced-only.experimental-setting[is="checkbox-list"] .checkboxListLabel::after, .advanced-only.experimental-setting[is="sortable-checkbox-list"] .checkboxListLabel::after { color: green; content: "EXPERIMENTAL"; border-color: green; } .debug-only.inputContainer .inputLabel::after, .debug-only.selectContainer .selectLabel::after, .debug-only.checkboxContainer .checkboxLabel::after, .debug-only[is="checkbox-list"] .checkboxListLabel::after, .debug-only[is="sortable-checkbox-list"] .checkboxListLabel::after { color: orange; content: "DEBUG"; border-color: orange; } .emby-button.fab:disabled { cursor: default; opacity: .3; } </style> <form> <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/jellyfin/configuring-shokofin/">Help</a> </div> <fieldset id="Connection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Connection Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="Url" 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 in addition to all images sent to clients and redirects back to Shoko 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" label="Username" /> <div class="fieldDescription">The username of your administrator account in Shoko.</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 of your administrator account in Shoko.</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 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" readonly value="Unknown Version" /> <div class="fieldDescription">The version of Shoko we're connected to.</div> </div> <button is="emby-button" type="submit" name="reset-connection" class="raised block emby-button"> <span>${Disconnect}</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="Metadata_Title_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Title Settings</h3> </legend> <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">Adds the type and number to the title of non-standard episodes such as specials. (e.g. S1)</div> </div> <hr class="advanced-only" /> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="Title_ConfigureFor">Configure settings for</label> <select is="emby-select" id="Title_ConfigureFor" name="Title_ConfigureFor" class="emby-select-withcolor emby-select"> <option value="Default" selected>Global Settings</option> <optgroup label="AniDB Anime Structure"> <option value="AniDBCollection" disabled>Box-Set / Collection (AniDB Anime Structure)</option> <option value="AnidbMovie">Movie / Film (AniDB Anime Structure)</option> <option value="AnidbAnime">Series / Show (AniDB Anime Structure)</option> <option value="AnidbSeason">Season (AniDB Anime Structure)</option> <option value="AnidbEpisode">Episode (AniDB Anime Structure)</option> </optgroup> <optgroup label="Shoko Group Structure"> <option value="ShokoCollection">Box-Set / Collection (Shoko Group Structure)</option> <option value="ShokoMovie">Movie / Film (Shoko Group Structure)</option> <option value="ShokoSeries">Series / Show (Shoko Group Structure)</option> <option value="ShokoSeason">Season (Shoko Group Structure)</option> <option value="ShokoEpisode">Episode (Shoko Group Structure)</option> </optgroup> <optgroup label="TheMovieDb Shows & Movies Structure"> <option value="TmdbCollection">Box-Set / Collection (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbMovie">Movie / Film (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbShow">Series / Show (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbSeason">Season (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbEpisode">Episode (TheMovieDb Shows & Movies Structure)</option> </optgroup> </select> <div class="fieldDescription"> Determines which entity type to configure the settings for. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Title_ConfigureFor_Enabled" disabled checked /> <span>Enabled</span> </label> <div class="fieldDescription checkboxFieldDescription"> Determines if the settings override is enabled for the selected entity type. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="RemoveDuplicateTitles" class="emby-checkbox"> <span class="checkboxLabel">Remove Duplicate Titles</span> </label> <div class="fieldDescription checkboxFieldDescription">Remove duplicate titles from the list.</div> </div> <fieldset class="verticalSection verticalSection-extrabottompadding"> <legend>Main Title</legend> <div id="TitleMainList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">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 media's 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">TheMovieDb | 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">TheMovieDb | 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">TheMovieDb | Use the language from the media's 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 for entities, in priority order.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleMainAllowAny" /> <span>Allow Any Title in Selected Language</span> </label> <div class="fieldDescription checkboxFieldDescription">Allows for any titles to be utilized if an official title is not present in the given language. Only applies to the AniDB title selectors above.</div> </div> </fieldset> <div id="TitleAlternateListContainer"> <fieldset class="verticalSection verticalSection-extrabottompadding"> <legend>%number_formatted%Alternate/Original Title</legend> <div id="TitleAlternateList_%number%" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">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 media's 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">TheMovieDb | 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">TheMovieDb | 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">TheMovieDb | Use the language from the media's 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 %number_formatted%alternate/original title for entities, in priority order.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleAlternateAllowAny_%number%" class="emby-checkbox"> <span class="checkboxLabel">Allow Any Title in Selected Language</span> </label> <div class="fieldDescription checkboxFieldDescription">Allows for any titles to be utilized if an official title is not present in the given language. Only applies to the AniDB title selectors above.</div> </div> <div id="TitleAlternateAddButton_%number%" class="inputContainer inputContainer-withDescription"> <button is="emby-button" type="submit" name="add-alternate-title" class="raised button-submit block emby-button"> <span>${Add}</span> </button> <div class="fieldDescription">This will add a new alternate/original title. You can add %remaining% more titles.</div> </div> <div id="TitleAlternateRemoveButton_%number%" class="inputContainer inputContainer-withDescription"> <button is="emby-button" type="submit" name="remove-alternate-title" data-index="%number%" class="raised button-delete block emby-button"> <span>${ButtonRemove}</span> </button> <div class="fieldDescription">This will remove the %number_formatted%alternate/original title.</div> </div> </fieldset> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Metadata_Description_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Description Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="DescriptionConversionMode">AniDB Description Conversion Mode</label> <select is="emby-select" id="DescriptionConversionMode" name="DescriptionConversionMode" class="emby-select-withcolor emby-select"> <option value="Disabled">Disabled</option> <option value="PlainText">Plain Text</option> <option value="Markdown" selected>Markdown (Default)</option> </select> <div class="fieldDescription"> Determines how to convert the AniDB descriptions. If disabled, the description will not be converted. </div> </div> <hr class="advanced-only" /> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="Description_ConfigureFor">Configure settings for</label> <select is="emby-select" id="Description_ConfigureFor" name="Description_ConfigureFor" class="emby-select-withcolor emby-select"> <option value="Default" selected>Global Settings</option> <optgroup label="AniDB Anime Structure"> <option value="AniDBCollection" disabled>Box-Set / Collection (AniDB Anime Structure)</option> <option value="AnidbMovie">Movie / Film (AniDB Anime Structure)</option> <option value="AnidbAnime">Series / Show (AniDB Anime Structure)</option> <option value="AnidbSeason">Season (AniDB Anime Structure)</option> <option value="AnidbEpisode">Episode (AniDB Anime Structure)</option> </optgroup> <optgroup label="Shoko Group Structure"> <option value="ShokoCollection">Box-Set / Collection (Shoko Group Structure)</option> <option value="ShokoMovie">Movie / Film (Shoko Group Structure)</option> <option value="ShokoSeries">Series / Show (Shoko Group Structure)</option> <option value="ShokoSeason">Season (Shoko Group Structure)</option> <option value="ShokoEpisode">Episode (Shoko Group Structure)</option> </optgroup> <optgroup label="TheMovieDb Shows & Movies Structure"> <option value="TmdbCollection">Box-Set / Collection (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbMovie">Movie / Film (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbShow">Series / Show (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbSeason">Season (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbEpisode">Episode (TheMovieDb Shows & Movies Structure)</option> </optgroup> </select> <div class="fieldDescription"> Determines which entity type to configure the settings for. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Description_ConfigureFor_Enabled" disabled checked /> <span>Enabled</span> </label> <div class="fieldDescription checkboxFieldDescription"> Determines if the settings override is enabled for the selected entity type. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Description_AddNotes" /> <span>Add Notes</span> </label> <div class="fieldDescription checkboxFieldDescription"> Determines if we should add the note(s) which show up on the AniDB anime page to the description for the series and/or season entities. </div> </div> <div id="DescriptionSourceList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Description Source</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="Shoko"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Shoko"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Shoko | Let Shoko decide</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="AniDB"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="AniDB"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">AniDB | Follow metadata language in library</h3> <span></span> </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">TheMovieDb | 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> <div class="fieldDescription">The metadata providers to use as the source of descriptions for entities, in priority order.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Metadata_Image_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Image Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="Image_ConfigureFor">Configure settings for</label> <select is="emby-select" id="Image_ConfigureFor" name="Image_ConfigureFor" class="emby-select-withcolor emby-select"> <option value="Default" selected>Global Settings</option> <optgroup label="AniDB Anime Structure"> <option value="AniDBCollection" disabled>Box-Set / Collection (AniDB Anime Structure)</option> <option value="AnidbMovie">Movie / Film (AniDB Anime Structure)</option> <option value="AnidbAnime">Series / Show (AniDB Anime Structure)</option> <option value="AnidbSeason">Season (AniDB Anime Structure)</option> <option value="AnidbEpisode">Episode (AniDB Anime Structure)</option> </optgroup> <optgroup label="Shoko Group Structure"> <option value="ShokoCollection">Box-Set / Collection (Shoko Group Structure)</option> <option value="ShokoMovie">Movie / Film (Shoko Group Structure)</option> <option value="ShokoSeries">Series / Show (Shoko Group Structure)</option> <option value="ShokoSeason">Season (Shoko Group Structure)</option> <option value="ShokoEpisode">Episode (Shoko Group Structure)</option> </optgroup> <optgroup label="TheMovieDb Shows & Movies Structure"> <option value="TmdbCollection">Box-Set / Collection (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbMovie">Movie / Film (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbShow">Series / Show (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbSeason">Season (TheMovieDb Shows & Movies Structure)</option> <option value="TmdbEpisode">Episode (TheMovieDb Shows & Movies Structure)</option> </optgroup> </select> <div class="fieldDescription"> Determines which entity type to configure the settings for. To revert back an entity type to following the global settings, disable all settings for that entity type. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Image_ConfigureFor_Enabled" disabled checked /> <span>Enabled</span> </label> <div class="fieldDescription checkboxFieldDescription"> Determines if the settings override is enabled for the selected entity type. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Image_UsePreferred" /> <span>Respect Preferred Image in Automatic Image Search</span> </label> <div class="fieldDescription checkboxFieldDescription"> Respect the 'preferred' flag sent by the server in automatic image searches. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Image_UseCommunityRating" /> <span>Use Community Ratings in Automatic Image Search</span> </label> <div class="fieldDescription checkboxFieldDescription"> Use the community ratings to order the images in automatic image searches. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Image_UseDimensions" /> <span>Set Image Dimensions in Automatic Image Search</span> </label> <div class="fieldDescription checkboxFieldDescription"> Set the image dimensions the images in automatic image searches. This will allow Jellyfin to select the "best" image to use based on the image dimensions, if a minimum dimension is specified in the settings for the library. </div> </div> <div id="Image_PosterList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Only Allow Posters By Language</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="None"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">No language / Text-less images</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="Metadata"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">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="Original"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Use the language from the media's 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="English"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">English (only applies if the metadata language in the library is not English)</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">Filter the images during an automatic image search for posters to allow only the specified languages, in the specified order. If no languages are selected then no filtering will occur in the automatic image search.</div> </div> <div id="Image_LogoList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Only Allow Logos By Language</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="None"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">No language / Text-less images</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="Metadata"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">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="Original"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Use the language from the media's 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="English"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">English (only applies if the metadata language in the library is not English)</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">Filter the images during an automatic image search for logos to only allow the specified languages, in the specified order. If no languages are selected then no filtering will occur in the automatic image search.</div> </div> <div id="Image_BackdropList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Only Allow Backdrops/Banners/Thumbnails By Language</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="None"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">No language / Text-less images</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="Metadata"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">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="Original"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Use the language from the media's 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="English"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">English (only applies if the metadata language in the library is not English)</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">Filter the images during an automatic image search for backdrops/banners/thumbnails to only allow the specified languages, in the specified order. If no languages are selected then no filtering will occur in the automatic image search.</div> </div> <hr class="debug-only" /> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Image_DebugMode" /> <span>Display Automatic Image Search View</span> </label> <div class="fieldDescription checkboxFieldDescription"> Displays the images the way Jellyfin sees it during an automatic image search, instead of the usual display with proper language and ratings for each image. </div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Metadata_TagGenre_Section" class="verticalSection verticalSection-extrabottompadding advanced-only" hidden> <legend> <h3>Tag & Genre Settings</h3> </legend> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> <span>Ignore Unverified Tags</span> </label> <div class="fieldDescription checkboxFieldDescription">Don't use any user-submitted tags that have not been verified by AniDB.</div> </div> <div id="TagSources" is="checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">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 class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="TmdbKeywords"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">TheMovieDb | Keywords</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="TmdbGenres"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">TheMovieDb | Genres</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="FirstYearlySeason"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Yearly Season | The first yearly season for the entity</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="AllYearlySeasons"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Yearly Season | All yearly seasons for the entity</h3> </div> </div> </div> <div class="fieldDescription">The tag categories to use as the sources of the displayed tags for entities.</div> </div> <div id="TagIncludeFilters" is="checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Tag Inclusion 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 inclusion filters to use for the displayed tags for entities.</div> </div> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="TagMinimumWeight">Minimum Weight for Tags</label> <select is="emby-select" id="TagMinimumWeight" name="TagMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless" selected>All Allowed (Default)</option> <option value="One">0.5</option> <option value="Two">1.0</option> <option value="Three">1.5</option> <option value="Four">2.0</option> <option value="Five">2.5</option> <option value="Six">3.0</option> </select> <div class="fieldDescription"> Choose the minimum weight a tag must have to be included with the displayed tags, not including weightless tags. </div> </div> <div class="inputContainer inputContainer-withDescription advanced-only"> <input is="emby-input" id="TagMaximumDepth" label="Maximum Depth for Tags" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of a tag to be included with the displayed tags based on it's source category.</div> </div> <div class="inputContainer inputContainer-withDescription advanced-only"> <input is="emby-input" type="text" id="TagExcludeList" label="Exclude List for Tags" /> <div class="fieldDescription">A comma separated list of tags to exclude from the displayed tags.</div> </div> <div id="GenreSources" is="checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">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 class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="TmdbKeywords"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">TheMovieDb | Keywords</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="TmdbGenres"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">TheMovieDb | Genres</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="FirstYearlySeason"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Yearly Season | The first yearly season for the entity</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="AllYearlySeasons"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Yearly Season | All yearly seasons for the entity</h3> </div> </div> </div> <div class="fieldDescription">The tag categories to use as the sources of the displayed genres for entities.</div> </div> <div id="GenreIncludeFilters" is="checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Genre Inclusion 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 inclusion filters to use for the displayed genres for entities.</div> </div> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="GenreMinimumWeight">Minimum Weight for Genres</label> <select is="emby-select" id="GenreMinimumWeight" name="GenreMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless">All Allowed</option> <option value="One">0.5</option> <option value="Two">1.0</option> <option value="Three">1.5</option> <option value="Four" selected>2.0 (Default)</option> <option value="Five">2.5</option> <option value="Six">3.0</option> </select> <div class="fieldDescription"> Choose the minimum weight a tag must have to be included with the displayed genres, not including weightless tags. </div> </div> <div class="inputContainer inputContainer-withDescription advanced-only"> <input is="emby-input" id="GenreMaximumDepth" label="Maximum Depth for Genres" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of a tag to be included with the displayed genres based on it's source category.</div> </div> <div class="inputContainer inputContainer-withDescription advanced-only"> <input is="emby-input" type="text" id="GenreExcludeList" label="Exclude List for Genres" /> <div class="fieldDescription">A comma separated list of genres to exclude from the displayed tags.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Metadata_Misc_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Miscellaneous Settings</h3> </legend> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Metadata_StudioOnlyAnimationWorks" /> <span>Only Animation Studios</span> </label> <div class="fieldDescription checkboxFieldDescription">Only select studios responsible for the animation for entities. Only applies to AniDB.</div> </div> <div id="Metadata_ContentRatingList" is="sortable-checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Content Rating Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <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">TheMovieDb | Follow country/region set in library</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="AniDB"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="AniDB"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</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 for entities, in priority order.</div> </div> <div id="Metadata_ProductionLocationList" is="sortable-checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">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">TheMovieDb</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 for entities, in priority order.</div> </div> <div id="Metadata_ThirdPartyIdProviderList" is="checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Add Third Party IDs</h3> <div class="checkboxList paperList checkboxList-paperList"> <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 | Series, Seasons, Episodes, Movies</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">TheMovieDb | Series, Episodes, Movies</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="TvDB"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">TvDB | Series, Episodes</h3> </div> </div> </div> <div class="fieldDescription">Enable which third party IDs to provide for other plugins to consume with supported media items.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Library_Basic_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Basic Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="DefaultLibraryStructure">Default Library Structure Mode</label> <select is="emby-select" id="DefaultLibraryStructure" name="DefaultLibraryStructure" class="emby-select-withcolor emby-select"> <option value="AniDB_Anime" selected>AniDB Anime Structure (Default)</option> <option value="Shoko_Groups">Shoko Group Structure</option> <option value="TMDB_SeriesAndMovies">TheMovieDb Shows & Movies Structure</option> </select> <div class="fieldDescription"> <div> This setting determines which library structure to use by default for shows and movies in all plugin managed libraries. You can override the structure on a per Shoko series basis over in the Series settings tab at the top. </div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">Pre-requirements for using <strong>Shoko Group structure</strong>.</summary> To make the most out of this feature you first need to configure your groups in Shoko. You can either enable auto-grouping in the settings, manually craft your own group structure, or a mix of the two where you have auto-grouping enabled but override the placement if you feel it should belong elsewhere instead. For more information look up the <a href="https://docs.shokoanime.com/shoko-server/group-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 most of the time that you've configured a multi-layered structure for your groups because the deepest layer will be used for the shows while all the layers above will be used for collections. The exception to this is if you have a group which contains both movies and shows and you've enabled the option to separate movies from shows. In which case the deepest layer of groups will also be used to generate a collection for your movie(s) and show within the layer. Also note that enabling auto-grouping only gives you 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/shoko-server/group-management">Shoko docs</a> on how to manage your groups. </details> </div> </div> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="DefaultSeasonOrdering">Default Shoko Group Structure Season Ordering</label> <select is="emby-select" id="DefaultSeasonOrdering" name="DefaultSeasonOrdering" class="emby-select-withcolor emby-select"> <option value="Default" selected>Let Shoko decide (Default)</option> <option value="ReleaseDate">Order seasons by release date</option> <option value="Chronological" class="advanced-only">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> <option value="ChronologicalIgnoreIndirect" class="advanced-only">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> </select> <div class="fieldDescription">Determines how to order seasons within shows when using groups for shows.</div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="DefaultSpecialsPlacement">Default Specials Placement Within Seasons</label> <select is="emby-select" id="DefaultSpecialsPlacement" name="DefaultSpecialsPlacement" class="emby-select-withcolor emby-select"> <option value="Excluded" selected>Exclude specials from the seasons (Default)</option> <option value="AfterSeason">Always place specials after the normal episodes</option> <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> <option value="InBetweenSeasonByOtherData">Loosely use the TheMovieDb data available in Shoko to place specials</option> <option value="InBetweenSeasonMixed">Either loosely use the TheMovieDb data available in Shoko or fallback to using release dates to place specials</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="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">By default we allow movies to show up as episodes within seasons of your shows in show libraries. Enable this if you want your movies to be filtered out of your show libraries. Also note that enabling this option allows you to create collections for your shows and movies when using 'Use Groups for Shows' and have a flat group structure. <strong>This setting also applies to mixed type libraries.</strong></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 currently present in your local collection. Display settings for each Jellyfin user will have to be adjusted to show missing episode information to make use of this feature.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="DisableFilterMovieLibraries" /> <span>Disable Movie Library Filtering</span> </label> <div class="fieldDescription checkboxFieldDescription">By default we filter out anything that is not a movie in movie libraries. Enable this if you want everything to show up as movies in your movie libraries instead.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <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 an AniDB movie series as special features for the movie(s). 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 will break some movie series in a show type library.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Library_Collection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Collection Settings</h3> </legend> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AutoReconstructCollections" /> <span>Automatically Reconstruct Collections</span> </label> <div class="fieldDescription checkboxFieldDescription">Automatically reconstruct collections after a library scan.</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</option> <option value="Shared">Create collections for movies and shows</option> </select> <div class="fieldDescription"> <div>Determines what entities to group into native Jellyfin 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 debug-only"> <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">If collections are enabled, only create a collection when the collection will contain at least two items.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Library_MediaFolder_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Library Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="DefaultLibraryOperationMode">Default Library Operation Mode</label> <select is="emby-select" id="DefaultLibraryOperationMode" name="DefaultLibraryOperationMode" class="emby-select-withcolor emby-select"> <option value="VFS" selected>Virtual File System (VFS)</option> <option value="Strict">Legacy Filtering (Strict)</option> <option value="Lax">Legacy Filtering (Lax)</option> </select> <div class="fieldDescription"> <div>Determines how the plugin should operate on new libraries.</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 using the Virtual File System (VFS) entail?</summary> Using the VFS allows you to disregard the underlying disk file structure while automagically meeting Jellyfin's requirements for file organization. It also ensures that no unrecognized files appear in your library and allows us to fully leverage Jellyfin's native features better than we otherwise could without it. This enables us to effortlessly support trailers, special features, and theme videos for series, seasons and movies, as well as merge partial episodes into a single episode. All this is possible because we disregard the underlying disk file structure to create our own using symbolic links. <br/> <strong>Do not enable this for an existing library. If you do, then delete and re-create your library from scratch.</strong> </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does using Legacy Filtering (Strict) entail?</summary> Using legacy filtering in strict mode means the plugin will operate on your media folders as-is, but filter out any and all unrecognized media from the library. </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does using Legacy Filtering (Lax) entail?</summary> Using legacy filtering in lax mode means the plugin will operate on your media folders as-is, but it will not filter out anything from the library. Use this only if intend to mix Shoko with other metadata providers. </details> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only experimental-setting"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_IterativeGenerationEnabled" /> <span>Default Iterative VFS Generation Enabled</span> </label> <div class="fieldDescription checkboxFieldDescription">Enable the iterative generation of the VFS for new libraries. Only generate links in the VFS for files changed since the last check occurred.</div> </div> <div class="inputContainer inputContainer-withDescription advanced-only experimental-setting"> <input is="emby-input" type="number" id="VFS_IterativeGenerationMaxCount" label="Default Max Iterations Before Forced Full Generation" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" max="100" /> <div class="fieldDescription">Maximum number of iterations to perform when generating the VFS for new libraries. Set to a value above 0 to enable.</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="">Click here to select a library</option> </select> <div class="fieldDescription selectFieldDescription">Select a media folder to add or modify the media folder settings for.</div> </div> <div id="MediaFolderPerFolderSettingsContainer" hidden> <div id="MediaFolderManagedFolderMapping" style="margin-bottom: 2em;"> <div style="display: flex; align-items: center"> <h3 class="folderListLabel" style="margin: .5em 0">Managed Folder Mapping</h3> <button is="emby-button" type="button" class="fab btnAddFolder submit emby-button" title="${Add}"> <span class="material-icons add"></span> </button> </div> <div class="folderList paperList folderList-paperList"> </div> <div class="fieldDescription">The Shoko Managed Folders the Media Folders are mapped to.</div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="MediaFolderLibraryOperationMode">Library Operation Mode</label> <select is="emby-select" id="MediaFolderLibraryOperationMode" name="MediaFolderLibraryOperationMode" class="emby-select-withcolor emby-select"> <option value="VFS" selected>Virtual File System (VFS)</option> <option value="Strict">Legacy Filtering (Strict)</option> <option value="Lax">Legacy Filtering (Lax)</option> </select> <div class="fieldDescription"> <div>Determines how the plugin should operate on the selected 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 using the Virtual File System (VFS) entail?</summary> Using the VFS allows you to disregard the underlying disk file structure while automagically meeting Jellyfin's requirements for file organization. It also ensures that no unrecognized files appear in your library and allows us to fully leverage Jellyfin's native features better than we otherwise could without it. This enables us to effortlessly support trailers, special features, and theme videos for series, seasons and movies, as well as merge partial episodes into a single episode. All this is possible because we disregard the underlying disk file structure to create our own using symbolic links. <br/> <strong>Do not enable this for an existing library. If you do, then delete and re-create your library from scratch.</strong> </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does using Legacy Filtering (Strict) entail?</summary> Using legacy filtering in strict mode means the plugin will operate on your media folders as-is, but filter out any and all unrecognized media from the library. </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does using Legacy Filtering (Lax) entail?</summary> Using legacy filtering in lax mode means the plugin will operate on your media folders as-is, but it will not filter out anything from the library. Use this only if intend to mix Shoko with other metadata providers. </details> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only experimental-setting"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MediaFolderLibraryIterativeGenerationEnabled" /> <span>Iterative VFS Generation Enabled</span> </label> <div class="fieldDescription checkboxFieldDescription">Enable the iterative generation of the VFS for the existing library. Only generate links in the VFS for files changed since the last check occurred.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MediaFolderLibraryIterativeGenerationNoCache" /> <span>Iterative VFS Generation — Disable Caching</span> </label> <div class="fieldDescription checkboxFieldDescription">Disable the caching on the library itself, making it always iteratively re-generate the VFS for the library, and forcing all generations occurring within the library to occur.</div> </div> <div class="inputContainer inputContainer-withDescription advanced-only experimental-setting"> <input is="emby-input" type="number" id="MediaFolderLibraryIterativeGenerationMaxCount" label="Max Iterations Before Forced Full Generation" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" max="100" /> <div class="fieldDescription">Maximum number of iterations to perform when generating the VFS for the existing library. Set to a value above 0 to enable.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only experimental-setting"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MediaFolderLibraryForceFullGenerationOnNextRefresh" /> <span>Force Full VFS Generation on Next Library Refresh</span> </label> <div class="fieldDescription checkboxFieldDescription">Force a full generation of the VFS on the next library generation. This option will be turned off once the next generation has started.</div> </div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Library_MultipleVersions_Section" class="verticalSection verticalSection-extrabottompadding advanced-only" hidden> <legend> <h3>Multiple Versions Settings</h3> </legend> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AutoMergeVersions" /> <span>Automatically Merge Multiple Versions</span> </label> <div class="fieldDescription checkboxFieldDescription">Enable to automatically merge multiple versions of the same media info a single displayed entry after a library scan or refresh. Only applies to videos with Shoko IDs set.</div> </div> <div id="MergeVersionSortSelectorList" is="sortable-checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Multiple Versions Sort Selectors</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="ImportedAt"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="ImportedAt"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Imported At | Newly imported first</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="CreatedAt"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="CreatedAt"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Created At | Newly created first</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="Resolution"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Resolution"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Resolution | Highest resolution first</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="ReleaseGroupName"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="ReleaseGroupName"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Release Group Name | Alphabetically sorted</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="FileSource"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="FileSource"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">File Source | Better sources first</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="FileVersion"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="FileVersion"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">File Version | Higher versions first</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="RelativeDepth"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="RelativeDepth"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Relative Folder Depth | Shallower placed files first<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="NoVariation"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="NoVariation"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">File Variation | Non-variations first</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 selectors to use to sort the multiple versions of the same video in priority order when all videos are the same width. 3D videos will be prioritized over 2D videos regardless of this setting, as it is hard coded in Jellyfin to prefer 3D over 2D. And Jellyfin also sorts by video width before using the order we specify.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Library_SeasonMerging_Section" class="verticalSection verticalSection-extrabottompadding advanced-only" hidden> <legend> <h3>Season Merging Settings</h3> </legend> <div class="fieldDescription verticalSection-extrabottompadding">Determines how the plugin handles merging one or more Shoko series into Jellyfin seasons.</div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SeasonMerging_Enabled" /> <span>Enable Season Merging</span> </label> <div class="fieldDescription checkboxFieldDescription"> Globally enable season merging. This will blur the boundaries between AniDB anime further by merging entries which could have just been a single anime entry based on name matching and a configurable merge window, and/or custom per Shoko series merge overrides. <br/><br/> <strong>Note</strong>: The Shoko series will need three things for season merging to be possible: <ul> <li>It needs to have an air date set.</li> <li>It needs to be inside the same Shoko group as the other Shoko series that it should be merged with.</li> <li>It needs to be using <strong>Shoko Group Structure</strong>, be it explicitly or implicitly, which also applies to the other Shoko series that it should be merged with.</li> </ul> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SeasonMerging_AutoMerge" /> <span>Automatically Merge Seasons</span> </label> <div class="fieldDescription checkboxFieldDescription">If disabled then merging will only happen when an override is set for the series in the Series settings tab, otherwise it will also happen automatically based upon the series types to merge and the merge window listed below unless an override is set.</div> </div> <div id="SeasonMerging_SeriesTypes" is="checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Series Types to Merge</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="TV"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">TV Series</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="TVSpecial"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">TV Special</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Web"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Web First Release / Original Net Animation (ONA)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="OVA"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Original Video Animation (OVA)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Movie"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Standalone Movie / Movie Collection</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MusicVideo"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Standalone Music Video Release</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Other"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Misc. Other Type</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Unknown"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Type Not Determined Yet</h3> </div> </div> </div> <div class="fieldDescription">Series types to attempt to automatically merge unless an override is in use. Will respect custom series type overrides.</div> </div> <div class="inputContainer inputContainer-withDescription advanced-only"> <input is="emby-input" type="number" id="SeasonMerging_MergeWindowInDays" label="Merge Window (days)" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" /> <div class="fieldDescription">Number of days to check between the start of each Shoko series during automatic merging, inclusive. Set to 0 to allow merging regardless of release dates.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="VFS_Basic_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Basic Settings</h3> </legend> <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.</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.</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.</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 Path</span> </label> <div class="fieldDescription checkboxFieldDescription">Add the full or short release group name to all automatically linked files in the VFS. "No Group" will be used for all manually linked files. <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 advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_UseSourceFileAsVersionIdentifier" /> <span>Use Source File As Version Identifier</span> </label> <div class="fieldDescription checkboxFieldDescription">Use the source file name as the version identifier.</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 Path</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 if available. <strong>Warning</strong>: Though rare, we may fail 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> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="VFS_Location_Section" class="verticalSection verticalSection-extrabottompadding advanced-only" hidden> <legend> <h3>Generation Settings</h3> </legend> <div class="fieldDescription verticalSection-extrabottompadding"> Configure where to place the VFS in the file system and how to attach it to your libraries. <strong>Tweak at your own risk.</strong> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="VFS_Threads" label="VFS Threads" placeholder="0" type="number" pattern="[0-9]*" min="-1" step="1" /> <div class="fieldDescription">Number of threads to concurrently generate links for the VFS. Set to -1 to to match the Jellyfin scan fanout concurrency. Set to 0 or below to use the core count.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_ResolveLinks" /> <span>Resolve Links Before VFS</span> </label> <div class="fieldDescription checkboxFieldDescription">If the library contains symbolic links to media, it will follow them until a final "real" file is found and use the path of said file for the VFS.</div> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="VFS_MaxTotalExceptionsBeforeAbort" label="Max Total Exceptions Before Abort" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" max="10000" /> <div class="fieldDescription">Maximum number of exceptions before aborting the VFS generation. Set to 0 to disable limit.</div> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="VFS_MaxSeriesExceptionsBeforeAbort" label="Max Series Exceptions Before Abort" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" max="10000" /> <div class="fieldDescription">Maximum number of series with exceptions before aborting the VFS generation. Set to 0 to disable limit.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_UseSemaphore" /> <span>Use Semaphore to Limit Concurrency</span> </label> <div class="fieldDescription checkboxFieldDescription">Use a semaphore instead of an action block to limit concurrency during VFS generation.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_CollectAndSort" /> <span>Collect And Sort Files</span> </label> <div class="fieldDescription checkboxFieldDescription">Collect all files and sort them before processing them during VFS generation. This should make the overall VFS generation faster, but will introduce a longer startup time before it starts processing the VFS entries.</div> </div> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="VFS_Location">VFS Location</label> <select is="emby-select" id="VFS_Location" name="VFS_Location" class="emby-select-withcolor emby-select"> <option value="Default" selected>Jellyfin Data Directory (Default)</option> <option value="Custom">Custom Directory</option> </select> <div class="fieldDescription">Change where the VFS structure will be placed. Changing this will cause your library to "remove" and "re-add" itself because of the path changes. You will need to manually move your VFS root if you plan to keep it when toggling this setting. Trick-play files not already moved out of the VFS by the plugin will need to be backed-up beforehand and moved back after the next library scan if you want to avoid regenerating them after you change this setting. <strong>You have been warned.</strong></div> </div> <div id="VFS_CustomLocationContainer" class="inputContainer inputContainer-withDescription advanced-only" hidden> <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location" disabled /> <div class="fieldDescription">An absolute path, or a relative path from the Jellyfin Data Directory, to the custom root directory of where the VFS will be placed. You decide.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="SignalR_Connection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Connection Status</h3> </legend> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="SignalRStatus" label="Connection 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.</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.</div> </div> </fieldset> <fieldset id="SignalR_Basic_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Basic 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"> Attempt to establish a SignalR connection to your running Shoko instance when Jellyfin starts up. </div> </div> <div class="inputContainer inputContainer-withDescription advanced-only"> <input is="emby-input" type="text" id="SignalRAutoReconnectIntervals" label="Auto Reconnect Intervals (seconds)" /> <div class="fieldDescription">A comma separated list of intervals given in seconds to try to reconnect to your running Shoko instance if we ever gets disconnected. The last value in the list will be retried forever until a successful connection is made. Can be left empty to disable auto reconnect.</div> </div> <div id="SignalREventSources" is="checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">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">TheMovieDb</h3> </div> </div> </div> <div class="fieldDescription">Which event sources should be listened to for metadata events.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="SignalR_Library_New_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>New Library Settings</h3> </legend> <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>Listen for file events, such as when new media gets added or moved around in the file system, and trigger library updates accordingly. Especially useful if your Jellyfin library is using a network share for it's media folder(s) where native file events may not be available for Jellyfin's real time monitoring to function properly. Can be used as a complete replacement for Jellyfin's built-in real time monitoring or used alongside it.</div> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SignalRDefaultRefreshEvents" /> <span>Metadata Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> <div>Listen for metadata update events, such as when metadata gets added, updated, or removed, and trigger library updates accordingly. Can be useful when metadata is partially titles or other info initially, but which were added or updated at a later time.</div> </div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="SignalR_Library_Existing_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Existing Library Settings</h3> </legend> <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="">Click here to select a library</option> </select> <div class="fieldDescription selectFieldDescription">Select a media folder to add or modify the SignalR settings for.</div> </div> <div id="SignalRMediaFolderPerFolderSettingsContainer" hidden> <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>Listen for file events, such as when new media gets added or moved around in the file system, and trigger library updates accordingly. Especially useful if your Jellyfin library is using a network share for it's media folder(s) where native file events may not be available for Jellyfin's real time monitoring to function properly. Can be used as a complete replacement for Jellyfin's built-in real time monitoring or used alongside it.</div> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SignalRRefreshEvents" /> <span>Metadata Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> <div>Listen for metadata update events, such as when metadata gets added, updated, or removed, and trigger library updates accordingly. Can be useful when metadata is partially titles or other info initially, but which were added or updated at a later time.</div> </div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </div> </fieldset> <fieldset id="User_Section" 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">Enable syncing certain data between Jellyfin and Shoko for the user. The specific behavior on what gets synced and when can be configured using the remaining options in this section. Leaving this setting unchecked will disable synchronization entirely, regardless of other synchronization settings.</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">When media gets scanned into your library or when a library's metadata gets refreshed, sync watch-state between Jellyfin and Shoko.</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">Sync watch-state with Shoko when you finish watching a movie, episode, or other video.</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">Sync watch-state to Shoko on play/pause/stop/resume events during media 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">Sync watch-state to Shoko at a regular interval during media 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">Wait about 10 seconds before syncing any watch-state events to Shoko. This will prevent accidental clicks and/or previews from marking a 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 Media</span> </label> <div class="fieldDescription checkboxFieldDescription">Allow syncing watch-state for restricted media (H) to Shoko.</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 in Shoko 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 of the account in Shoko to synchronize with the currently selected user.</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>${ButtonRemove}</span> </button> <div class="fieldDescription">This will delete any saved settings for the user.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </div> </fieldset> <fieldset id="Series_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Series Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="SeriesSearch" label="Search" /> <div class="fieldDescription">Search for series by title, AniDB ID or Shoko Series ID. AniDB IDs should be prefixed with 'a', e.g. 'a69', Shoko Series IDs should be prefixed with 's', e.g. 's1'.</div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesSelector">Configure settings for</label> <select is="emby-select" id="SeriesSelector" name="SeriesSelector" value="" class="emby-select-withcolor emby-select" disabled> <option value="">Click here to select a series</option> </select> <div class="fieldDescription selectFieldDescription">Select a series to modify the series settings for.</div> </div> <div id="SeriesSettingsContainer" hidden> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesType">Series Type</label> <select is="emby-select" id="SeriesType" name="SeriesType" class="emby-select-withcolor emby-select"> <option value="None" selected>Use Original Type (Default)</option> <option value="TV">TV series</option> <option value="TVSpecial">TV Special</option> <option value="Web">Web First Release / Original Net Animation (ONA)</option> <option value="Movie">Standalone Movie / Movie Collection</option> <option value="OVA">Original Video Animation (OVA)</option> <option value="MusicVideo">Music Video (MV)</option> </select> <div class="fieldDescription"> <div> Override the series type for this series. Useful if you want to opt into type specific features for this series. </div> </div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesLibraryStructure">Library Structure Mode</label> <select is="emby-select" id="SeriesLibraryStructure" name="SeriesLibraryStructure" class="emby-select-withcolor emby-select"> <option value="None" selected>Follow Global Settings (Default)</option> <option value="AniDB_Anime">AniDB Anime Structure</option> <option value="Shoko_Groups">Shoko Group Structure</option> <option value="TMDB_SeriesAndMovies">TheMovieDb Shows & Movies Structure</option> </select> <div class="fieldDescription"> <div> This setting determines which library structure to use the Shoko series in all plugin managed libraries. </div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">Pre-requirements for using <strong>Shoko Group Structure</strong>.</summary> To make the most out of this feature you first need to configure your groups in Shoko. You can either enable auto-grouping in the settings, manually craft your own group structure, or a mix of the two where you have auto-grouping enabled but override the placement if you feel it should belong elsewhere instead. For more information look up the <a href="https://docs.shokoanime.com/shoko-server/group-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 most of the time that you've configured a multi-layered structure for your groups because the deepest layer will be used for the shows while all the layers above will be used for collections. The exception to this is if you have a group which contains both movies and shows and you've enabled the option to separate movies from shows. In which case the deepest layer of groups will also be used to generate a collection for your movie(s) and show within the layer. Also note that enabling auto-grouping only gives you 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/shoko-server/group-management">Shoko docs</a> on how to manage your groups. </details> </div> </div> <div class="selectContainer selectContainer-withDescription advanced-only"> <label class="selectLabel" for="SeriesSeasonOrdering">Shoko Group Structure Season Ordering</label> <select is="emby-select" id="SeriesSeasonOrdering" name="SeriesSeasonOrdering" class="emby-select-withcolor emby-select"> <option value="None">Follow Global Settings (Default)</option> <option value="Default">Let Shoko decide</option> <option value="ReleaseDate">Order seasons by release date</option> <option value="Chronological" class="advanced-only">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> <option value="ChronologicalIgnoreIndirect" class="advanced-only">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> </select> <div class="fieldDescription">Determines how to order seasons within the show when using <strong>Shoko Group Structure</strong> and this series is the main series in the group.</div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesSpecialsPlacement">Specials Placement Within Seasons</label> <select is="emby-select" id="SeriesSpecialsPlacement" name="SeriesSpecialsPlacement" class="emby-select-withcolor emby-select"> <option value="None" selected>Follow Global Settings (Default)</option> <option value="Excluded">Exclude specials from the seasons</option> <option value="AfterSeason">Always place specials after the normal episodes</option> <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> <option value="InBetweenSeasonByOtherData">Loosely use the TheMovieDb data available in Shoko to place specials</option> <option value="InBetweenSeasonMixed">Either loosely use the TheMovieDb data available in Shoko or fallback to using release dates to place specials</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 id="SeriesSeasonMergingBehavior" is="checkbox-list" class="advanced-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Season Merging Behavior</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="NoMerge"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Do Not Merge This Series With Any Other Series</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeForward"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge With Sequel (Respects Air Date)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeBackward"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge With Prequel (Respects Air Date)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeWithMainStory"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge With Main Story</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupATarget"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Other Series Into This Series (Merge Group A)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupASource"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Into Other Series (Merge Group A)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupBTarget"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Other Series Into This Series (Merge Group B)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupBSource"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Into Other Series (Merge Group B)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupCTarget"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Other Series Into This Series (Merge Group C)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupCSource"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Into Other Series (Merge Group C)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupDTarget"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Other Series Into This Series (Merge Group D)</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="MergeGroupDSource"> <span></span> </label> <div class="listItemBody"> <h3 class="listItemBodyText">Merge Into Other Series (Merge Group D)</h3> </div> </div> </div> <div class="fieldDescription">Determines how the merging should be handled for the series. Requires the global season merging feature to be enabled for this to take effect.</div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesEpisodeConversion">Episode Conversion Mode</label> <select is="emby-select" id="SeriesEpisodeConversion" name="SeriesEpisodeConversion" class="emby-select-withcolor emby-select"> <option value="None" selected>No Conversion (Default)</option> <option value="EpisodesAsSpecials">Convert Normal Episodes to Specials</option> <option value="SpecialsAsEpisodes">Convert Specials to Normal Episodes</option> <option value="SpecialsAsExtraFeaturettes">Convert Specials to Extra Featurettes</option> </select> <div class="fieldDescription"> <div> Determines how episodes should be converted between episode types. </div> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SeriesOrderByAirdate" /> <span>Order episodes by airdate</span> </label> <div class="fieldDescription checkboxFieldDescription"> Order episodes by their airdate instead of by their original episode number. <strong>Warning</strong>: Only applied to <strong>AniDB Anime Structure</strong> and <strong>Shoko Group Structure</strong>. </div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </div> </fieldset> <fieldset id="Misc_Section" class="verticalSection verticalSection-extrabottompadding advanced-only" hidden> <legend> <h3>Miscellaneous Settings</h3> </legend> <div class="checkboxContainer checkboxContainer-withDescription advanced-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Misc_ShowInMenu" /> <span>Show in Menu</span> </label> <div class="fieldDescription checkboxFieldDescription">Shows a shortcut to the plugin settings in the sidebar navigation menu.</div> </div> <div class="inputContainer inputContainer-withDescription advanced-only"> <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored Folder Names" /> <div class="fieldDescription">A comma separated list of folder names to ignore during a library scan. Useful for skipping folders generated by a NAS or other pieces of software that access the file system where media resides. Only applicable to libraries not managed by the VFS as files not recognized by Shoko Server are already ignored.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Debug_Section" class="verticalSection verticalSection-extrabottompadding debug-only" hidden> <legend> <h3>Debug Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="Debug_UsageTrackerStalledTimeInSeconds" label="Usage Tracker Stall Time (seconds)" placeholder="0" type="number" pattern="[0-9]*" min="1" max="10800" step="1" /> <div class="fieldDescription">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 stalled event.</div> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="Debug_MaxInFlightRequests" label="Max In-Flight Requests" placeholder="0" type="number" pattern="[0-9]*" min="1" max="1000" step="1" /> <div class="fieldDescription">Maximum number of requests of outgoing traffic at any given time.</div> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="Debug_SeriesPageSize" label="Series Page Size" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10000" step="1" /> <div class="fieldDescription">The page size to use for series queries. Set to 0 to disable pagination.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Debug_AutoClearClientCache" /> <span>Auto-Clear Client Cache</span> </label> <div class="fieldDescription checkboxFieldDescription">Whether or not to automatically clear the API client cache.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Debug_AutoClearManagerCache" /> <span>Auto-Clear Manager Cache</span> </label> <div class="fieldDescription checkboxFieldDescription">Whether or not to automatically clear the API manager's cache.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription debug-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="Debug_AutoClearVfsCache" /> <span>Auto-Clear VFS Cache</span> </label> <div class="fieldDescription checkboxFieldDescription">Whether or not to automatically clear the API VFS' cache.</div> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="Debug_ExpirationScanFrequencyInMinutes" label="Expiration Scan Frequency (minutes)" placeholder="0" type="number" pattern="[0-9]*" min="1" max="180" step="1" /> <div class="fieldDescription">The expiration scan frequency in minutes for the guarded caches.</div> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="Debug_SlidingExpirationInMinutes" label="Sliding Expiration (minutes)" placeholder="0" type="number" pattern="[0-9]*" min="1" max="180" step="1" /> <div class="fieldDescription">The sliding expiration in minutes for the guarded caches.</div> </div> <div class="inputContainer inputContainer-withDescription debug-only"> <input is="emby-input" type="number" id="Debug_AbsoluteExpirationRelativeToNowInMinutes" label="Absolute Expiration (minutes)" placeholder="0" type="number" pattern="[0-9]*" min="1" max="1440" step="1" /> <div class="fieldDescription">The absolute expiration relative to now in minutes for the guarded caches.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>${Save}</span> </button> </fieldset> <fieldset id="Utilities_Section" class="verticalSection verticalSection-extrabottompadding advanced-only" hidden> <legend> <h3>Utilities</h3> </legend> <div id="UserDeleteContainer" class="inputContainer inputContainer-withDescription advanced-only"> <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt block emby-button link-redirection" target="_blank" href="#" data-page="Shoko.Utilities.Dummy"> <span>Dummy Example</span> </a> <div class="fieldDescription">Don't click the button above! You dummy!</div> </div> </fieldset> </div> </form> </div> </div> </div> ================================================ FILE: Shokofin/Plugin.cs ================================================ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; 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 { private static TimeSpan BaseUrlUpdateDelay => TimeSpan.FromMinutes(15); private readonly IServerConfigurationManager _configurationManager; private readonly ILogger<Plugin> Logger; private readonly object Lock = new(); /// <summary> /// The last time the base URL and base path was updated. /// </summary> private DateTime? LastBaseUrlUpdate = null; /// <summary> /// Cached base URL of the Jellyfin server, to avoid calculating it all the /// time. /// </summary> private string? CachedBaseUrl = null; /// <summary> /// Base URL where the Jellyfin server is running. /// </summary> public string BaseUrl { get { if (CachedBaseUrl is not null && LastBaseUrlUpdate is not null && DateTime.Now - LastBaseUrlUpdate < BaseUrlUpdateDelay) return CachedBaseUrl; lock (Lock) { LastBaseUrlUpdate = DateTime.Now; if (_configurationManager.GetNetworkConfiguration() is not { } networkOptions) { CachedBaseUrl = "http://localhost:8096/"; CachedBasePath = string.Empty; return CachedBaseUrl; } var protocol = networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http"; // TODO: Fix local network address being set. It breaks images currently. var hostname = networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" and not "::" ? address : "localhost"; var port = networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort; var basePath = networkOptions.BaseUrl is { } baseUrl ? baseUrl : string.Empty; if (basePath.Length > 0 && basePath[0] == '/') basePath = basePath[1..]; CachedBaseUrl = new UriBuilder(protocol, hostname, port).ToString(); CachedBasePath = basePath; return CachedBaseUrl; } } } /// <summary> /// Cached base path of the Jellyfin server, to avoid calculating it all the /// time. /// </summary> private string? CachedBasePath = null; /// <summary> /// Base path where the Jellyfin server is running on the domain. /// </summary> public string BasePath { get { if (CachedBasePath is not null && LastBaseUrlUpdate is not null && DateTime.Now - LastBaseUrlUpdate < BaseUrlUpdateDelay) return CachedBasePath; lock (Lock) { LastBaseUrlUpdate = DateTime.Now; if (_configurationManager.GetNetworkConfiguration() is not { } networkOptions) { CachedBaseUrl = "http://localhost:8096/"; CachedBasePath = string.Empty; return CachedBasePath; } var protocol = networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http"; var hostname = networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" and not "::" ? address : "localhost"; var port = networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort; var basePath = networkOptions.BaseUrl is { } baseUrl ? baseUrl : string.Empty; if (basePath.Length > 0 && basePath[0] == '/') basePath = basePath[1..]; CachedBaseUrl = new UriBuilder(protocol, hostname, port).ToString(); CachedBasePath = basePath; return CachedBasePath; } } } 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; /// <summary> /// "Virtual" File System Root Directory. /// </summary> private string? _virtualRoot; /// <summary> /// "Virtual" File System Root Directory. /// </summary> public string VirtualRoot { get { if (_virtualRoot is not null) return _virtualRoot; var virtualRoot = Configuration.VFS_Location switch { VirtualRootLocation.Custom => VirtualRoot_Custom ?? VirtualRoot_Default, VirtualRootLocation.Default or _ => VirtualRoot_Default, }; if (!Directory.Exists(virtualRoot)) Directory.CreateDirectory(virtualRoot); return _virtualRoot = virtualRoot; } } private string[]? _allVirtualRoots; /// <summary> /// All "Virtual" File System Root Directories. /// </summary> public string[] AllVirtualRoots => _allVirtualRoots ??= (new string[] { VirtualRoot_Default, VirtualRoot_Custom ?? string.Empty }) .Except([string.Empty]) .Distinct() .ToArray(); private string VirtualRoot_Default => Path.Join(ApplicationPaths.ProgramDataPath, "Shokofin", "VFS"); private string? VirtualRoot_Custom => string.IsNullOrWhiteSpace(Configuration.VFS_CustomLocation) ? null : Path.Combine(ApplicationPaths.ProgramDataPath, Configuration.VFS_CustomLocation); /// <summary> /// Gets or sets the event handler that is triggered when this configuration changes. /// </summary> public new event EventHandler<PluginConfiguration>? ConfigurationChanged; public Plugin(UsageTracker usageTracker, IServerConfigurationManager configurationManager, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { var configExists = File.Exists(ConfigurationFilePath); _configurationManager = configurationManager; Tracker = usageTracker; Logger = logger; CanCreateSymbolicLinks = true; Instance = this; base.ConfigurationChanged += OnConfigChanged; 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); } } MigrateConfiguration(Configuration); FixupConfiguration(Configuration); IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(Configuration.Debug.UsageTrackerStalledTime); Logger.LogDebug("Virtual File System Root Directory; {Path}", VirtualRoot); Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); // Disable VFS if we can't create symbolic links on Windows and no configuration exists. if (!configExists && !CanCreateSymbolicLinks) { Configuration.DefaultLibraryOperationMode = Ordering.LibraryOperationMode.Strict; // Remove TvDB from the list of description providers. var index = Configuration.Description.Default.List.IndexOf(TextUtility.DescriptionProvider.TvDB); if (index != -1) { var list = Configuration.Description.Default.List.ToList(); list.RemoveAt(index); Configuration.Description.Default.List = [.. list]; } index = Configuration.Description.Default.Order.IndexOf(TextUtility.DescriptionProvider.TvDB); if (index != -1) { var list = Configuration.Description.Default.Order.ToList(); list.RemoveAt(index); Configuration.Description.Default.Order = [.. list]; } SaveConfiguration(); } } public void UpdateConfiguration() { UpdateConfiguration(Configuration); } private void OnConfigChanged(object? sender, BasePluginConfiguration e) { if (e is not PluginConfiguration config) return; FixupConfiguration(config); IgnoredFolders = config.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(Configuration.Debug.UsageTrackerStalledTime); // Reset the cached VFS root directory in case it has changed. _virtualRoot = null; _allVirtualRoots = null; ConfigurationChanged?.Invoke(sender, config); } private void MigrateConfiguration(PluginConfiguration config) { var changed = false; // Upgrade deprecated configuration options. if (config.Description.Default.Order.Length != Enum.GetValues<TextUtility.DescriptionProvider>().Length) { var current = config.Description.Default.Order; config.Description.Default.Order = Enum.GetValues<TextUtility.DescriptionProvider>() .Except([TextUtility.DescriptionProvider.TvDB]) .OrderBy(x => Array.IndexOf(current, x) == -1 ? int.MaxValue : Array.IndexOf(current, x)) .ToArray(); changed = true; } if (config.RespectPreferredImage.HasValue) { config.Image.Default.UsePreferred = config.RespectPreferredImage.Value; config.RespectPreferredImage = null; changed = true; } if (config.TitleAllowAny is not null || config.TitleMainList is not null || config.TitleAlternateList is not null) { if (config.TitleMainList is not null) { config.Title.Default.MainTitle.List = config.TitleMainList; if (config.TitleMainOrder is not null) config.Title.Default.MainTitle.Order = config.TitleMainOrder; if (config.TitleAllowAny is not null) config.Title.Default.MainTitle.AllowAny = config.TitleAllowAny.Value; } if (config.TitleAlternateList is not null) { config.Title.Default.AlternateTitles[0].List = config.TitleAlternateList; if (config.TitleAlternateOrder is not null) config.Title.Default.AlternateTitles[0].Order = config.TitleAlternateOrder; if (config.TitleAllowAny is not null) config.Title.Default.AlternateTitles[0].AllowAny = config.TitleAllowAny.Value; } config.TitleMainList = null; config.TitleMainOrder = null; config.TitleAlternateList = null; config.TitleAlternateOrder = null; config.TitleAllowAny = null; changed = true; } else if (config.MainTitle is not null || config.AlternateTitles is not null) { if (config.MainTitle is not null) config.Title.Default.MainTitle = config.MainTitle; if (config.AlternateTitles is not null) config.Title.Default.AlternateTitles = config.AlternateTitles; config.MainTitle = null; config.AlternateTitles = null; changed = true; } if (config.DescriptionSourceList is not null || config.DescriptionSourceOrder is not null) { if (config.DescriptionSourceList is not null) { config.Description.Default.List = config.DescriptionSourceList; if (config.DescriptionSourceOrder is not null) config.Description.Default.Order = config.DescriptionSourceOrder; } config.DescriptionSourceList = null; config.DescriptionSourceOrder = null; changed = true; } if (config.SignalR_ReplaceImagesDuringRefresh is not null) { if (config.SignalR_ReplaceImagesDuringRefresh.Value) { config.MetadataRefresh.Collection |= MetadataRefreshField.Images; config.MetadataRefresh.Movie |= MetadataRefreshField.Images; config.MetadataRefresh.Series |= MetadataRefreshField.Images; config.MetadataRefresh.Season |= MetadataRefreshField.Images; config.MetadataRefresh.Video |= MetadataRefreshField.Images; config.MetadataRefresh.Episode |= MetadataRefreshField.Images; } config.SignalR_ReplaceImagesDuringRefresh = null; changed = true; } if (config.VFS_Legacy_Enabled.HasValue) { if (config.VFS_Legacy_Enabled.Value) config.DefaultLibraryOperationMode = Ordering.LibraryOperationMode.VFS; config.VFS_Legacy_Enabled = null; changed = true; } if (config.LegacyMediaFolders is not null) { foreach (var groupedMediaFolders in config.LegacyMediaFolders.GroupBy(c => c.LibraryId)) { var mediaConfig = groupedMediaFolders.FirstOrDefault(c => c.IsVirtualRoot) ?? groupedMediaFolders.First(); var libraryConfig = new LibraryConfiguration { Id = groupedMediaFolders.Key, Name = mediaConfig.LibraryName ?? string.Empty, IsFileEventsEnabled = mediaConfig.IsFileEventsEnabled, IsRefreshEventsEnabled = mediaConfig.IsRefreshEventsEnabled, LibraryOperationMode = mediaConfig.LegacyVirtualFileSystemEnabled.HasValue && mediaConfig.LegacyVirtualFileSystemEnabled.Value ? Ordering.LibraryOperationMode.VFS : mediaConfig.LibraryOperationMode, IterativeVfsGeneration_Enabled = mediaConfig.IterativeVfsGeneration_Enabled, IterativeVfsGeneration_ForceFullGenerationOnNextRefresh = mediaConfig.IterativeVfsGeneration_ForceFullGenerationOnNextRefresh, IterativeVfsGeneration_CurrentCount = mediaConfig.IterativeVfsGeneration_CurrentCount, IterativeVfsGeneration_LastGeneratedAt = mediaConfig.IterativeVfsGeneration_LastGeneratedAt, IterativeVfsGeneration_MaxCount = mediaConfig.IterativeVfsGeneration_MaxCount, IterativeVfsGeneration_NoCache = mediaConfig.IterativeVfsGeneration_NoCache, }; config.Libraries.Add(libraryConfig); foreach (var mediaFolder in groupedMediaFolders.Where(c => !c.IsVirtualRoot)) { var mediaFolderConfig = new MediaFolderConfiguration { LibraryId = groupedMediaFolders.Key, Path = mediaFolder.MediaFolderPath, ManagedFolderId = mediaFolder.ManagedFolderId, ManagedFolderName = mediaFolder.ManagedFolderName, ManagedFolderRelativePath = mediaFolder.ManagedFolderRelativePath, }; config.LibraryFolders.Add(mediaFolderConfig); } } config.LegacyMediaFolders = null; changed = true; } if (changed) SaveConfiguration(config); } public void FixupConfiguration(PluginConfiguration config) { // Fix-up faulty configuration. var changed = false; // Disallow setting the default library structure to none. if (config.DefaultLibraryStructure is SeriesStructureType.None) { config.DefaultLibraryStructure = SeriesStructureType.AniDB_Anime; changed = true; } // Disallow setting the default season ordering to none. if (config.DefaultSeasonOrdering is Ordering.OrderType.None) { config.DefaultSeasonOrdering = Ordering.OrderType.Default; changed = true; } // Disallow setting the default specials placement to none. if (config.DefaultSpecialsPlacement is Ordering.SpecialOrderType.None) { config.DefaultSpecialsPlacement = Ordering.SpecialOrderType.Excluded; changed = true; } if (changed) SaveConfiguration(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 [ // HTML new() { Name = "Shoko.Settings", EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Settings.html", EnableInMainMenu = Configuration.Misc_ShowInMenu, DisplayName = "Shoko - Settings", MenuSection = "Shoko", }, new() { Name = "Shoko.Utilities.Dummy", EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Dummy.html", DisplayName = "Shoko - Dummy", MenuSection = "Shoko", }, // JS new() { Name = "Shoko.Common.js", EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Scripts.Common.js", }, new() { Name = "Shoko.Settings.js", EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Scripts.Settings.js", }, new() { Name = "Shoko.Utilities.Dummy.js", EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Scripts.Dummy.js", }, ]; } } ================================================ FILE: Shokofin/PluginServiceRegistrator.cs ================================================ 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.UsageTracker>(); serviceCollection.AddSingleton<Utils.LibraryScanWatcher>(); serviceCollection.AddSingleton<API.ShokoApiClient>(); serviceCollection.AddSingleton<API.ShokoApiManager>(); serviceCollection.AddSingleton<API.ShokoIdLookup>(); serviceCollection.AddSingleton<Configuration.MediaFolderConfigurationService>(); serviceCollection.AddSingleton<Configuration.SeriesConfigurationService>(); serviceCollection.AddSingleton<Sync.UserDataSyncManager>(); serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); serviceCollection.AddSingleton<Collections.CollectionManager>(); serviceCollection.AddSingleton<Resolvers.VirtualFileSystemService>(); serviceCollection.AddSingleton<Events.MetadataRefreshService>(); serviceCollection.AddSingleton<Events.EventDispatchService>(); serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); serviceCollection.AddHostedService<SignalR.SignalREntryPoint>(); serviceCollection.AddHostedService<Resolvers.ShokoLibraryMonitor>(); serviceCollection.AddControllers(options => { options.Filters.Add<Web.ImageHostUrl>(); options.Filters.Add<Web.VfsActionFilter>(); }); } } ================================================ FILE: Shokofin/Providers/BoxSetProvider.cs ================================================ 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.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; namespace Shokofin.Providers; public class BoxSetProvider(IHttpClientFactory _httpClientFactory, ILogger<BoxSetProvider> _logger, ShokoApiManager _apiManager) : IRemoteMetadataProvider<BoxSet, BoxSetInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; public int Order => -1; public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { try { // Try to read the shoko group id if (info.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForGroup, out var collectionId)) using (Plugin.Instance.Tracker.Enter($"Providing info for Collection \"{info.Name}\". (Path=\"{info.Path}\",Collection=\"{collectionId}\")")) return await GetShokoGroupMetadata(info, collectionId).ConfigureAwait(false); // Try to read the shoko series id if (info.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForSeries, out var seasonId)) using (Plugin.Instance.Tracker.Enter($"Providing info for Collection \"{info.Name}\". (Path=\"{info.Path}\",Season=\"{seasonId}\")")) return await GetShokoSeriesMetadata(info, seasonId).ConfigureAwait(false); return new(); } catch (Exception ex) { _logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, ex.Message); return new MetadataResult<BoxSet>(); } } private async Task<MetadataResult<BoxSet>> GetShokoSeriesMetadata(BoxSetInfo info, string seasonId) { // First try to re-use any existing series id. var result = new MetadataResult<BoxSet>(); var seasonInfo = await _apiManager.GetSeasonInfo(seasonId).ConfigureAwait(false); if (seasonInfo == 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) = TextUtility.GetCollectionTitles(seasonInfo, info.MetadataLanguage); _logger.LogInformation("Found collection {CollectionName} (Season={SeasonId},ExtraSeasons={ExtraIds})", displayTitle, seasonInfo.Id, seasonInfo.ExtraIds); result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, Overview = TextUtility.GetCollectionDescription(seasonInfo, info.MetadataLanguage), PremiereDate = seasonInfo.PremiereDate, EndDate = seasonInfo.EndDate, ProductionYear = seasonInfo.PremiereDate?.Year, Tags = seasonInfo.Tags.ToArray(), CommunityRating = seasonInfo.CommunityRating.ToFloat(10), }; result.Item.SetProviderId(ProviderNames.ShokoCollectionForSeries, seasonInfo.Id); result.HasMetadata = true; return result; } private async Task<MetadataResult<BoxSet>> GetShokoGroupMetadata(BoxSetInfo info, string collectionId) { // Filter out all manually created collections. We don't help those. var result = new MetadataResult<BoxSet>(); var collectionInfo = await _apiManager.GetCollectionInfo(collectionId).ConfigureAwait(false); if (collectionInfo == null) { _logger.LogWarning("Unable to find collection info for name {Name} and path {Path}", info.Name, info.Path); return result; } var (displayTitle, alternateTitle) = TextUtility.GetCollectionTitles(collectionInfo, info.MetadataLanguage); displayTitle ??= collectionInfo.Title; _logger.LogInformation("Found collection {CollectionName} (Collection={CollectionId})", displayTitle, collectionInfo.Id); result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, Overview = TextUtility.GetCollectionDescription(collectionInfo, info.MetadataLanguage), }; result.Item.SetProviderId(ProviderNames.ShokoCollectionForGroup, collectionInfo.Id); result.HasMetadata = true; return result; } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } ================================================ FILE: Shokofin/Providers/CustomBoxSetProvider.cs ================================================ 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.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; namespace Shokofin.Providers; #pragma warning disable IDE0059 #pragma warning disable IDE0290 /// <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(ILogger<CustomBoxSetProvider> _logger, ShokoApiManager _apiManager, ILibraryManager _libraryManager, CollectionManager _collectionManager) : IHasItemChangeMonitor, ICustomMetadataProvider<BoxSet> { public string Name => Plugin.MetadataProviderName; public bool HasChanged(BaseItem item, IDirectoryService directoryService) { // We're only interested in box sets. if (item is not BoxSet collection) return false; // Try to read the shoko group id. if (collection.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForGroup, out var collectionId)) return true; // Try to read the shoko series id. if (collection.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForSeries, out var seasonId)) return true; return false; } 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).ConfigureAwait(false); if (collectionRoot is null) return ItemUpdateType.None; // Try to read the shoko group id. if (collection.TryGetProviderId(ProviderNames.ShokoCollectionForGroup, out var collectionId) || collection.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForGroup, 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).ConfigureAwait(false)) return ItemUpdateType.MetadataEdit; // Try to read the shoko series id. if (collection.TryGetProviderId(ProviderNames.ShokoCollectionForSeries, out var seasonId) || collection.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForSeries, out seasonId)) using (Plugin.Instance.Tracker.Enter($"Providing custom info for Collection \"{collection.Name}\". (Path=\"{collection.Path}\",Season=\"{seasonId}\")")) if (await EnsureSeriesCollectionIsCorrect(collection, seasonId, cancellationToken).ConfigureAwait(false)) return ItemUpdateType.MetadataEdit; return ItemUpdateType.None; } private async Task<bool> EnsureSeriesCollectionIsCorrect(BoxSet collection, string seasonId, CancellationToken cancellationToken) { var seasonInfo = await _apiManager.GetSeasonInfo(seasonId).ConfigureAwait(false); if (seasonInfo is null) return false; var updated = EnsureNoTmdbIdIsSet(collection); var metadataLanguage = _libraryManager.GetLibraryOptions(collection)?.PreferredMetadataLanguage; var (displayName, alternateTitle) = TextUtility.GetCollectionTitles(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); _logger.LogDebug("Fixed collection {CollectionName} (Season={SeasonId})", collection.Name, seasonId); } return updated; } private async Task<bool> EnsureGroupCollectionIsCorrect(Folder collectionRoot, BoxSet collection, string collectionId, CancellationToken cancellationToken) { var collectionInfo = await _apiManager.GetCollectionInfo(collectionId).ConfigureAwait(false); if (collectionInfo is null) return false; var updated = EnsureNoTmdbIdIsSet(collection); var parent = collectionInfo.IsTopLevel ? collectionRoot : await GetCollectionByCollectionId(collectionRoot, collectionInfo.ParentId).ConfigureAwait(false); var (displayTitle, alternateTitle) = TextUtility.GetCollectionTitles(collectionInfo, collection.GetPreferredMetadataLanguage()); displayTitle ??= collectionInfo.Title; if (collection.ParentId != parent.Id) { collection.SetParent(parent); updated = true; } if (!string.Equals(collection.Name, displayTitle)) { collection.Name = displayTitle; updated = true; } if (!string.Equals(collection.OriginalTitle, alternateTitle)) { collection.OriginalTitle = alternateTitle; updated = true; } if (updated) { await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Fixed collection {CollectionName} (Collection={CollectionId})", collection.Name, collectionId); } return updated; } private async Task<BoxSet> GetCollectionByCollectionId(Folder collectionRoot, string? collectionId) { if (string.IsNullOrEmpty(collectionId)) throw new ArgumentNullException(nameof(collectionId)); var collectionInfo = await _apiManager.GetCollectionInfo(collectionId).ConfigureAwait(false) ?? 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 = [BaseItemKind.BoxSet], HasAnyProviderId = new() { { ProviderNames.ShokoCollectionForGroup, 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.Title.ForceASCII()} [{ProviderNames.ShokoCollectionForGroup}={collectionInfo.Id}]"; var folderName = BaseItem.FileSystem.GetValidFilename(baseName) + " [boxset]"; var path = Path.Combine(collectionRoot.Path, folderName); return _libraryManager.FindByPath(path, true) as BoxSet; } private static bool EnsureNoTmdbIdIsSet(BoxSet collection) { var willRemove = collection.HasProviderId(MetadataProvider.TmdbCollection); collection.ProviderIds.Remove(MetadataProvider.TmdbCollection.ToString()); return willRemove; } } ================================================ FILE: Shokofin/Providers/CustomEpisodeProvider.cs ================================================ using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Extensions; using Shokofin.ExternalIds; using Shokofin.MergeVersions; using Shokofin.Resolvers; using Info = Shokofin.API.Info; namespace Shokofin.Providers; #pragma warning disable IDE0059 /// <summary> /// The custom episode provider. Responsible for de-duplicating episodes, both /// virtual and physical. /// </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( ILogger<CustomEpisodeProvider> _logger, VirtualFileSystemService _vfsService, ILibraryManager _libraryManager, ShokoIdLookup _lookup, #if NET9_0_OR_GREATER ShokoApiManager _apiManager, #endif MergeVersionsManager _mergeVersionsManager) : IHasItemChangeMonitor, ICustomMetadataProvider<Episode> { public string Name => Plugin.MetadataProviderName; public bool HasChanged(BaseItem item, IDirectoryService directoryService) { // We're only interested in episodes. if (item is not Episode episode) return false; // Abort if we're unable to get the shoko episode id. if (!_lookup.IsEnabledForItem(episode) || !episode.TryGetProviderId(ProviderNames.ShokoEpisode, out var episodeId)) return false; using (Plugin.Instance.Tracker.Enter($"Checking for custom info for Episode \"{episode.Name}\". (Path=\"{episode.Path}\")")) { if (_vfsService.TryGetCurrentLibraryGenerationMode(episode.ContainingFolderPath, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped episode during iterative generation. (Episode={EpisodeId})", episodeId); return false; } } return true; } public async Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) { var series = episode.Series; if (!_lookup.IsEnabledForItem(series) || !series.TryGetSeasonId(out var seasonId)) return ItemUpdateType.None; var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Episode \"{episode.Name}\". (Path=\"{episode.Path}\",IsMissingEpisode={episode.IsMissingEpisode})"); try { if (_vfsService.TryGetCurrentLibraryGenerationMode(series.Path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped episode during iterative generation. (MainSeason={MainSeasonId},Season={SeasonNumber},Episode={EpisodeNumber})", seasonId, episode.ParentIndexNumber, episode.IndexNumber); return ItemUpdateType.None; } var updateType = (ItemUpdateType)0; #if NET9_0_OR_GREATER // Since Jellyfin 10.11.1 onwards they've fixed it so the creation date for videos doesn't follow the symlink but instead follows the target location, so to match the older behavior to get the date to match the import date, we now make sure the creation date is set to the import date here. if (episode.TryGetFileAndSeriesId(out var fileId, out var seriesId, vfsOnly: true)) { if (await _apiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false) is { } fileInfo) { var createdAt = fileInfo.Shoko.ImportedAt ?? fileInfo.Shoko.CreatedAt; if (episode.DateCreated != createdAt) { episode.DateCreated = createdAt; updateType |= ItemUpdateType.MetadataImport; } } } #endif if (_lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) { foreach (var episodeId in episodeIds) { RemoveVirtualEpisodes(episodeId, episode, series.GetPresentationUniqueKey()); if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { _mergeVersionsManager.ScheduleSplitAndMergeEpisodesByEpisodeId(episodeId); } } } return updateType is 0 ? ItemUpdateType.None : updateType; } finally { Plugin.Instance.Tracker.Remove(trackerId); } } private bool RemoveVirtualEpisodes(string episodeId, Episode episode, string seriesPresentationUniqueKey) { // Remove any extra virtual episodes that matches the newly refreshed episode. var searchList = _libraryManager.GetItemList( new() { ExcludeItemIds = [episode.Id], HasAnyProviderId = new() { { ProviderNames.ShokoEpisode, episodeId } }, IncludeItemTypes = [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 seasonId) { var searchList = libraryManager.GetItemList( new() { IncludeItemTypes = [Jellyfin.Data.Enums.BaseItemKind.Episode], HasAnyProviderId = new() { { ProviderNames.ShokoEpisode, 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},Season={SeasonId})", searchList[0].Name, episodeId, seasonId); 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)) 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},Season={SeasonId},ExtraSeasons={ExtraIds})", episode.IndexNumber, season.IndexNumber, showInfo.Title, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); season.AddChild(episode); return true; } } ================================================ FILE: Shokofin/Providers/CustomMovieProvider.cs ================================================ using System.Threading; using System.Threading.Tasks; 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.Extensions; using Shokofin.ExternalIds; using Shokofin.MergeVersions; using Shokofin.Resolvers; namespace Shokofin.Providers; #pragma warning disable IDE0059 #pragma warning disable IDE0290 /// <summary> /// The custom movie provider. Responsible for de-duplicating physical movies. /// </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 CustomMovieProvider( ILogger<CustomMovieProvider> _logger, VirtualFileSystemService _vfsService, ILibraryManager _libraryManager, ShokoIdLookup _lookup, #if NET9_0_OR_GREATER ShokoApiManager _apiManager, #endif MergeVersionsManager _mergeVersionsManager) : IHasItemChangeMonitor, ICustomMetadataProvider<Movie> { public string Name => Plugin.MetadataProviderName; public bool HasChanged(BaseItem item, IDirectoryService directoryService) { // We're only interested in movies. if (item is not Movie movie) return false; // Abort if we're unable to get the shoko episode id. if (!_lookup.IsEnabledForItem(movie) || !movie.TryGetProviderId(ProviderNames.ShokoEpisode, out var episodeId)) return false; using (Plugin.Instance.Tracker.Enter($"Checking for custom info for Movie \"{movie.Name}\". (Path=\"{movie.Path}\")")) { if (_vfsService.TryGetCurrentLibraryGenerationMode(movie.ContainingFolderPath, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped movie during iterative generation. (Episode={EpisodeId})", episodeId); return false; } } return true; } public async Task<ItemUpdateType> FetchAsync(Movie movie, MetadataRefreshOptions options, CancellationToken cancellationToken) { if (!_lookup.IsEnabledForItem(movie) || !movie.TryGetSeasonId(out var seasonId) || !movie.TryGetEpisodeId(out var episodeId) || !movie.TryGetFileAndSeriesId(out var fileId, out var seriesId)) return ItemUpdateType.None; var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Movie \"{movie.Name}\". (Path=\"{movie.Path}\")"); try { if (_vfsService.TryGetCurrentLibraryGenerationMode(movie.ContainingFolderPath, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped movie during iterative generation. (Season={SeasonId},Episode={EpisodeId})", seasonId, episodeId); return ItemUpdateType.None; } var updateType = (ItemUpdateType)0; #if NET9_0_OR_GREATER // Since Jellyfin 10.11.1 onwards they've fixed it so the creation date for videos doesn't follow the symlink but instead follows the target location, so to match the older behavior to get the date to match the import date, we now make sure the creation date is set to the import date here. if (movie.TryGetFileAndSeriesId(out _, out _, vfsOnly: true)) { if (await _apiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false) is { } fileInfo) { var createdAt = fileInfo.Shoko.ImportedAt ?? fileInfo.Shoko.CreatedAt; if (movie.DateCreated != createdAt) { movie.DateCreated = createdAt; updateType |= ItemUpdateType.MetadataImport; } } } #endif if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { _mergeVersionsManager.ScheduleSplitAndMergeMoviesByEpisodeId(episodeId); } return updateType is 0 ? ItemUpdateType.None : updateType; } finally { Plugin.Instance.Tracker.Remove(trackerId); } } } ================================================ FILE: Shokofin/Providers/CustomSeasonProvider.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Extensions; using Shokofin.MergeVersions; using Shokofin.Resolvers; using Info = Shokofin.API.Info; namespace Shokofin.Providers; #pragma warning disable IDE0059 #pragma warning disable IDE0290 /// <summary> /// The custom season provider. Responsible for de-duplicating seasons, /// adding/removing "missing" episodes, and de-duplicating physical 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(ILogger<CustomSeasonProvider> _logger, VirtualFileSystemService _vfsService, ShokoApiManager _apiManager, ShokoIdLookup _lookup, ILibraryManager _libraryManager, MergeVersionsManager _mergeVersionsManager) : IHasItemChangeMonitor, ICustomMetadataProvider<Season> { private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; public string Name => Plugin.MetadataProviderName; public bool HasChanged(BaseItem item, IDirectoryService directoryService) { // We're only interested in seasons. if (item is not Season season) return false; // We're not interested in the dummy season. if (!season.IndexNumber.HasValue) return false; // Silently abort if we're unable to get the shoko series id. var series = season.Series; if (!_lookup.IsEnabledForItem(series) || !series.TryGetSeasonId(out var seasonId)) return false; using (Plugin.Instance.Tracker.Enter($"Checking for custom info for Season \"{season.Name}\". (Path=\"{season.Path}\",MainSeason=\"{seasonId}\",Season={season.IndexNumber})")) { if (_vfsService.TryGetCurrentLibraryGenerationMode(series.Path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped season during iterative generation. (MainSeason={MainSeasonId},Season={SeasonNumber})", seasonId, season.IndexNumber); return false; } } return true; } 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 (!_lookup.IsEnabledForItem(series) || !series.TryGetSeasonId(out var seasonId)) return ItemUpdateType.None; var seasonNumber = season.IndexNumber!.Value; var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Season \"{season.Name}\". (Path=\"{season.Path}\",MainSeason=\"{seasonId}\",Season={seasonNumber})"); try { if (_vfsService.TryGetCurrentLibraryGenerationMode(series.Path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped season during iterative generation. (MainSeason={MainSeasonId},Season={SeasonNumber})", seasonId, seasonNumber); return ItemUpdateType.None; } // Loudly abort if the show metadata doesn't exist. var showInfo = await _apiManager.GetShowInfoBySeasonId(seasonId).ConfigureAwait(false); if (showInfo == null || showInfo.SeasonList.Count == 0) { _logger.LogWarning("Unable to find show info for season. (MainSeason={MainSeasonId},Season={SeasonNumber})", seasonId, seasonNumber); return ItemUpdateType.None; } // Remove duplicates of the same season. RemoveVirtualSeasons(_libraryManager, _logger, seasonNumber, season, series, seasonId); // 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>(); var orderedEpisodes = season.Children.OfType<Episode>().OrderBy(e => e.IndexNumber).ThenBy(e => e.IndexNumberEnd).ThenByDescending(e => e.IsVirtualItem).ToList(); foreach (var episode in orderedEpisodes) { if (_lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && (!knownEpisodeIds.Overlaps(episodeIds) || existingEpisodes.Overlaps(episodeIds))) toRemoveEpisodes.Add(episode); else foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); } // Remove unknown or unwanted episodes. foreach (var episode in toRemoveEpisodes) { _logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (MainSeason={MainSeasonId})", episode.Name, 0, series.Name, seasonId); _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).ConfigureAwait(false)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; CustomEpisodeProvider.AddVirtualEpisode(_libraryManager, _logger, showInfo, sI, episodeInfo, season, series); } } } // Merge versions. if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in existingEpisodes) { _mergeVersionsManager.ScheduleSplitAndMergeEpisodesByEpisodeId(episodeId); } } } // 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. (MainSeason={MainSeasonId})", seasonNumber, seasonId); 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() : []; var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); var orderedEpisodes = season.Children.OfType<Episode>().OrderBy(e => e.IndexNumber).ThenBy(e => e.IndexNumberEnd).ThenByDescending(e => e.IsVirtualItem).ToList(); foreach (var episode in orderedEpisodes) { if (_lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) { if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && (!knownEpisodeIds.Overlaps(episodeIds) || existingEpisodes.Overlaps(episodeIds))) toRemoveEpisodes.Add(episode); else foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); } } // Remove unknown or unwanted episodes. foreach (var episode in toRemoveEpisodes) { _logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (MainSeason={MainSeasonId})", episode.Name, seasonNumber, series.Name, seasonId); _libraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); } // Add missing episodes. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in await _apiManager.GetLocalEpisodeIdsForSeason(seasonInfo).ConfigureAwait(false)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in episodeList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; CustomEpisodeProvider.AddVirtualEpisode(_libraryManager, _logger, showInfo, seasonInfo, episodeInfo, season, series); } } // Merge versions. if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in existingEpisodes) { _mergeVersionsManager.ScheduleSplitAndMergeEpisodesByEpisodeId(episodeId); } } } return ItemUpdateType.None; } finally { Plugin.Instance.Tracker.Remove(trackerId); } } private static bool RemoveVirtualSeasons(ILibraryManager libraryManager, ILogger logger, int seasonNumber, Season season, Series series, string seasonId) { // Remove the virtual season that matches the season. var searchList = libraryManager .GetItemList( new() { ParentId = season.ParentId, IncludeItemTypes = [Jellyfin.Data.Enums.BaseItemKind.Season], ExcludeItemIds = [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} (MainSeason={MainSeasonId})", searchList.Count, seasonNumber, series.Name, seasonId); 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 = [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 = $"AA - {seasonName}", ForcedSortName = $"AA - {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}. (Season={SeasonId},ExtraSeasons={ExtraIds})", seasonNumber, series.Name, seasonInfo.Id, seasonInfo.ExtraIds); series.AddChild(season); return season; } } ================================================ FILE: Shokofin/Providers/CustomSeriesProvider.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Extensions; using Shokofin.MergeVersions; using Shokofin.Resolvers; using Shokofin.Utils; using Info = Shokofin.API.Info; namespace Shokofin.Providers; #pragma warning disable IDE0059 #pragma warning disable IDE0290 /// <summary> /// The custom series provider. Responsible for de-duplicating seasons, /// adding/removing "missing" episodes, and de-duplicating physical 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 CustomSeriesProvider(ILogger<CustomSeriesProvider> _logger, VirtualFileSystemService _vfsService, ShokoApiManager _apiManager, ShokoIdLookup _lookup, ILibraryManager _libraryManager, MergeVersionsManager _mergeVersionsManager) : IHasItemChangeMonitor, ICustomMetadataProvider<Series> { private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; public string Name => Plugin.MetadataProviderName; public bool HasChanged(BaseItem item, IDirectoryService directoryService) { // We're only interested in series. if (item is not Series series) return false; // Abort if we're unable to get the shoko series id. if (!_lookup.IsEnabledForItem(series) || !series.TryGetSeasonId(out var seasonId)) return false; using (Plugin.Instance.Tracker.Enter($"Checking for custom info for Series \"{series.Name}\". (MainSeason=\"{seasonId}\")")) { if (_vfsService.TryGetCurrentLibraryGenerationMode(series.Path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped series during iterative generation. (MainSeason={MainSeasonId})", seasonId); return false; } } return true; } public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) { // Abort if we're unable to get the shoko series id. if (!_lookup.IsEnabledForItem(series) || !series.TryGetSeasonId(out var seasonId)) return ItemUpdateType.None; var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Series \"{series.Name}\". (MainSeason=\"{seasonId}\")"); try { if (_vfsService.TryGetCurrentLibraryGenerationMode(series.Path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { _logger.LogTrace("Skipped series during iterative generation. (MainSeason={MainSeasonId})", seasonId); return ItemUpdateType.None; } // Provide metadata for a series using Shoko's Group feature var showInfo = await _apiManager.GetShowInfoBySeasonId(seasonId).ConfigureAwait(false); if (showInfo == null || showInfo.SeasonList.Count == 0) { _logger.LogWarning("Unable to find show info for series. (MainSeason={MainSeasonId})", seasonId); return ItemUpdateType.None; } // Get the existing seasons and known seasons. 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} (MainSeason={MainSeasonId})", seasonNumber, series.Name, seasonId); 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} (MainSeason={MainSeasonId})", episode.IndexNumber, episode.Name, season.IndexNumber, series.Name, seasonId); episode.SetParent(mainSeason); } await _libraryManager.UpdateItemsAsync(episodes, mainSeason, ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } _logger.LogDebug("Removing extra Season {SeasonNumber} for Series {SeriesName} (MainSeason={MainSeasonId})", season.IndexNumber!.Value, series.Name, seasonId); _libraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); } // Add missing seasons. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) { 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>(); var orderedEpisodes = zeroSeason.Children.OfType<Episode>().OrderBy(e => e.IndexNumber).ThenBy(e => e.IndexNumberEnd).ThenByDescending(e => e.IsVirtualItem).ToList(); foreach (var episode in orderedEpisodes) { if (_lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && (!knownEpisodeIds.Overlaps(episodeIds) || existingEpisodes.Overlaps(episodeIds))) toRemoveEpisodes.Add(episode); else foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); } // Remove unknown or unwanted episodes. foreach (var episode in toRemoveEpisodes) { _logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (MainSeason={MainSeasonId})", episode.Name, 0, series.Name, seasonId); _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).ConfigureAwait(false)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in seasonInfo.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; CustomEpisodeProvider.AddVirtualEpisode(_libraryManager, _logger, showInfo, seasonInfo, episodeInfo, zeroSeason, series); } } } // Merge versions. if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in existingEpisodes) { _mergeVersionsManager.ScheduleSplitAndMergeEpisodesByEpisodeId(episodeId); } } } // 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 season info for Season {SeasonNumber}. (MainSeason={MainSeasonId})", seasonNumber, showInfo.Id); 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() : []; var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); var orderedEpisodes = season.Children.OfType<Episode>().OrderBy(e => e.IndexNumber).ThenBy(e => e.IndexNumberEnd).ThenByDescending(e => e.IsVirtualItem).ToList(); foreach (var episode in orderedEpisodes) { if (_lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) { if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && (!knownEpisodeIds.Overlaps(episodeIds) || existingEpisodes.Overlaps(episodeIds))) toRemoveEpisodes.Add(episode); else foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); } } // Remove unknown or unwanted episodes. foreach (var episode in toRemoveEpisodes) { _logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (MainSeason={MainSeasonId})", episode.Name, seasonNumber, series.Name, seasonId); _libraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); } // Add missing episodes. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in await _apiManager.GetLocalEpisodeIdsForSeason(seasonInfo).ConfigureAwait(false)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in episodeList) { var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); if (episodeParentIndex != seasonNumber) continue; if (existingEpisodes.Contains(episodeInfo.Id)) continue; CustomEpisodeProvider.AddVirtualEpisode(_libraryManager, _logger, showInfo, seasonInfo, episodeInfo, season, series); } } // Merge versions. if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in existingEpisodes) { _mergeVersionsManager.ScheduleSplitAndMergeEpisodesByEpisodeId(episodeId); } } } return ItemUpdateType.None; } 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); } } } ================================================ FILE: Shokofin/Providers/EpisodeProvider.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions; 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(IHttpClientFactory _httpClientFactory, ILogger<EpisodeProvider> _logger, ShokoApiManager _apiManager) : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; public int Order => 0; 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.TryGetProviderId(ProviderNames.ShokoEpisode, out var episodeId)) return result; episodeInfo = await _apiManager.GetEpisodeInfo(episodeId).ConfigureAwait(false); if (episodeInfo == null) return result; seasonInfo = await _apiManager.GetSeasonInfoForEpisode(episodeId).ConfigureAwait(false); if (seasonInfo == null) return result; showInfo = await _apiManager.GetShowInfoBySeasonId(seasonInfo.Id).ConfigureAwait(false); if (showInfo == null || showInfo.SeasonList.Count == 0) return result; } else { (fileInfo, seasonInfo, showInfo) = await _apiManager.GetFileInfoByPath(info.Path).ConfigureAwait(false); episodeInfo = fileInfo is { EpisodeList.Count: > 0 } ? fileInfo.EpisodeList[0].Episode : null; } // 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, info.MetadataCountryCode); _logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Season={SeasonId},ExtraSeasons={ExtraIds})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); result.HasMetadata = true; result.ResetPeople(); foreach (var person in episodeInfo.Staff) result.AddPerson(person); return result; } catch (Exception ex) { if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { if (!info.TryGetProviderId(ProviderNames.ShokoEpisode, out var episodeId)) episodeId = null; _logger.LogError(ex, "Threw unexpectedly while refreshing a missing episode; {Message} (Episode={EpisodeId})", ex.Message, episodeId); } else { _logger.LogError(ex, "Threw unexpectedly while refreshing {Path}: {Message}", info.Path, ex.Message); } return new MetadataResult<Episode>(); } finally { Plugin.Instance.Tracker.Remove(trackerId); } } public static Episode CreateMetadata(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season, Guid episodeId) => CreateMetadata(showInfo, seasonInfo, episodeInfo, null, season.GetPreferredMetadataLanguage(), season.GetPreferredMetadataCountryCode(), season, episodeId); public static Episode CreateMetadata(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Info.FileInfo? file, string metadataLanguage, string metadataCountryCode) => CreateMetadata(showInfo, seasonInfo, episodeInfo, file, metadataLanguage, metadataCountryCode, null, Guid.Empty); private static Episode CreateMetadata(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Info.FileInfo? fileInfo, string metadataLanguage, string metadataCountryCode, Season? season, Guid episodeId) { var config = Plugin.Instance.Configuration; var episodeNumber = Ordering.GetEpisodeNumber(showInfo, seasonInfo, episodeInfo); var seasonNumber = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, isSpecial) = Ordering.GetSpecialPlacement(showInfo, seasonInfo, episodeInfo); string? displayTitle, alternateTitle, description; if (fileInfo is not null && fileInfo.EpisodeList.Count > 1) { var displayTitles = new List<string?>(); var alternateTitles = new List<string?>(); foreach (var (eI, _, _) in fileInfo.EpisodeList) { string defaultEpisodeTitle = eI.Title; string? dTitle, aTitle; if ( // Movies (seasonInfo.Type == SeriesType.Movie && eI.Type is EpisodeType.Episode or EpisodeType.Special) || // All other ignored types. ( eI.Type is EpisodeType.Episode && eI.EpisodeNumber == 1 && eI.Titles.FirstOrDefault(title => title.Source is "AniDB" && title.LanguageCode is "en")?.Value is { } mainTitle && TextUtility.IgnoredSubTitles.Contains(mainTitle) && TextUtility.GetEpisodeTitles(eI, seasonInfo, metadataLanguage) is { } episodeTitles && string.IsNullOrEmpty(episodeTitles.displayTitle) ) ) (dTitle, aTitle) = TextUtility.GetMovieTitles(eI, seasonInfo, metadataLanguage); else (dTitle, aTitle) = TextUtility.GetEpisodeTitles(eI, seasonInfo, metadataLanguage); if (string.IsNullOrEmpty(dTitle)) dTitle = eI.Type switch { EpisodeType.Special => $"Special {Ordering.GetEpisodeNumber(showInfo, seasonInfo, eI)}", _ => $"Episode {Ordering.GetEpisodeNumber(showInfo, seasonInfo, eI)}", }; displayTitles.Add(dTitle); alternateTitles.Add(aTitle); } displayTitle = TextUtility.JoinText(displayTitles); alternateTitle = TextUtility.JoinText(alternateTitles); description = TextUtility.GetEpisodeDescription(fileInfo.EpisodeList.Select(tuple => tuple.Episode), seasonInfo, metadataLanguage); } else { string defaultEpisodeTitle = episodeInfo.Title; if ( // Movies (seasonInfo.Type == SeriesType.Movie && episodeInfo.Type is EpisodeType.Episode or EpisodeType.Special) || // All other ignored types. ( episodeInfo.Type is EpisodeType.Episode && episodeInfo.EpisodeNumber == 1 && episodeInfo.Titles.FirstOrDefault(title => title.Source is "AniDB" && title.LanguageCode is "en")?.Value is { } mainTitle && TextUtility.IgnoredSubTitles.Contains(mainTitle) && TextUtility.GetEpisodeTitles(episodeInfo, seasonInfo, metadataLanguage) is { } episodeTitles && string.IsNullOrEmpty(episodeTitles.displayTitle) ) ) (displayTitle, alternateTitle) = TextUtility.GetMovieTitles(episodeInfo, seasonInfo, metadataLanguage); else (displayTitle, alternateTitle) = TextUtility.GetEpisodeTitles(episodeInfo, seasonInfo, metadataLanguage); if (string.IsNullOrEmpty(displayTitle)) displayTitle = episodeInfo.Type switch { EpisodeType.Special => $"Special {episodeNumber}", _ => $"Episode {episodeNumber}", }; description = TextUtility.GetEpisodeDescription(episodeInfo, seasonInfo, metadataLanguage); } if (isSpecial && config.MarkSpecialsWhenGrouped) { // We're guaranteed to find the index, because otherwise it would've thrown when getting the episode number. var index = seasonInfo.SpecialsList.FindIndex(ep => ep == episodeInfo); displayTitle = $"S{index + 1} {displayTitle}"; alternateTitle = $"S{index + 1} {alternateTitle}"; } Episode result; if (season is not null) { result = new Episode { Name = displayTitle ?? $"Episode {episodeNumber}", 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 = episodeInfo.CommunityRating.Value > 0 ? episodeInfo.CommunityRating.ToFloat(10) : 0, PremiereDate = episodeInfo.AiredAt, SeriesName = season.Series.Name, SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, SeasonName = season.Name, ProductionLocations = TagFilter.GetProductionLocations(episodeInfo), OfficialRating = ContentRating.GetContentRating(episodeInfo, metadataCountryCode), DateLastSaved = DateTime.UtcNow, RunTimeTicks = episodeInfo.Runtime?.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 = episodeInfo.AiredAt, Overview = description, ProductionLocations = TagFilter.GetProductionLocations(episodeInfo), OfficialRating = ContentRating.GetContentRating(episodeInfo, metadataCountryCode), CustomRating = showInfo.CustomRating, CommunityRating = episodeInfo.CommunityRating.Value > 0 ? episodeInfo.CommunityRating.ToFloat(10) : 0, }; } if (fileInfo is not null && fileInfo.EpisodeList.Count > 1) { var episodeNumberEnd = episodeNumber + fileInfo.EpisodeList.Count - 1; if (episodeNumberEnd != episodeNumber && episodeInfo.EpisodeNumber != episodeNumberEnd) result.IndexNumberEnd = episodeNumberEnd; } if (fileInfo is not null) { result.SetProviderId(ShokoInternalId.Name, fileInfo.InternalId); result.SetProviderId(ProviderNames.Shoko, ShokoExternalUrlHandler.GetFileInfoUrls(fileInfo)); result.SetProviderId(ProviderNames.ShokoFile, fileInfo.Id); result.SetProviderId(ProviderNames.ShokoSeries, fileInfo.SeriesId); } else { result.SetProviderId(ShokoInternalId.Name, episodeInfo.InternalId); result.SetProviderId(ProviderNames.Shoko, ShokoExternalUrlHandler.GetEpisodeInfoUrls(episodeInfo)); } result.SetProviderId(ProviderNames.ShokoEpisode, episodeInfo.Id); if (config.AddAniDBId && episodeInfo.AnidbEpisodeId is { Length: > 0 } anidbEpisodeId) result.SetProviderId(ProviderNames.Anidb, anidbEpisodeId); if (config.AddTMDBId && episodeInfo.TmdbEpisodeId is { Length: > 0 } tmdbEpisodeId) result.SetProviderId(MetadataProvider.Tmdb, tmdbEpisodeId); if (config.AddTvDBId && episodeInfo.TvdbEpisodeId is { Length: > 0 } tvdbEpisodeId) result.SetProviderId(MetadataProvider.Tvdb, tvdbEpisodeId); return result; } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } ================================================ FILE: Shokofin/Providers/ImageProvider.cs ================================================ using System; using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; 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.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; using Shokofin.Web; namespace Shokofin.Providers; public class ImageProvider(IHttpClientFactory _httpClientFactory, ILogger<ImageProvider> _logger, ShokoApiManager _apiManager, ShokoIdLookup _lookup) : IRemoteImageProvider, IHasOrder { public string Name => Plugin.MetadataProviderName; public int Order => 0; public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var isRequest = ImageHostUrl.CurrentItemId is { } currentItemId && currentItemId == item.Id; var displayMode = !Plugin.Instance.Configuration.Image.DebugMode && isRequest; var list = new List<RemoteImageInfo>(); var metadataLanguage = item.GetPreferredMetadataLanguage(); 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: { var (fileInfo, seasonInfo, _) = await _apiManager.GetFileInfoByPath(episode.Path).ConfigureAwait(false); if (fileInfo is not { EpisodeList.Count: > 0 } || seasonInfo is null) break; var episodeInfo = fileInfo.EpisodeList[0].Episode; var images = await ImageUtility.GetEpisodeImages(episodeInfo, seasonInfo, metadataLanguage, displayMode, cancellationToken).ConfigureAwait(false); list.AddRange(images); _logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId},Language={MetadataLanguage})", list.Count, episode.Name, episodeInfo.Id, metadataLanguage); break; } case Series series: { if (!_lookup.TryGetSeasonIdFor(series, out var seasonId) || await _apiManager.GetShowInfoBySeasonId(seasonId).ConfigureAwait(false) is not { } showInfo) break; var images = await ImageUtility.GetShowImages(showInfo, metadataLanguage, displayMode, cancellationToken).ConfigureAwait(false); list.AddRange(images); _logger.LogInformation("Getting {Count} images for series {SeriesName} (MainSeason={MainSeasonId},Language={MetadataLanguage})", list.Count, series.Name, seasonId, metadataLanguage); break; } // Per a user request, we'll allow getting images for the "Specials" season when the client requests them. case Season { IndexNumber: 0 } season when isRequest: { return await GetImages(season.Series, cancellationToken).ConfigureAwait(false); } case Season { IndexNumber: > 0 } season: { if (!_lookup.TryGetSeasonIdFor(season, out var seasonId) || await _apiManager.GetSeasonInfo(seasonId).ConfigureAwait(false) is not { } seasonInfo) break; var images = await ImageUtility.GetSeasonImages(seasonInfo, metadataLanguage, displayMode, cancellationToken).ConfigureAwait(false); list.AddRange(images); _logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Season={SeasonId},Language={MetadataLanguage})", list.Count, season.IndexNumber, season.SeriesName, seasonId, metadataLanguage); break; } case Movie movie: { var (fileInfo, seasonInfo, _) = await _apiManager.GetFileInfoByPath(movie.Path).ConfigureAwait(false); if (fileInfo is not { EpisodeList.Count: > 0 } || seasonInfo is null) break; var episodeInfo = fileInfo.EpisodeList[0].Episode; var images = await ImageUtility.GetMovieImages(episodeInfo, seasonInfo, metadataLanguage, displayMode, cancellationToken).ConfigureAwait(false); list.AddRange(images); _logger.LogInformation("Getting {Count} images for movie {MovieName} (Episode={EpisodeId},Language={MetadataLanguage})", list.Count, movie.Name, episodeInfo.Id, metadataLanguage); break; } case BoxSet collection: { string? collectionId = null; if (collection.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForSeries, out var seasonId)) { if (await _apiManager.GetSeasonInfo(seasonId).ConfigureAwait(false) is not { } seasonInfo) break; var images = await ImageUtility.GetCollectionImages(seasonInfo, metadataLanguage, displayMode, cancellationToken).ConfigureAwait(false); list.AddRange(images); } else if (collection.Path.TryGetAttributeValue(ProviderNames.ShokoCollectionForGroup, out collectionId)) { if ( await _apiManager.GetCollectionInfo(collectionId).ConfigureAwait(false) is not { } collectionInfo || string.IsNullOrEmpty(collectionInfo.MainSeasonId) || await _apiManager.GetShowInfoBySeasonId(collectionInfo.MainSeasonId).ConfigureAwait(false) is not { } showInfo ) break; var images = await ImageUtility.GetCollectionImages(showInfo, metadataLanguage, displayMode, cancellationToken).ConfigureAwait(false); list.AddRange(images); } _logger.LogInformation("Getting {Count} images for collection {CollectionName} (Collection={CollectionId},Season={SeasonId},Language={MetadataLanguage})", list.Count, collection.Name, collectionId, collectionId is null ? seasonId : null, metadataLanguage); break; } } return list; } catch (Exception ex) { _logger.LogError(ex, "Threw unexpectedly for {BaseKind} {Name}; {Message}", baseKind, item.Name, ex.Message); return list; } finally { Plugin.Instance.Tracker.Remove(trackerId); } } public IEnumerable<ImageType> GetSupportedImages(BaseItem item) => [ImageType.Primary, ImageType.Backdrop, ImageType.Logo]; 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("Shokofin/Host"); if (index is -1) return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); url = $"{Plugin.Instance.Configuration.Url}/api/v3{url[(index + 13)..]}"; return await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } } ================================================ FILE: Shokofin/Providers/MovieProvider.cs ================================================ using System; using System.Collections.Generic; 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(IHttpClientFactory _httpClientFactory, ILogger<MovieProvider> _logger, ShokoApiManager _apiManager) : IRemoteMetadataProvider<Movie, MovieInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; public int Order => 0; 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 (fileInfo, seasonInfo, _) = await _apiManager.GetFileInfoByPath(info.Path).ConfigureAwait(false); var episodeInfo = fileInfo is { EpisodeList.Count: > 0 } ? fileInfo.EpisodeList[0].Episode : null; if (fileInfo == null || episodeInfo == null || seasonInfo == null) { _logger.LogWarning("Unable to find movie info for path {Path}", info.Path); return result; } var (displayTitle, alternateTitle) = TextUtility.GetMovieTitles(episodeInfo, seasonInfo, info.MetadataLanguage); if (string.IsNullOrEmpty(displayTitle)) displayTitle = episodeInfo.Id[0] == IdPrefix.TmdbMovie ? episodeInfo.Title : TextUtility.IgnoredSubTitles.Contains(episodeInfo.Title) ? seasonInfo.Title : $"{seasonInfo.Title}: {episodeInfo.Title}"; var rating = seasonInfo.IsMultiEntry ? episodeInfo.CommunityRating.ToFloat(10) : seasonInfo.CommunityRating.ToFloat(10); _logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Season={SeasonId},ExtraSeasons={ExtraIds})", displayTitle, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); result.Item = new Movie { Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episodeInfo.AiredAt, Overview = TextUtility.GetMovieDescription(episodeInfo, seasonInfo, info.MetadataLanguage), ProductionYear = episodeInfo.AiredAt?.Year, Tags = [.. episodeInfo.Tags], Genres = [.. episodeInfo.Genres], Studios = [.. episodeInfo.Studios], ProductionLocations = TagFilter.GetProductionLocations(episodeInfo), OfficialRating = ContentRating.GetContentRating(episodeInfo, info.MetadataCountryCode), CommunityRating = rating, }; result.Item.SetProviderId(ShokoInternalId.Name, fileInfo.InternalId); result.Item.SetProviderId(ProviderNames.Shoko, ShokoExternalUrlHandler.GetFileInfoUrls(fileInfo)); result.Item.SetProviderId(ProviderNames.ShokoFile, fileInfo.Id); result.Item.SetProviderId(ProviderNames.ShokoSeries, fileInfo.SeriesId); result.Item.SetProviderId(ProviderNames.ShokoEpisode, episodeInfo.Id); if (Plugin.Instance.Configuration.AddAniDBId && seasonInfo.AnidbAnimeId is { Length: > 0 } anidbAnimeId) result.Item.SetProviderId(ProviderNames.Anidb, anidbAnimeId); if (Plugin.Instance.Configuration.AddTMDBId && episodeInfo.TmdbMovieId is { Length: > 0 } tmdbMovieId) result.Item.SetProviderId(MetadataProvider.Tmdb, tmdbMovieId); result.HasMetadata = true; result.ResetPeople(); foreach (var person in episodeInfo.Staff) result.AddPerson(person); return result; } catch (Exception ex) { _logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, 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>>([]); public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } ================================================ FILE: Shokofin/Providers/SeasonProvider.cs ================================================ 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.Extensions; using Shokofin.ExternalIds; using Shokofin.Utils; using Info = Shokofin.API.Info; namespace Shokofin.Providers; public class SeasonProvider(IHttpClientFactory _httpClientFactory, ILogger<SeasonProvider> _logger, ShokoApiManager _apiManager) : IRemoteMetadataProvider<Season, SeasonInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; public int Order => 0; 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 "AA" to make it // always appear first in the UI. var seasonName = info.Name; result.Item = new Season { Name = seasonName, IndexNumber = info.IndexNumber, SortName = $"AA - {seasonName}", ForcedSortName = $"AA - {seasonName}", }; result.HasMetadata = true; return result; } if (!info.TryGetSeasonId(out var seasonId)) { _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=\"{seasonId}\",Season={seasonNumber})"); try { var showInfo = await _apiManager.GetShowInfoBySeasonId(seasonId).ConfigureAwait(false); if (showInfo == null) { _logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (MainSeason={MainSeasonId})", seasonNumber, seasonId); 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}. (MainSeason={MainSeasonId})", seasonNumber, seasonId); return result; } _logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (MainSeason={MainSeasonId})", seasonNumber, showInfo.Title, seasonId); var offset = Math.Abs(seasonNumber - baseSeasonNumber); result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage, info.MetadataCountryCode); result.HasMetadata = true; result.ResetPeople(); foreach (var person in seasonInfo.Staff) result.AddPerson(person); return result; } catch (Exception ex) { _logger.LogError(ex, "Threw unexpectedly while refreshing season {SeasonNumber}; {Message} (Path={Path},MainSeason={MainSeasonId})", info.IndexNumber, ex.Message, info.Path, seasonId); return new MetadataResult<Season>(); } finally { Plugin.Instance.Tracker.Remove(trackerId); } } public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, string metadataCountryCode) => CreateMetadata(seasonInfo, seasonNumber, offset, metadataLanguage, metadataCountryCode, 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.GetPreferredMetadataCountryCode(), series, seasonId); private static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, string metadataCountryCode, Series? series, Guid seasonId) { var (displayTitle, alternateTitle) = TextUtility.GetSeasonTitles(seasonInfo, offset, metadataLanguage); if (string.IsNullOrEmpty(displayTitle)) displayTitle = $"Season {seasonNumber}"; var sortTitle = $"S{seasonNumber} - {seasonInfo.Title}"; Season season; if (series != null) { season = new Season { Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = seasonNumber, SortName = sortTitle, ForcedSortName = sortTitle, Id = seasonId, IsVirtualItem = true, Overview = TextUtility.GetSeasonDescription(seasonInfo, metadataLanguage), PremiereDate = seasonInfo.PremiereDate, EndDate = seasonInfo.EndDate, ProductionYear = seasonInfo.PremiereDate?.Year, Tags = seasonInfo.Tags.ToArray(), Genres = seasonInfo.Genres.ToArray(), Studios = seasonInfo.Studios.ToArray(), ProductionLocations = TagFilter.GetProductionLocations(seasonInfo), OfficialRating = ContentRating.GetContentRating(seasonInfo, metadataCountryCode), CommunityRating = seasonInfo.CommunityRating.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 = TextUtility.GetSeasonDescription(seasonInfo, metadataLanguage), PremiereDate = seasonInfo.PremiereDate, EndDate = seasonInfo.EndDate, ProductionYear = seasonInfo.PremiereDate?.Year, Tags = seasonInfo.Tags.ToArray(), Genres = seasonInfo.Genres.ToArray(), Studios = seasonInfo.Studios.ToArray(), ProductionLocations = TagFilter.GetProductionLocations(seasonInfo), OfficialRating = ContentRating.GetContentRating(seasonInfo, metadataCountryCode), CommunityRating = seasonInfo.CommunityRating?.ToFloat(10), }; } season.SetProviderId(ShokoInternalId.Name, seasonInfo.InternalId); season.SetProviderId(ProviderNames.Shoko, ShokoExternalUrlHandler.GetSeasonInfoUrls(seasonInfo)); if (seasonInfo.ShokoSeriesId is { Length: > 0 } shokoSeriesId) season.SetProviderId(ProviderNames.ShokoSeries, shokoSeriesId); if (Plugin.Instance.Configuration.AddAniDBId && seasonInfo.AnidbAnimeId is { Length: > 0 } anidbAnimeId) season.SetProviderId(ProviderNames.Anidb, anidbAnimeId); return season; } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } ================================================ FILE: Shokofin/Providers/SeriesProvider.cs ================================================ 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(IHttpClientFactory _httpClientFactory, ILogger<SeriesProvider> _logger, ShokoApiManager _apiManager, IFileSystem _fileSystem) : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; public int Order => 0; 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 showInfo = await _apiManager.GetShowInfoByPath(info.Path).ConfigureAwait(false); if (showInfo == 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) { showInfo = await _apiManager.GetShowInfoByPath(entry.FullName).ConfigureAwait(false); if (showInfo is not null) break; } if (showInfo == null) { _logger.LogWarning("Unable to find show info for path {Path}", info.Path); return result; } } catch (DirectoryNotFoundException) { return result; } } var (displayTitle, alternateTitle) = TextUtility.GetShowTitles(showInfo, info.MetadataLanguage); if (string.IsNullOrEmpty(displayTitle)) displayTitle = showInfo.Title; var premiereDate = showInfo.PremiereDate; var endDate = showInfo.EndDate; result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, Overview = TextUtility.GetShowDescription(showInfo, info.MetadataLanguage), PremiereDate = premiereDate, AirDays = showInfo.DaysOfWeek.ToArray(), ProductionYear = premiereDate?.Year, EndDate = endDate, Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, Tags = showInfo.Tags.ToArray(), Genres = showInfo.Genres.ToArray(), Studios = showInfo.Studios.ToArray(), ProductionLocations = TagFilter.GetProductionLocations(showInfo), OfficialRating = ContentRating.GetContentRating(showInfo, info.MetadataCountryCode), CustomRating = showInfo.CustomRating, CommunityRating = showInfo.CommunityRating, }; result.HasMetadata = true; result.ResetPeople(); foreach (var person in showInfo.Staff) result.AddPerson(person); var config = Plugin.Instance.Configuration; result.Item.SetProviderId(ShokoInternalId.Name, showInfo.InternalId); result.Item.SetProviderId(ProviderNames.Shoko, ShokoExternalUrlHandler.GetShowInfoUrls(showInfo)); if (showInfo.ShokoSeriesId is { Length: > 0 } shokoSeriesId) result.Item.SetProviderId(ProviderNames.ShokoSeries, shokoSeriesId); if (showInfo.ShokoGroupId is { Length: > 0 } shokoGroupId) result.Item.SetProviderId(ProviderNames.ShokoGroup, shokoGroupId); if (config.AddAniDBId && showInfo.AnidbAnimeId is { Length: > 0 } anidbAnimeId) result.Item.SetProviderId(ProviderNames.Anidb, anidbAnimeId); if (config.AddTMDBId && showInfo.TmdbShowId is { Length: > 0 } tmdbShowId) result.Item.SetProviderId(ProviderNames.Tmdb, tmdbShowId); if (config.AddTvDBId && showInfo.TvdbShowId is { Length: > 0 } tvdbShowId) result.Item.SetProviderId(MetadataProvider.Tvdb, tvdbShowId); _logger.LogInformation("Found series {SeriesName} (MainSeason={MainSeasonId})", displayTitle, showInfo.Id); return result; } catch (Exception ex) { _logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, ex.Message); return new MetadataResult<Series>(); } finally { Plugin.Instance.Tracker.Remove(trackerId); } } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } ================================================ FILE: Shokofin/Providers/TrailerProvider.cs ================================================ using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; 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 TrailerProvider(IHttpClientFactory _httpClientFactory, ILogger<TrailerProvider> _logger, ShokoApiManager _apiManager) : IRemoteMetadataProvider<Trailer, TrailerInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; // Always run first, so we can react to the VFS entries. public int Order => -1; 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).ConfigureAwait(false); var episodeInfo = fileInfo is { EpisodeList.Count: > 0 } ? fileInfo.EpisodeList[0].Episode : null; 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) = TextUtility.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); if (string.IsNullOrEmpty(displayTitle)) displayTitle = episodeInfo.Title; var description = TextUtility.GetEpisodeDescription(episodeInfo, seasonInfo, info.MetadataLanguage); result.Item = new() { Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episodeInfo.AiredAt, ProductionYear = episodeInfo.AiredAt?.Year ?? seasonInfo.PremiereDate?.Year, Overview = description, CommunityRating = episodeInfo.CommunityRating.Value > 0 ? episodeInfo.CommunityRating.ToFloat(10) : 0, }; _logger.LogInformation("Found trailer {EpisodeName} (File={FileId},Episode={EpisodeId},Season={SeasonId},ExtraSeasons={ExtraIds})", result.Item.Name, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); result.Item.SetProviderId(ShokoInternalId.Name, fileInfo.InternalId); result.Item.SetProviderId(ProviderNames.Shoko, ShokoExternalUrlHandler.GetFileInfoUrls(fileInfo)); result.Item.SetProviderId(ProviderNames.ShokoFile, fileInfo.Id); result.Item.SetProviderId(ProviderNames.ShokoEpisode, episodeInfo.Id); result.Item.SetProviderId(ProviderNames.ShokoSeries, fileInfo.SeriesId); result.HasMetadata = true; return result; } catch (Exception ex) { _logger.LogError(ex, "Threw unexpectedly while refreshing path {Path}; {Message}", info.Path, 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>>([]); public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } ================================================ FILE: Shokofin/Providers/VideoProvider.cs ================================================ using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; 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 VideoProvider(IHttpClientFactory _httpClientFactory, ILogger<VideoProvider> _logger, ShokoApiManager _apiManager) : IRemoteMetadataProvider<Video, ItemLookupInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; // Always run first, so we can react to the VFS entries. public int Order => -1; 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).ConfigureAwait(false); var episodeInfo = fileInfo is { EpisodeList.Count: > 0 } ? fileInfo.EpisodeList[0].Episode : null; 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) = TextUtility.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); if (string.IsNullOrEmpty(displayTitle)) displayTitle = episodeInfo.Title; var description = TextUtility.GetEpisodeDescription(episodeInfo, seasonInfo, info.MetadataLanguage); result.Item = new() { Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episodeInfo.AiredAt, ProductionYear = episodeInfo.AiredAt?.Year ?? seasonInfo.PremiereDate?.Year, Overview = description, CommunityRating = episodeInfo.CommunityRating.Value > 0 ? episodeInfo.CommunityRating.ToFloat(10) : 0, }; _logger.LogInformation("Found video {EpisodeName} (File={FileId},Episode={EpisodeId},Season={SeasonId},ExtraSeasons={ExtraIds})", result.Item.Name, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); result.Item.SetProviderId(ShokoInternalId.Name, fileInfo.InternalId); result.Item.SetProviderId(ProviderNames.Shoko, ShokoExternalUrlHandler.GetFileInfoUrls(fileInfo)); result.Item.SetProviderId(ProviderNames.ShokoFile, fileInfo.Id); result.Item.SetProviderId(ProviderNames.ShokoEpisode, episodeInfo.Id); result.Item.SetProviderId(ProviderNames.ShokoSeries, fileInfo.SeriesId); result.HasMetadata = true; result.ResetPeople(); foreach (var person in episodeInfo.Staff) result.AddPerson(person); return result; } catch (Exception ex) { _logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, 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>>([]); public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => await _httpClientFactory.CreateClient().GetAsync(url, cancellationToken).ConfigureAwait(false); } ================================================ FILE: Shokofin/Resolvers/Models/LinkGenerationResult.cs ================================================ 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; set; } = []; public ConcurrentBag<string> RemovedPaths { get; set; } = []; public int Total => TotalVideos + TotalExternalFiles + TotalTrickplayDirectories; public int Created => CreatedVideos + CreatedExternalFiles + CreatedTrickplayDirectories; public int Fixed => FixedVideos + FixedExternalFiles + FixedTrickplayDirectories; public int Skipped => SkippedVideos + SkippedExternalFiles + SkippedTrickplayDirectories; public int Removed => RemovedVideos + RemovedExternalFiles + RemovedNfos + RemovedTrickplayDirectories; 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 TotalExternalFiles => CreatedExternalFiles + FixedExternalFiles + SkippedExternalFiles; public int CreatedExternalFiles { get; set; } public int FixedExternalFiles { get; set; } public int SkippedExternalFiles { get; set; } public int RemovedExternalFiles { get; set; } public int TotalTrickplayDirectories => CreatedTrickplayDirectories + FixedTrickplayDirectories + SkippedTrickplayDirectories; public int CreatedTrickplayDirectories { get; set; } public int FixedTrickplayDirectories { get; set; } public int SkippedTrickplayDirectories { get; set; } public int RemovedTrickplayDirectories { get; set; } public int RemovedNfos { get; set; } public void Print(ILogger logger, string path) { var timeSpent = DateTime.Now - CreatedAt; logger.LogInformation( "Created {CreatedTotal} ({CreatedMedia},{CreatedExternal},{CreatedTrickplay}), fixed {FixedTotal} ({FixedMedia},{FixedExternal},{FixedTrickplay}), skipped {SkippedTotal} ({SkippedMedia},{SkippedExternal},{SkippedTrickplay}), and removed {RemovedTotal} ({RemovedMedia},{RemovedExternal},{RemovedTrickplay},{RemovedNFO}) entries in folder at {Path} in {TimeSpan} (Total={Total})", Created, CreatedVideos, CreatedExternalFiles, CreatedTrickplayDirectories, Fixed, FixedVideos, FixedExternalFiles, FixedTrickplayDirectories, Skipped, SkippedVideos, SkippedExternalFiles, SkippedTrickplayDirectories, Removed, RemovedVideos, RemovedExternalFiles, RemovedTrickplayDirectories, 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) paths.Add(path); var removedPaths = a.RemovedPaths; foreach (var path in b.RemovedPaths) removedPaths.Add(path); return new() { CreatedAt = a.CreatedAt, Paths = paths, RemovedPaths = removedPaths, CreatedVideos = a.CreatedVideos + b.CreatedVideos, FixedVideos = a.FixedVideos + b.FixedVideos, SkippedVideos = a.SkippedVideos + b.SkippedVideos, RemovedVideos = a.RemovedVideos + b.RemovedVideos, CreatedExternalFiles = a.CreatedExternalFiles + b.CreatedExternalFiles, FixedExternalFiles = a.FixedExternalFiles + b.FixedExternalFiles, SkippedExternalFiles = a.SkippedExternalFiles + b.SkippedExternalFiles, RemovedExternalFiles = a.RemovedExternalFiles + b.RemovedExternalFiles, CreatedTrickplayDirectories = a.CreatedTrickplayDirectories + b.CreatedTrickplayDirectories, FixedTrickplayDirectories = a.FixedTrickplayDirectories + b.FixedTrickplayDirectories, SkippedTrickplayDirectories = a.SkippedTrickplayDirectories + b.SkippedTrickplayDirectories, RemovedTrickplayDirectories = a.RemovedTrickplayDirectories + b.RemovedTrickplayDirectories, RemovedNfos = a.RemovedNfos + b.RemovedNfos, }; } } ================================================ FILE: Shokofin/Resolvers/Models/ShokoWatcher.cs ================================================ using System; using System.IO; using Shokofin.Configuration; namespace Shokofin.Resolvers.Models; public class ShokoWatcher(MediaFolderConfiguration configuration, FileSystemWatcher watcher, IDisposable lease) { public MediaFolderConfiguration Configuration = configuration; public FileSystemWatcher Watcher = watcher; public IDisposable SubmitterLease = lease; } ================================================ FILE: Shokofin/Resolvers/ShokoIgnoreRule.cs ================================================ 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 ShokoIdLookup 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, ShokoIdLookup 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 (Plugin.Instance.AllVirtualRoots.Any(fileInfo.FullName.StartsWith)) 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)) return false; // Don't even bother continuing if the file system entry is matching one of the ignore patterns. if (IgnorePatterns.ShouldIgnore(fileInfo.FullName)) { Logger.LogTrace("Skipped ignored path {Path}", fileInfo.FullName); return true; } 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 (libraryConfig, mediaFolderConfig) = await ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder).ConfigureAwait(false); if (libraryConfig is null || mediaFolderConfig is null || !mediaFolderConfig.IsMapped) { Logger.LogDebug("Skipped media folder for path {Path} (MediaFolder={MediaFolderId})", fileInfo.FullName, mediaFolder.Id); 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 (libraryConfig.IsVirtualFileSystemEnabled) return true; var shouldIgnore = libraryConfig.LibraryOperationMode is not Ordering.LibraryOperationMode.Lax; 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} (Season={SeasonId},ExtraSeries={ExtraIds})", season.Title, 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. (Season={SeasonId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); return true; } break; case CollectionType.movies: if (!isMovieSeason && Plugin.Instance.Configuration.FilterMovieLibraries) { Logger.LogInformation("Found show in movie library, ignoring shoko series. (Season={SeasonId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); return true; } break; } var show = await ApiManager.GetShowInfoBySeasonId(season.Id).ConfigureAwait(false)!; if (show 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; } if (!show.IsAvailable) { if (shouldIgnore) Logger.LogInformation("Ignored folder at path {Path}", partialPath); else Logger.LogWarning("Skipped folder at path {Path}", partialPath); return shouldIgnore; } Logger.LogInformation("Found show {SeriesName} (MainSeason={MainSeasonId},Season={SeasonId},ExtraSeries={ExtraIds})", show.Title, show.Id, season.Id, season.ExtraIds); return false; } private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, bool shouldIgnore) { var (file, season, show) = 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 || show 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; } if (!show.IsAvailable) { if (shouldIgnore) Logger.LogInformation("Ignored folder at path {Path}", partialPath); else Logger.LogWarning("Skipped folder at path {Path}", partialPath); return shouldIgnore; } Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Season={SeasonId},ExtraSeries={ExtraIds},File={FileId})", file.EpisodeList.Count, season.Title, 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. (Season={SeasonId},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 } ================================================ FILE: Shokofin/Resolvers/ShokoLibraryMonitor.cs ================================================ 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 MediaBrowser.Model.Entities; 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; 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.LibraryConfigurationAdded += OnLibraryConfigurationAddedOrChanged; ConfigurationService.LibraryConfigurationChanged += OnLibraryConfigurationAddedOrChanged; ConfigurationService.LibraryConfigurationRemoved += OnLibraryConfigurationRemoved; ConfigurationService.MediaFolderConfigurationAdded += OnMediaFolderConfigurationAdded; ConfigurationService.MediaFolderConfigurationRemoved += 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.LibraryConfigurationAdded -= OnLibraryConfigurationAddedOrChanged; ConfigurationService.LibraryConfigurationChanged -= OnLibraryConfigurationAddedOrChanged; ConfigurationService.LibraryConfigurationRemoved -= OnLibraryConfigurationRemoved; ConfigurationService.MediaFolderConfigurationAdded -= OnMediaFolderConfigurationAdded; ConfigurationService.MediaFolderConfigurationRemoved -= 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 libraryConfig in Plugin.Instance.Configuration.Libraries.ToList()) { if ( !libraryConfig.IsVirtualFileSystemEnabled || LibraryManager.GetItemById(libraryConfig.Id) is not Folder libraryFolder || LibraryManager.GetLibraryOptions(libraryFolder) is not { } libraryOptions || !libraryOptions.EnableRealtimeMonitor ) continue; foreach (var mediaConfig in libraryConfig.MediaFolders) StartWatchingMediaFolder(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 OnLibraryConfigurationAddedOrChanged(object? sender, LibraryConfigurationChangedEventArgs eventArgs) { // Don't add/remove watchers during a scan. if (LibraryScanWatcher.IsScanRunning) return; if ( eventArgs.LibraryConfiguration.IsVirtualFileSystemEnabled && LibraryManager.GetItemById(eventArgs.LibraryConfiguration.Id) is Folder libraryFolder && LibraryManager.GetLibraryOptions(libraryFolder) is { } libraryOptions && libraryOptions.EnableRealtimeMonitor ) { foreach (var mediaConfig in eventArgs.MediaFolderConfigurations) StartWatchingMediaFolder(mediaConfig); } else { foreach (var mediaConfig in eventArgs.MediaFolderConfigurations) StopWatchingPath(mediaConfig.Path); } } private void OnLibraryConfigurationRemoved(object? sender, LibraryConfigurationChangedEventArgs eventArgs) { // Don't add/remove watchers during a scan. if (LibraryScanWatcher.IsScanRunning) return; foreach (var mediaConfig in eventArgs.MediaFolderConfigurations) StopWatchingPath(mediaConfig.Path); } private void OnMediaFolderConfigurationAdded(object? sender, MediaConfigurationChangedEventArgs eventArgs) { // Don't add/remove watchers during a scan. if (LibraryScanWatcher.IsScanRunning) return; if ( eventArgs.LibraryConfiguration.IsVirtualFileSystemEnabled && LibraryManager.GetItemById(eventArgs.LibraryConfiguration.Id) is Folder libraryFolder && LibraryManager.GetLibraryOptions(libraryFolder) is { } libraryOptions && libraryOptions.EnableRealtimeMonitor ) StartWatchingMediaFolder(eventArgs.MediaFolderConfiguration); else StopWatchingPath(eventArgs.MediaFolderConfiguration.Path); } private void OnMediaFolderConfigurationRemoved(object? sender, MediaConfigurationChangedEventArgs eventArgs) { // Don't add/remove watchers during a scan. if (LibraryScanWatcher.IsScanRunning) return; StopWatchingPath(eventArgs.MediaFolderConfiguration.Path); } private void StartWatchingMediaFolder(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(config.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(config.Path, new(config, watcher, lease))) { LibraryMonitor.ReportFileSystemChangeBeginning(config.Path); watcher.EnableRaisingEvents = true; Logger.LogInformation("Watching directory {Path}", config.Path); } else { lease.Dispose(); DisposeWatcher(watcher, false); } } catch (Exception ex) { Logger.LogError(ex, "Error watching path: {Path}", config.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.Path)) { 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; FileEventArgsStub eventArgs; var reason = changeTypes is WatcherChangeTypes.Deleted ? ( UpdateReason.MetadataRemoved ) : changeTypes is WatcherChangeTypes.Created ? ( UpdateReason.MetadataAdded ) : ( UpdateReason.MetadataUpdated ); var relativePath = path[mediaConfig.Path.Length..]; using (Plugin.Instance.Tracker.Enter($"Library Monitor: Path=\"{path}\"")) { var files = await ApiClient.GetFileByPath(relativePath).ConfigureAwait(false); var file0 = files.FirstOrDefault(file => file.Locations.Any(location => location.ManagedFolderId == mediaConfig.ManagedFolderId && location.RelativePath == mediaConfig.ManagedFolderRelativePath + relativePath)); if (file0 is not null) { var fileLocation = file0.Locations.First(location => location.ManagedFolderId == mediaConfig.ManagedFolderId && location.RelativePath == mediaConfig.ManagedFolderRelativePath + relativePath); eventArgs = new FileEventArgsStub(fileLocation, file0); } else if (reason is not UpdateReason.MetadataRemoved) { Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); return null; } else 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; } else if (!video.TryGetProviderId(ProviderNames.ShokoFile, out fileId)) { Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); return null; } else if (await ApiClient.GetFile(fileId).ConfigureAwait(false) is { } file1) { var fileLocation = file1.Locations.First(location => location.ManagedFolderId == mediaConfig.ManagedFolderId && location.RelativePath == mediaConfig.ManagedFolderRelativePath + relativePath); eventArgs = new FileEventArgsStub(fileLocation, file1); } else { 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.ManagedFolderId, relativePath, []); } } Logger.LogDebug( "File {EventName}; {ManagedFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", reason, eventArgs.ManagedFolderId, 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.ManagedFolderId, relativePath, eventArgs); return eventArgs; } ).ConfigureAwait(false); } private bool IsVideoFile(string path) => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path)); } ================================================ FILE: Shokofin/Resolvers/ShokoResolver.cs ================================================ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; 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.Extensions; 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 ShokoIdLookup 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, ShokoIdLookup 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, CancellationToken cancellationToken = default) { 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, collectionType, fileInfo.FullName, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) return null; 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, CancellationToken cancellationToken = default) { if (!(collectionType is CollectionType.tvshows or CollectionType.movies or null) || parent is null) return new(); var root = LibraryManager.RootFolder; if (root is null || parent == root) return new(); Guid? trackerId = null; try { if (!Lookup.IsEnabledForItem(parent)) return new(); if (parent.GetTopParent() is not Folder mediaFolder) return new(); trackerId = Plugin.Instance.Tracker.Add($"Resolve children of \"{parent.Path}\". (Children={fileInfoList.Count})"); var (vfsPath, shouldContinue, skipValidation, paths) = await ResolveManager.GenerateStructureInVFS(mediaFolder, collectionType, parent.Path, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) return new(); // 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.DirectoryExists(vfsPath) ? FileSystem.GetDirectories(vfsPath) : []) .AsParallel() .SelectMany(dirInfo => { if (!dirInfo.Name.TryGetAttributeValue(ProviderNames.ShokoSeries, out var seasonId)) return []; // We have an id, but the path does not belong to the generated set of paths. var episodeId = (string?)null; if (!paths.Contains(dirInfo.FullName)) { // If we've been asked to skip validation, then just iterate it as-is, otherwise mark it for removal. if (skipValidation) { if (dirInfo.Name.TryGetAttributeValue(ProviderNames.ShokoEpisode, out episodeId)) { if (collectionType is CollectionType.tvshows) { pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } 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 var fileId, out var seriesId)) return null; return new Movie() { Path = fileInfo.FullName, } as BaseItem; }) .ToArray(); } return [ new TvSeries() { Path = dirInfo.FullName, }, ]; } pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } var season = ApiManager.GetSeasonInfo(seasonId) .ConfigureAwait(false) .GetAwaiter() .GetResult(); if (season is null) { pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } if (dirInfo.Name.TryGetAttributeValue(ProviderNames.ShokoEpisode, out episodeId)) { var episode = ApiManager.GetEpisodeInfo(episodeId) .ConfigureAwait(false) .GetAwaiter() .GetResult(); if (episode is null || !episode.IsAvailable) { pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } if (episode.SeasonId != seasonId) { pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } if (!(createMovies && (season.Type is SeriesType.Movie || collectionType is CollectionType.movies && !Plugin.Instance.Configuration.FilterMovieLibraries))) { pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } 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 var fileId, out var seriesId)) 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(); } var show = ApiManager.GetShowInfoBySeasonId(seasonId) .ConfigureAwait(false) .GetAwaiter() .GetResult(); if (show is null || !show.IsAvailable) { pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } if (seasonId != show.Id) { pathsToRemoveBag.Add((dirInfo.FullName, true)); return []; } return [ new TvSeries() { Path = dirInfo.FullName, }, ]; }) .WhereNotNull() .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 {Path}", pathToRemove); } } var deltaTime = DateTime.Now - start; Logger.LogDebug("Cleaned up {Count} removed entries in {Time}", pathsToRemove.Count, deltaTime); } var keepFile = Path.Join(vfsPath, ".keep"); var keepFileExists = File.Exists(keepFile); var isEmpty = !ResolveManager.GetFileSystemEntryPaths(vfsPath).Except([keepFile]).Any(); if (keepFileExists && !isEmpty) { Logger.LogTrace("Removing now unnecessary keep file: {Path}", keepFile); File.Delete(keepFile); } else if (!keepFileExists && isEmpty) { Logger.LogTrace("Creating necessary keep file: {Path}", keepFile); File.Create(keepFile).Dispose(); } return new() { Items = items, ExtraFiles = [] }; } return new(); } 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; public BaseItem? ResolvePath(ItemResolveArgs args) => ResolveSingle(args.Parent, args.CollectionType, args.FileInfo) .ConfigureAwait(false) .GetAwaiter() .GetResult(); public BaseItem? ResolvePath(ItemResolveArgs args, CancellationToken cancellationToken) => ResolveSingle(args.Parent, args.CollectionType, args.FileInfo, cancellationToken) .ConfigureAwait(false) .GetAwaiter() .GetResult(); #endregion #region IMultiItemResolver public MultiItemResolverResult ResolveMultiple(Folder parent, List<FileSystemMetadata> files, CollectionType? collectionType, IDirectoryService directoryService) => ResolveMultiple(parent, collectionType, files) .ConfigureAwait(false) .GetAwaiter() .GetResult(); public MultiItemResolverResult ResolveMultiple(Folder parent, List<FileSystemMetadata> files, CollectionType? collectionType, IDirectoryService directoryService, CancellationToken cancellationToken) => ResolveMultiple(parent, collectionType, files, cancellationToken) .ConfigureAwait(false) .GetAwaiter() .GetResult(); #endregion } ================================================ FILE: Shokofin/Resolvers/VirtualFileSystemService.cs ================================================ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; using Emby.Naming.Common; using Emby.Naming.ExternalFiles; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Models; using Shokofin.Configuration; using Shokofin.Extensions; using Shokofin.ExternalIds; using Shokofin.Resolvers.Models; using Shokofin.Utils; using File = System.IO.File; namespace Shokofin.Resolvers; public class VirtualFileSystemService { const string TrickplayExtensionName = ".trickplay"; private readonly ShokoApiManager ApiManager; private readonly ShokoApiClient ApiClient; private readonly UsageTracker UsageTracker; private readonly IProviderManager ProviderManager; private readonly ILibraryManager LibraryManager; private readonly ILibraryMonitor LibraryMonitor; private readonly IServerConfigurationManager ConfigurationManager; private readonly ILogger<VirtualFileSystemService> Logger; private readonly MediaFolderConfigurationService ConfigurationService; private readonly NamingOptions NamingOptions; private readonly ExternalPathParser ExternalSubtitlePathParser; private readonly ExternalPathParser ExternalAudioPathParser; private readonly GuardedMemoryCache DataCache; // Note: Out of the 14k entries in my test shoko database, then only **348** entries have a title longer than 64 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", "theme-music", ]; public VirtualFileSystemService( ShokoApiManager apiManager, ShokoApiClient apiClient, UsageTracker usageTracker, MediaFolderConfigurationService configurationService, IProviderManager providerManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IServerConfigurationManager configurationManager, ILogger<VirtualFileSystemService> logger, ILocalizationManager localizationManager, NamingOptions namingOptions ) { ApiManager = apiManager; ApiClient = apiClient; UsageTracker = usageTracker; ConfigurationService = configurationService; ProviderManager = providerManager; LibraryManager = libraryManager; LibraryMonitor = libraryMonitor; ConfigurationManager = configurationManager; Logger = logger; DataCache = new( logger, new() { ExpirationScanFrequency = Plugin.Instance.Configuration.Debug.ExpirationScanFrequency }, new() { AbsoluteExpirationRelativeToNow = Plugin.Instance.Configuration.Debug.AbsoluteExpirationRelativeToNow, SlidingExpiration = Plugin.Instance.Configuration.Debug.SlidingExpiration, } ); NamingOptions = namingOptions; ExternalSubtitlePathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Subtitle); ExternalAudioPathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Audio); UsageTracker.Stalled += OnTrackerStalled; ProviderManager.RefreshStarted += OnProviderManagerRefreshStarted; } ~VirtualFileSystemService() { UsageTracker.Stalled -= OnTrackerStalled; ProviderManager.RefreshStarted -= OnProviderManagerRefreshStarted; DataCache.Dispose(); } private void OnTrackerStalled(object? sender, EventArgs eventArgs) { if (Plugin.Instance.Configuration.Debug.AutoClearVfsCache) Clear(); } public void Clear() { Logger.LogDebug("Clearing data…"); DataCache.Clear(); } #region Changes Tracking private void OnProviderManagerRefreshStarted(object? sender, GenericEventArgs<BaseItem> e) { var item = e.Argument; var vfsRoot = Plugin.Instance.VirtualRoot; if ( item.Path is not { Length: > 0 } || !item.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar) || item.GetBaseItemKind() is not BaseItemKind.Folder || !Guid.TryParse(item.Path.AsSpan(vfsRoot.Length + 1, 36), out var libraryId) || Plugin.Instance.Configuration.Libraries.FirstOrDefault(config => config.Id == libraryId) is not {} config ) return; Logger.LogTrace("Refresh started for {Name}: {Path} ", config.Name, item.Path); if (config.IterativeVfsGeneration_Enabled) { DataCache.Remove(CachePrefix + item.Path); } } #endregion #region Preview Structure public async Task<(HashSet<string> filesBefore, HashSet<string> filesAfter, VirtualFolderInfo? virtualFolder, LinkGenerationResult? result, string vfsPath)> PreviewChangesForLibrary(Guid libraryId, CancellationToken cancellationToken = default) { // Don't allow starting a preview if a library scan is running. var virtualFolders = LibraryManager.GetVirtualFolders(); var selectedFolder = virtualFolders.FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == libraryId); if (selectedFolder is null || LibraryManager.FindByPath(selectedFolder.Locations[0], true) is not Folder mediaFolder || LibraryManager.IsScanRunning) return ([], [], selectedFolder, null, string.Empty); var collectionType = selectedFolder.CollectionType.ConvertToCollectionType(); var (libraryConfig, mediaConfigs, _) = await ConfigurationService.GetMediaFoldersForLibraryInVFS(mediaFolder, collectionType).ConfigureAwait(false); if (libraryConfig is null || mediaConfigs.Count is 0) return ([], [], selectedFolder, null, string.Empty); // Only allow the preview to run once per caching cycle. var vfsPath = libraryConfig.VirtualRoot; return await DataCache.GetOrCreateAsync($"preview-changes:{vfsPath}", async () => { // This call will be slow depending on the size of your collection. var existingPaths = GetFilePaths(vfsPath, true, cancellationToken: cancellationToken).ToHashSet(); // Validate if we can use the media folders. if (!TryGetFileCheckerForMediaFolders(libraryConfig, mediaConfigs, out var fileChecker)) return (existingPaths, [], selectedFolder, new(), vfsPath); var allFiles = GetFilesForManagedFolders(mediaConfigs, fileChecker); var result = await GenerateStructure(collectionType, vfsPath, allFiles, preview: true, cancellationToken: cancellationToken).ConfigureAwait(false); result += CleanupStructure(vfsPath, vfsPath, result.Paths.ToArray(), preview: true, cancellationToken: cancellationToken); // Alter the paths to match the new structure. var alteredPaths = existingPaths .Concat(result.Paths.ToArray()) .Except(result.RemovedPaths.ToArray()) .ToHashSet(); return (existingPaths, alteredPaths, selectedFolder, result, vfsPath); }, cancellationToken: cancellationToken).ConfigureAwait(false); } #endregion #region Generate Structure private const string CachePrefix = "vfs-path:"; /// <summary> /// Tries to get the current library generation mode. If the library has not /// been refreshed recently then it will return false, otherwise it will /// return true, with the <paramref name="iterativeGeneration"/> set to true /// if the library was iteratively generated. /// </summary> /// <param name="path">A path to the vfs folder for the library, or an entity within the vfs folder for the library.</param> /// <param name="iterativeGeneration">Indicates the library were iteratively generated.</param> /// <returns>True if the library was recently generated, false otherwise.</returns> public bool TryGetCurrentLibraryGenerationMode(string? path, out bool iterativeGeneration, out bool wasGenerated) { if (string.IsNullOrEmpty(path)) { return iterativeGeneration = wasGenerated = false; } var vfsRoot = Plugin.Instance.VirtualRoot; if (!path.StartsWith(vfsRoot + Path.DirectorySeparatorChar)) { return iterativeGeneration = wasGenerated = false; } if (!Guid.TryParse(path.AsSpan(vfsRoot.Length + 1, 36), out var libraryId)) { return iterativeGeneration = wasGenerated = false; } var vfsPath = Path.Combine(vfsRoot, libraryId.ToString()); if (!DataCache.TryGetValue<(HashSet<string>? alteredPaths, bool iterative)>(CachePrefix + vfsPath, out var tuple)) { return iterativeGeneration = wasGenerated = false; } iterativeGeneration = tuple.iterative; wasGenerated = tuple.iterative && (tuple.alteredPaths?.Contains(path) ?? false); return true; } /// <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? vfsPath, bool shouldContinue, bool skipValidation, HashSet<string> alteredPaths)> GenerateStructureInVFS(Folder mediaFolder, CollectionType? collectionType, string path, CancellationToken cancellationToken = default) { var (libraryConfig, mediaConfigs, skipGeneration) = await ConfigurationService.GetMediaFoldersForLibraryInVFS(mediaFolder, collectionType).ConfigureAwait(false); if (libraryConfig is null || mediaConfigs.Count is 0) return (null, false, 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."); var vfsPath = libraryConfig.VirtualRoot; if (!string.Equals(vfsPath, path, StringComparison.Ordinal) && !path.StartsWith(vfsPath + Path.DirectorySeparatorChar, StringComparison.Ordinal)) return (vfsPath, false, false, []); // Skip link generation if we've already generated for the library. if (DataCache.TryGetValue<(HashSet<string>? alteredPaths, bool iterative)>(CachePrefix + vfsPath, out var tuple)) return ( tuple.alteredPaths is not null ? vfsPath : null, true, tuple.iterative, tuple.alteredPaths ?? [] ); // 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(CachePrefix + subPath, out tuple)) return (vfsPath, true, false, tuple.alteredPaths ?? []); pathSegments = pathSegments.SkipLast(1).ToArray(); } } // Validate if we can use the media folders. if (!TryGetFileCheckerForMediaFolders(libraryConfig, mediaConfigs, out var fileChecker)) return (vfsPath, true, true, []); // Since the generator is lazily started then we can do this outside // the guarded cache to check if we should abort or not. string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { 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(ProviderNames.ShokoSeries, out var seasonId)) break; // movie-folder if (seriesName.TryGetAttributeValue(ProviderNames.ShokoEpisode, out var episodeId) ) { pathToClean = path; allFiles = GetFilesForMovie(episodeId, mediaConfigs, fileChecker); break; } // show pathToClean = path; allFiles = GetFilesForShow(seasonId, null, mediaConfigs, fileChecker); break; } // season/movie level case 2: { var (seriesName, seasonOrMovieName) = pathSegments; if (!seriesName.TryGetAttributeValue(ProviderNames.ShokoSeries, out var seasonId)) break; // movie if (seriesName.TryGetAttributeValue(ProviderNames.ShokoEpisode, out _)) { if (!seasonOrMovieName.TryGetAttributeValue(ProviderNames.ShokoSeries, out var seriesId) || !int.TryParse(seriesId, out _)) break; if (!seasonOrMovieName.TryGetAttributeValue(ProviderNames.ShokoFile, out var fileId) || !int.TryParse(fileId, out _)) break; allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfigs, fileChecker); break; } // "season" or extras if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) break; pathToClean = path; allFiles = GetFilesForShow(seasonId, seasonNumber, mediaConfigs, fileChecker); break; } // episodes level case 3: { var (seriesName, seasonName, episodeName) = pathSegments; if (!seriesName.TryGetAttributeValue(ProviderNames.ShokoSeries, out var seasonId)) break; if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out _)) break; if (!episodeName.TryGetAttributeValue(ProviderNames.ShokoSeries, out var seriesId) || !int.TryParse(seriesId, out _)) break; if (!episodeName.TryGetAttributeValue(ProviderNames.ShokoFile, out var fileId) || !int.TryParse(fileId, out _)) break; allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfigs, fileChecker); break; } } // The only reason `allFiles` can be null after this check is if we're // trying to generate the root folder. if (allFiles is null) return (null, true, false, []); } // Skip generation if we're going to (re-)schedule a library scan. if (skipGeneration) return (vfsPath, true, true, []); // Only do this once. tuple = await DataCache.GetOrCreateAsync(CachePrefix + path, async (options) => { Logger.LogInformation( "Generating VFS structure for library {LibraryName} at sub-path {Path}. This might take some time depending on your collection size. (Library={LibraryId})", libraryConfig.Name, path.StartsWith(vfsPath + Path.DirectorySeparatorChar) ? path[vfsPath.Length..] : Path.DirectorySeparatorChar, mediaConfigs[0].LibraryId ); var lastGeneratedAt = (DateTime?)null; var knownFileSeriesBag = (ConcurrentBag<(string fileId, string seriesId)>?)null; // `allFiles` will only be null if we'te trying to generate the root folder, // so it's effectively the same as if we had done `vfsPath == path`, but we // get to tell the compiler that `allFiles` will not be null after this point. if (allFiles is null) { // Check if we want to do an iterative generation of the VFS since we're // operating on the root folder. if (libraryConfig.IterativeVfsGeneration_Enabled) { if (libraryConfig.IterativeVfsGeneration_ForceFullGenerationOnNextRefresh) { libraryConfig.IterativeVfsGeneration_ForceFullGenerationOnNextRefresh = false; libraryConfig.IterativeVfsGeneration_CurrentCount = 0; } else if (!libraryConfig.IterativeVfsGeneration_LastGeneratedAt.HasValue) { libraryConfig.IterativeVfsGeneration_CurrentCount = 0; } else if (libraryConfig.IterativeVfsGeneration_MaxCount > 0) { if (libraryConfig.IterativeVfsGeneration_CurrentCount + 1 < libraryConfig.IterativeVfsGeneration_MaxCount) { libraryConfig.IterativeVfsGeneration_CurrentCount++; lastGeneratedAt = libraryConfig.IterativeVfsGeneration_LastGeneratedAt.Value; } else if (libraryConfig.IterativeVfsGeneration_CurrentCount > 0) { libraryConfig.IterativeVfsGeneration_CurrentCount = 0; } } else { lastGeneratedAt = libraryConfig.IterativeVfsGeneration_LastGeneratedAt.Value; } options.NoCache = libraryConfig.IterativeVfsGeneration_NoCache; libraryConfig.IterativeVfsGeneration_LastGeneratedAt = DateTime.UtcNow; Plugin.Instance.SaveConfiguration(); } // Reset state if the option has been disabled. else if ( libraryConfig.IterativeVfsGeneration_ForceFullGenerationOnNextRefresh || libraryConfig.IterativeVfsGeneration_LastGeneratedAt.HasValue || libraryConfig.IterativeVfsGeneration_CurrentCount > 0 ) { libraryConfig.IterativeVfsGeneration_ForceFullGenerationOnNextRefresh = false; libraryConfig.IterativeVfsGeneration_CurrentCount = 0; libraryConfig.IterativeVfsGeneration_LastGeneratedAt = null; Plugin.Instance.SaveConfiguration(); } // Initialise the bag and switch to the flood search file checker if we're // doing an iterative generation and need to know which files were removed. if (lastGeneratedAt.HasValue) { knownFileSeriesBag = []; fileChecker = GetFloodSearchFileChecker(libraryConfig, mediaConfigs, cancellationToken); } pathToClean = vfsPath; allFiles = GetFilesForManagedFolders(mediaConfigs, fileChecker, lastGeneratedAt, knownFileSeriesBag); } var pathToReport = pathToClean ?? Path.GetDirectoryName(path)!; LibraryMonitor.ReportFileSystemChangeBeginning(pathToReport); // Generate any new structure in the VFS. var result = await GenerateStructure(collectionType, vfsPath, allFiles, cancellationToken: cancellationToken).ConfigureAwait(false); // Cleanup any residual entries from old structure in the VFS if interactive // generation is disabled, or if it's enabled and we generated something new. if (!string.IsNullOrEmpty(pathToClean)) { if (lastGeneratedAt.HasValue) { var newPaths = result.Paths.ToArray(); // Collect the bag to filter out the removed files. var fileSeriesIdSet = knownFileSeriesBag!.ToArray().ToHashSet(); // For now we're overcompensating when "cleaning" by also checking // all other videos in the directory when iterative generation is enabled, // so we move the sub/audio files and trickplay directories if necessary. var allPaths = GetFilePaths( pathToClean, recursive: true, extensions: NamingOptions.VideoFileExtensions, filter: (path, __) => TryGetIdsForPath(path, out var fileId, out var seresId) && fileSeriesIdSet.Contains((fileId, seresId)), cancellationToken: cancellationToken ); result.SkippedVideos = allPaths.Except(newPaths).Count(); result += CleanupStructure(vfsPath, pathToClean, allPaths, cancellationToken: cancellationToken); // The resolver only care about the new files, if any, so revert the // paths back to the original ones after the cleanup. result.Paths = [.. newPaths]; } else { var allPaths = result.Paths.ToArray(); result += CleanupStructure(vfsPath, pathToClean, allPaths, cancellationToken: cancellationToken); } } // Report the change to the library monitor. LibraryMonitor.ReportFileSystemChangeComplete(pathToReport, false); // 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, path); return (AddParentDirectories(vfsPath, result.Paths.ToArray()), lastGeneratedAt.HasValue); }, cancellationToken).ConfigureAwait(false); return ( tuple.alteredPaths is not null ? vfsPath : null, true, tuple.iterative, tuple.alteredPaths ?? [] ); } private bool TryGetFileCheckerForMediaFolders(LibraryConfiguration libraryConfig, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, [NotNullWhen(true)] out Func<string, bool>? fileChecker) { if (mediaConfigs.Count is 0) { Logger.LogWarning("No media folders to create a file checker for. (Library={LibraryId})", libraryConfig.Id); fileChecker = null; return false; } // Do a preliminary check to see if the folders exist and contain files, // in case a mount point failed to mount. var shouldReturn = false; foreach (var mediaConfig in mediaConfigs) { if (!Directory.Exists(mediaConfig.Path)) { Logger.LogWarning("Unable to create a file checker because a folder does not exist; {Path} (Library={LibraryId})", mediaConfig.Path, mediaConfig.LibraryId); shouldReturn = true; } else if (!ContainsFileSystemEntryPaths(mediaConfig.Path)) { Logger.LogWarning("Unable to create a file checker because the folder is empty; {Path} (Library={LibraryId})", mediaConfig.Path, mediaConfig.LibraryId); shouldReturn = true; } } if (shouldReturn) { fileChecker = null; return false; } Logger.LogDebug("Creating an iterative file checker for {Count} folders. (Library={LibraryId})", mediaConfigs.Count, libraryConfig.Id); fileChecker = File.Exists; return true; } private Func<string, bool> GetFloodSearchFileChecker(LibraryConfiguration libraryConfig, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, CancellationToken cancellationToken = default) { var startTime = DateTime.UtcNow; Logger.LogDebug( "Switching to a flood search file checker for {Count} folders. (Library={LibraryId})", mediaConfigs.Count, libraryConfig.Id ); var filePaths = (ConcurrentBag<string>?)[]; foreach (var (managedFolderId, managedFolderSubPath, mediaFolderPaths) in mediaConfigs.ToManagedFolderList()) { Logger.LogTrace("Processing managed folder {ManagedFolderId} with {Count} paths. (Library={LibraryId})", managedFolderId, mediaFolderPaths.Count, libraryConfig.Id); foreach (var path in mediaFolderPaths) { Logger.LogTrace("Processing path {Path}. (Library={LibraryId})", path, libraryConfig.Id); var allPaths = GetFilePaths( path, recursive: true, extensions: NamingOptions.VideoFileExtensions, cancellationToken: cancellationToken ); Parallel.ForEach(allPaths, new() { MaxDegreeOfParallelism = GetThreadCount() }, path => filePaths?.Add(path)); } } var filePathSet = filePaths!.ToArray().ToHashSet(); filePaths = null; Logger.LogTrace("Created a flood search file checker with {Count} paths in {Duration}. (Library={LibraryId})", filePathSet.Count, DateTime.UtcNow - startTime, libraryConfig.Id); return filePathSet.Contains; } private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, Func<string, bool> fileExists) { 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 (managedFolderId, managedFolderSubPath, mediaFolderPaths) in mediaConfigs.ToManagedFolderList()) { var location = file.Locations .Where(location => location.ManagedFolderId == managedFolderId && (managedFolderSubPath.Length is 0 || location.RelativePath.StartsWith(managedFolderSubPath))) .FirstOrDefault(); if (location is null) continue; foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[managedFolderSubPath.Length..]); if (!fileExists(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, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, Func<string, bool> fileExists) { var start = DateTime.UtcNow; var totalFiles = 0; var seasonInfo = ApiManager.GetSeasonInfoForEpisode(episodeId) .ConfigureAwait(false) .GetAwaiter() .GetResult(); if (seasonInfo is null) yield break; var seasonId = seasonInfo.Id; Logger.LogDebug( "Iterating files to potentially use within {Count} media folders. (Episode={EpisodeId},Season={SeasonId},Library={LibraryId})", mediaConfigs.Count, episodeId, seasonId, mediaConfigs[0].LibraryId ); var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); var files = seasonInfo.GetFiles() .ConfigureAwait(false) .GetAwaiter() .GetResult(); var fileLocations = files .Where(tuple => tuple.episodeIds.Overlaps(episodeIds)) .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); foreach (var (file, fileSeriesId, location) in fileLocations) { foreach (var (managedFolderId, managedFolderSubPath, mediaFolderPaths) in mediaConfigs.ToManagedFolderList()) { if (location.ManagedFolderId != managedFolderId || managedFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(managedFolderSubPath)) continue; foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[managedFolderSubPath.Length..]); if (!fileExists(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},Season={SeasonId},Library={LibraryId})", totalFiles, mediaConfigs.Count, timeSpent, episodeId, seasonId, mediaConfigs[0].LibraryId ); } private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seasonId, int? seasonNumber, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, Func<string, bool> fileExists) { var start = DateTime.UtcNow; var showInfo = ApiManager.GetShowInfoBySeasonId(seasonId).ConfigureAwait(false).GetAwaiter().GetResult(); if (showInfo is null) yield break; Logger.LogDebug( "Iterating files to potentially use within {Count} media folders. (MainSeason={MainSeasonId},Season={SeasonNumber},Library={LibraryId})", mediaConfigs.Count, seasonId, seasonNumber, mediaConfigs[0].LibraryId ); // Only return the files for the given season. var totalFiles = 0; var configList = mediaConfigs.ToManagedFolderList(); 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 = seasonInfo.GetFiles().ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files .Where(tuple => tuple.episodeIds.Overlaps(episodeIds)) .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); foreach (var (file, fileSeriesId, location) in fileLocations) { foreach (var (managedFolderId, managedFolderSubPath, mediaFolderPaths) in configList) { if (location.ManagedFolderId != managedFolderId || managedFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(managedFolderSubPath)) continue; foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[managedFolderSubPath.Length..]); if (!fileExists(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 = seasonInfo.GetFiles().ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files .Where(tuple => tuple.episodeIds.Overlaps(episodeIds)) .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); foreach (var (file, fileSeriesId, location) in fileLocations) { foreach (var (managedFolderId, managedFolderSubPath, mediaFolderPaths) in configList) { if (location.ManagedFolderId != managedFolderId || managedFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(managedFolderSubPath)) continue; foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[managedFolderSubPath.Length..]); if (!fileExists(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 = seasonInfo.GetFiles().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 (managedFolderId, managedFolderSubPath, mediaFolderPaths) in configList) { if (location.ManagedFolderId != managedFolderId || managedFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(managedFolderSubPath)) continue; foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[managedFolderSubPath.Length..]); if (!fileExists(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} (MainSeason={MainSeasonId},Season={SeasonNumber},Library={LibraryId})", totalFiles, mediaConfigs.Count, timeSpent, seasonId, seasonNumber, mediaConfigs[0].LibraryId ); } private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForManagedFolders(IReadOnlyList<MediaFolderConfiguration> mediaConfigs, Func<string, bool> fileExists, DateTime? lastGeneratedAt = null, ConcurrentBag<(string, string)>? knownFileSeriesBag = null) { var start = DateTime.UtcNow; var singleSeriesIds = new HashSet<string>(); var multiSeriesFiles = new List<(API.Models.File, string)>(); var totalSingleSeriesFiles = 0; var libraryId = mediaConfigs[0].LibraryId; var collectAndSort = Plugin.Instance.Configuration.VFS_CollectAndSort; var collected = new List<(string sourceLocation, string fileId, string seriesId)>(); foreach (var (managedFolderId, managedFolderSubPath, mediaFolderPaths) in mediaConfigs.ToManagedFolderList()) { var firstPage = ApiClient.GetFilesInManagedFolder(managedFolderId, managedFolderSubPath); 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 files to potentially use within media folder(s) at {Path} by checking {TotalCount} matches. (LibraryId={LibraryId},ManagedFolder={FolderId},RelativePath={RelativePath},PageSize={PageSize},TotalPages={TotalPages})", mediaFolderPaths, pageData.Total, libraryId, managedFolderId, managedFolderSubPath, 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(GetManagedFolderFilesPage(managedFolderId, managedFolderSubPath, 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} (LibraryId={LibraryId},ManagedFolder={FolderId},RelativePath={RelativePath})", totalPages - pages.Count, pageData.List.Count, libraryId, managedFolderId, managedFolderSubPath ); foreach (var file in pageData.List) { if (file.CrossReferences.Count is 0) continue; var location = file.Locations .Where(location => location.ManagedFolderId == managedFolderId && (managedFolderSubPath.Length is 0 || location.RelativePath.StartsWith(managedFolderSubPath))) .FirstOrDefault(); if (location is null) continue; foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[managedFolderSubPath.Length..]); if (!fileExists(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++; var seriesId = seriesIds.First().ToString(); singleSeriesIds.Add(seriesId); // Skip files that were generated before the last generated at time if we're doing an iterative run, // but still add it to the bag for validation of removed files. if (lastGeneratedAt.HasValue) { knownFileSeriesBag!.Add((file.Id.ToString(), seriesId)); if ((file.ImportedAt ?? file.CreatedAt) < lastGeneratedAt.Value) continue; } // If collect and sort is enabled, collect the file instead of yielding. if (collectAndSort) collected.Add((sourceLocation, file.Id.ToString(), seriesId)); else yield return (sourceLocation, file.Id.ToString(), seriesId); } 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 anidbExceptionSet = Plugin.Instance.Configuration.VFS_AlwaysIncludedAnidbIdList.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 => (seriesId: xref.Series.Shoko!.Value.ToString(), anidbId: xref.Series.AniDB)) .Distinct() .Select(tuple => ( tuple.seriesId, tuple.anidbId )) .Where(tuple => singleSeriesIds.Contains(tuple.seriesId) || anidbExceptionSet.Contains(tuple.anidbId)) .Select(tuple => tuple.seriesId) .ToList(); foreach (var seriesId in seriesIds) { // Skip files that were generated before the last generated at time if we're doing an iterative run, // but still add it to the bag for validation of removed files. if (lastGeneratedAt.HasValue) { knownFileSeriesBag!.Add((file.Id.ToString(), seriesId)); if ((file.ImportedAt ?? file.CreatedAt) < lastGeneratedAt.Value) continue; } // If collect and sort is enabled, collect the file instead of yielding. if (collectAndSort) collected.Add((sourceLocation, file.Id.ToString(), seriesId)); else yield return (sourceLocation, file.Id.ToString(), seriesId); } totalMultiSeriesFiles += seriesIds.Count; } } // If collect and sort is enabled, yield the collected files after sorting them now. if (collectAndSort) foreach (var (sourceLocation, fileId, seriesId) in collected.GroupBy(t => t.seriesId).OrderBy(g => g.Count()).ThenBy(g => g.Key).SelectMany(g => g)) yield return (sourceLocation, fileId, seriesId); 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, libraryId ); } private async Task<ListResult<API.Models.File>> GetManagedFolderFilesPage(int managedFolderId, string managedFolderSubPath, int page, SemaphoreSlim semaphore) { await semaphore.WaitAsync().ConfigureAwait(false); return await ApiClient.GetFilesInManagedFolder(managedFolderId, managedFolderSubPath, page).ConfigureAwait(false); } private async Task<LinkGenerationResult> GenerateStructure(CollectionType? collectionType, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles, bool preview = false, CancellationToken cancellationToken = default) { var result = new LinkGenerationResult(); var maxTotalExceptions = Plugin.Instance.Configuration.VFS_MaxTotalExceptionsBeforeAbort; var maxSeriesExceptions = Plugin.Instance.Configuration.VFS_MaxSeriesExceptionsBeforeAbort; var failedSeries = new HashSet<string>(); var failedExceptions = new List<Exception>(); var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); if (Plugin.Instance.Configuration.VFS_UseSemaphore) { var semaphore = new SemaphoreSlim(GetThreadCount()); await Task.WhenAll(allFiles.Select(async (tuple) => { await semaphore.WaitAsync().ConfigureAwait(false); var (sourceLocation, fileId, seriesId) = tuple; try { if (cancelTokenSource.IsCancellationRequested) { Logger.LogTrace("Cancelling generation of links for {Path}", sourceLocation); return; } Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", sourceLocation, fileId, seriesId); var (symbolicLinks, importedAt) = await GenerateLocationsForFile(collectionType, vfsPath, sourceLocation, fileId, seriesId).ConfigureAwait(false); if (symbolicLinks.Length == 0 || !importedAt.HasValue) return; var subResult = GenerateSymbolicLinks(vfsPath, sourceLocation, symbolicLinks, importedAt.Value, preview); // Combine the current results with the overall results. lock (semaphore) { result += subResult; } } catch (Exception ex) { Logger.LogWarning(ex, "Failed to generate links for {Path} (File={FileId},Series={SeriesId})", sourceLocation, fileId, seriesId); lock (semaphore) { failedSeries.Add(seriesId); failedExceptions.Add(ex); if ((maxSeriesExceptions > 0 && failedSeries.Count == maxSeriesExceptions) || (maxTotalExceptions > 0 && failedExceptions.Count == maxTotalExceptions)) { cancelTokenSource.Cancel(); } } } finally { semaphore.Release(); } })).ConfigureAwait(false); } else { await Parallelize(allFiles, async tuple => { var (sourceLocation, fileId, seriesId) = tuple; try { if (cancelTokenSource.IsCancellationRequested) { Logger.LogTrace("Cancelling generation of links for {Path}", sourceLocation); return; } Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", sourceLocation, fileId, seriesId); var (symbolicLinks, importedAt) = await GenerateLocationsForFile(collectionType, vfsPath, sourceLocation, fileId, seriesId).ConfigureAwait(false); if (symbolicLinks.Length == 0 || !importedAt.HasValue) return; var subResult = GenerateSymbolicLinks(vfsPath, sourceLocation, symbolicLinks, importedAt.Value, preview); // Combine the current results with the overall results. lock (cancelTokenSource) { result += subResult; } } catch (Exception ex) { Logger.LogWarning(ex, "Failed to generate links for {Path} (File={FileId},Series={SeriesId})", sourceLocation, fileId, seriesId); lock (cancelTokenSource) { failedSeries.Add(seriesId); failedExceptions.Add(ex); if ((maxSeriesExceptions > 0 && failedSeries.Count >= maxSeriesExceptions) || (maxTotalExceptions > 0 && failedExceptions.Count >= maxTotalExceptions)) { cancelTokenSource.Cancel(); } } } }, cancelTokenSource.Token).ConfigureAwait(false); } cancellationToken.ThrowIfCancellationRequested(); // Throw an `AggregateException` if any series exceeded the maximum number of exceptions, or if the total number of exceptions exceeded the maximum allowed. Additionally, // if no links were generated and there were any exceptions, but we haven't reached the maximum allowed exceptions yet, then also throw an `AggregateException`. if (cancelTokenSource.IsCancellationRequested || (failedExceptions.Count > 0 && (maxTotalExceptions > 0 || maxSeriesExceptions > 0) && result.TotalVideos == 0)) { Logger.LogWarning("Failed to generate {FileCount} links across {SeriesCount} series for {Path}", failedExceptions.Count, failedSeries.Count, vfsPath); throw new AggregateException(failedExceptions); } return result; } public async Task<(string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(CollectionType? collectionType, string vfsPath, string sourceLocation, string fileId, string seriesId) { var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); if (file is null) return ([], null); if (file.EpisodeList is not { Count: > 0 }) return ([], null); var (episode, episodeXref, _) = file.EpisodeList[0]; var season = await ApiManager.GetSeasonInfo(episode.SeasonId).ConfigureAwait(false); if (season is null) return ([], null); var isMovieSeason = season.Type is SeriesType.Movie; var isMovieLibrary = collectionType is CollectionType.movies || (collectionType is null && isMovieSeason); var config = Plugin.Instance.Configuration; var shouldAbort = collectionType switch { CollectionType.tvshows => isMovieSeason && config.SeparateMovies, CollectionType.movies => !isMovieSeason && config.FilterMovieLibraries, _ => false, }; if (shouldAbort) return ([], null); var show = await ApiManager.GetShowInfoBySeasonId(season.Id).ConfigureAwait(false); if (show is null) return ([], null); var showName = (show.Titles.FirstOrDefault(t => t.Source is "AniDB" && t.IsDefault)?.Value ?? show.Titles.FirstOrDefault(t => t.Source is "TMDB" && t.IsDefault)?.Value)?.ReplaceInvalidPathCharacters(); if (string.IsNullOrWhiteSpace(showName)) showName = isMovieLibrary ? "Movie" : "Series"; var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); var episodeName = (episode.Titles.FirstOrDefault(t => t.Source is "AniDB" && t.LanguageCode == "en")?.Value ?? $"{(episode.Type is EpisodeType.Episode ? "Episode " : $"{episode.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 fileIdList = fileId; var filePartSuffix = ""; if (isMovieLibrary) { if (extrasFolders != null) { foreach (var extrasFolder in extrasFolders) foreach (var episodeInfo in season.EpisodeList.Where(e => e.IsAvailable)) folders.Add(Path.Join(vfsPath, $"{showName} [{ProviderNames.ShokoSeries}={season.Id}] [{ProviderNames.ShokoEpisode}={episodeInfo.Id}]", extrasFolder)); } else { folders.Add(Path.Join(vfsPath, $"{showName} [{ProviderNames.ShokoSeries}={season.Id}] [{ProviderNames.ShokoEpisode}={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} [{ProviderNames.ShokoSeries}={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')}"; if (episodeXref.Percentage.Group is not 1) { var list = episode.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage.Group == episodeXref.Percentage.Group).ToList(); var files = (await Task.WhenAll(list.Select(xref => ApiClient.GetFileByEd2kAndFileSize(xref.ED2K, xref.FileSize))).ConfigureAwait(false)) .WhereNotNull() .ToList(); if (files.Count != list.Count) throw new Exception($"Mismatch between cross-references and files. (FileCount={files.Count},CrossReferenceCount={list.Count},Episode={episode.Id},File={fileId},Series={seriesId})"); var index = list.FindIndex(xref => xref.Percentage.Start == episodeXref.Percentage.Start && xref.Percentage.End == episodeXref.Percentage.End); filePartSuffix = $".pt{index + 1}"; fileIdList = files.Select(f => f.Id.ToString()).Join(","); } } } var extraDetails = new List<string>(); if (config.VFS_AddReleaseGroup) extraDetails.Add( file.Shoko.Release?.Group is { } releaseGroup ? !string.IsNullOrEmpty(releaseGroup.ShortName) ? releaseGroup.ShortName : !string.IsNullOrEmpty(releaseGroup.Name) ? releaseGroup.Name : $"Release group {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.Select(a => a.ReplaceInvalidPathCharacters()).Join("] [")}] " : "")}[{ProviderNames.ShokoSeries}={seriesId}] [{ProviderNames.ShokoFile}={fileIdList}]{filePartSuffix}{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 (symbolicLinks, (file.Shoko.ImportedAt ?? file.Shoko.CreatedAt).ToLocalTime()); } public LinkGenerationResult GenerateSymbolicLinks(string vfsPath, string sourceLocation, string[] symbolicLinks, DateTime importedAt, bool preview = false) { try { var result = new LinkGenerationResult(); if (Plugin.Instance.Configuration.VFS_ResolveLinks && !preview) { Logger.LogTrace("Attempting to resolve link for {Path}", sourceLocation); try { if (File.ResolveLinkTarget(sourceLocation, true) is { } linkTarget) { Logger.LogTrace("Resolved link for {Path} to {LinkTarget}", sourceLocation, linkTarget.FullName); sourceLocation = linkTarget.FullName; } } catch (Exception ex) { Logger.LogWarning(ex, "Unable to resolve link target for {Path}", sourceLocation); return result; } } foreach (var symbolicLink in symbolicLinks) { var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; if (!Directory.Exists(symbolicDirectory)) Directory.CreateDirectory(symbolicDirectory); EnsureCreationDateForDirectories(vfsPath, symbolicDirectory, importedAt); result.Paths.Add(symbolicLink); if (!File.Exists(symbolicLink)) { result.CreatedVideos++; if (!preview) { Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); File.CreateSymbolicLink(symbolicLink, sourceLocation); #if NET9_0 #else // Mock the creation date to fake the "date added" order in Jellyfin. File.SetCreationTime(symbolicLink, importedAt); #endif } } else { var shouldFix = false; try { var nextTarget = File.ResolveLinkTarget(symbolicLink, false); if (!string.Equals(sourceLocation, nextTarget?.FullName)) { shouldFix = true; if (!preview) Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); } #if NET9_0 #else var linkCreatedAt = File.GetCreationTime(symbolicLink).ToLocalTime(); if (linkCreatedAt != importedAt) { shouldFix = true; if (!preview) Logger.LogWarning("Fixing broken symbolic link {Link} with incorrect creation date.", symbolicLink); } #endif } catch (Exception ex) { shouldFix = true; if (!preview) Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicLink); } if (shouldFix) { result.FixedVideos++; if (!preview) { File.Delete(symbolicLink); File.CreateSymbolicLink(symbolicLink, sourceLocation); #if NET9_0 #else // Mock the creation date to fake the "date added" order in Jellyfin. File.SetCreationTime(symbolicLink, importedAt); #endif } } else { result.SkippedVideos++; } } var trickplayLocation = Path.ChangeExtension(sourceLocation, TrickplayExtensionName); if (Directory.Exists(trickplayLocation)) { var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); var symbolicTrickplay = Path.Join(symbolicDirectory, symbolicName + TrickplayExtensionName); result.Paths.Add(symbolicTrickplay); if (!Directory.Exists(symbolicTrickplay)) { result.CreatedTrickplayDirectories++; if (!preview) { Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicTrickplay, trickplayLocation); Directory.CreateSymbolicLink(symbolicTrickplay, trickplayLocation); } } else { var shouldFix = false; try { var nextTarget = Directory.ResolveLinkTarget(symbolicTrickplay, false); if (!string.Equals(trickplayLocation, nextTarget?.FullName)) { shouldFix = true; if (!preview) Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicTrickplay, trickplayLocation, nextTarget?.FullName); } } catch (Exception ex) { shouldFix = true; if (!preview) Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicTrickplay); } if (shouldFix) { result.FixedTrickplayDirectories++; if (!preview) { if ((File.GetAttributes(symbolicTrickplay) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) { File.Delete(symbolicTrickplay); } else { if (Directory.GetCreationTime(symbolicTrickplay) > Directory.GetCreationTime(trickplayLocation)) { Logger.LogTrace("Replacing trickplay for target {Link} → {LinkTarget}", symbolicTrickplay, trickplayLocation); try { Directory.Delete(trickplayLocation, recursive: true); Directory.CreateDirectory(trickplayLocation); CopyDirectory(symbolicTrickplay, trickplayLocation); } catch (Exception ex) { if (!preview) Logger.LogError(ex, "Failed to replace trickplay for target {Link} → {LinkTarget}", symbolicTrickplay, trickplayLocation); } } Directory.Delete(symbolicTrickplay, recursive: true); } Directory.CreateSymbolicLink(symbolicTrickplay, trickplayLocation); } } else { result.SkippedTrickplayDirectories++; } } } LinkExternalFiles(sourceLocation, symbolicLink, symbolicDirectory, result, preview); } 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> FindExternalFilesForPath(string sourcePath, ExternalPathParser parser) { var externalPaths = new List<string>(); var folderPath = Path.GetDirectoryName(sourcePath); if (string.IsNullOrEmpty(folderPath) || !Directory.Exists(folderPath)) return externalPaths; var files = GetFilePaths(folderPath, recursive: true) .Except([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 = parser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); if (externalPathInfo is not null && !string.IsNullOrEmpty(externalPathInfo.Path)) externalPaths.Add(externalPathInfo.Path); } } return externalPaths; } private void EnsureCreationDateForDirectories(string vfsPath, string path, DateTime dateTime) { var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).Prepend(vfsPath).ToArray(); while (pathSegments.Length > 1) { try { var subPath = Path.Join(pathSegments); var createdDate = Directory.GetCreationTimeUtc(subPath); if (createdDate > dateTime) { Directory.SetCreationTimeUtc(subPath, dateTime); } } catch (Exception ex) { Logger.LogError(ex, "Failed to set creation date for directory at {Path}", path); } pathSegments = pathSegments.SkipLast(1).ToArray(); } } private void LinkExternalFiles(string sourceLocation, string symbolicLink, string symbolicDirectory, LinkGenerationResult result, bool preview) { var externalFiles = FindExternalFilesForPath(sourceLocation, ExternalSubtitlePathParser) .Concat(FindExternalFilesForPath(sourceLocation, ExternalAudioPathParser)) .ToList(); if (externalFiles.Count == 0) return; var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); var sourceDirectory = Path.GetDirectoryName(sourceLocation)!; var sourcePrefixLength = Path.GetFileName(sourceLocation).Length - Path.GetExtension(sourceLocation).Length; foreach (var externalSource in externalFiles) { var extName = Path.GetFileName(externalSource)[sourcePrefixLength..]; if (Path.GetRelativePath(sourceDirectory, Path.GetDirectoryName(externalSource)!) is not "." and { Length: > 0 } relativePath) extName = $".[{relativePath.Split(Path.DirectorySeparatorChar).Join("].[")}]" + extName; var externalLink = Path.Join(symbolicDirectory, symbolicName + extName); result.Paths.Add(externalLink); if (!File.Exists(externalLink)) { result.CreatedExternalFiles++; if (!preview) { Logger.LogDebug("Linking {Link} → {LinkTarget}", externalLink, externalSource); File.CreateSymbolicLink(externalLink, externalSource); } } else { var shouldFix = false; try { var nextTarget = File.ResolveLinkTarget(externalLink, false); if (!string.Equals(externalSource, nextTarget?.FullName)) { shouldFix = true; if (!preview) Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", externalLink, externalSource, nextTarget?.FullName); } } catch (Exception ex) { shouldFix = true; if (!preview) Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", externalLink, externalSource); } if (shouldFix) { result.FixedExternalFiles++; if (!preview) { File.Delete(externalLink); File.CreateSymbolicLink(externalLink, externalSource); } } else { result.SkippedExternalFiles++; } } } } private static HashSet<string> AddParentDirectories(string rootDirectoryPath, IEnumerable<string> input) { var allKnownPaths = new HashSet<string>(input); var parentsToAdd = allKnownPaths .SelectMany(filePath => { var directoryPath = Path.GetDirectoryName(filePath); var tuple = new List<(string path, int level)>(); while (!string.IsNullOrEmpty(directoryPath)) { var level = directoryPath == rootDirectoryPath ? 0 : directoryPath[(rootDirectoryPath.Length + 1)..].Split(Path.DirectorySeparatorChar).Length; tuple.Add((directoryPath, level)); if (directoryPath == rootDirectoryPath) break; directoryPath = Path.GetDirectoryName(directoryPath); } return tuple; }) .DistinctBy(tuple => tuple.path) .OrderByDescending(tuple => tuple.level) .ThenBy(tuple => tuple.path) .Select(tuple => tuple.path) .ToList(); foreach (var directoryPath in parentsToAdd) allKnownPaths.Add(directoryPath); return allKnownPaths; } #endregion #region Cleanup Structure private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths, bool preview = false, CancellationToken cancellationToken = default) { if (!Directory.Exists(directoryToClean)) { if (!preview) Logger.LogDebug("Skipped cleaning up folder because it does not exist: {Path}", directoryToClean); return new(); } if (!preview) Logger.LogDebug("Looking for file system entries to remove in folder: {Path}", directoryToClean); var start = DateTime.UtcNow; var previousStep = start; var result = new LinkGenerationResult(); var searchExtensions = NamingOptions.VideoFileExtensions.Concat(NamingOptions.SubtitleFileExtensions).Concat(NamingOptions.AudioFileExtensions).Concat([".nfo", TrickplayExtensionName]).ToHashSet(); var entriesToBeRemoved = GetFileSystemEntryPaths(directoryToClean, true, searchExtensions, (path, isDirectory) => !allKnownPaths.Contains(path), cancellationToken: cancellationToken) .Select(path => (path, extName: Path.GetExtension(path))) .ToList(); var nextStep = DateTime.UtcNow; if (!preview) Logger.LogDebug("Found {FileCount} file system entries to potentially remove or fix in {TimeSpent} in folder: {DirectoryToClean}", entriesToBeRemoved.Count, nextStep - previousStep, directoryToClean); previousStep = nextStep; Parallelize(entriesToBeRemoved, (path) => { var (location, extName) = path; if (extName is ".nfo") { if (!preview) { 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); return; } } result.RemovedPaths.Add(location); result.RemovedNfos++; } else if (extName is TrickplayExtensionName) { if (TryMoveTrickplayDirectory(allKnownPaths, location, preview, out var skip)) { result.Paths.Add(location); if (skip) { result.SkippedTrickplayDirectories++; } else { result.FixedTrickplayDirectories++; } return; } if (!preview) { try { Logger.LogTrace("Removing trickplay directory at {Path}", location); if ((File.GetAttributes(location) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) { File.Delete(location); } else { Directory.Delete(location, recursive: true); } } catch (Exception ex) { Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); return; } } result.RemovedPaths.Add(location); result.RemovedTrickplayDirectories++; } else if (NamingOptions.SubtitleFileExtensions.Contains(extName) || NamingOptions.AudioFileExtensions.Contains(extName)) { if (ShouldIgnoreFile(vfsPath, location)) { result.Paths.Add(location); result.SkippedExternalFiles++; return; } if (TryMoveExternalFile(allKnownPaths, location, preview, out var skip)) { result.Paths.Add(location); if (skip) { result.SkippedExternalFiles++; } else { result.FixedExternalFiles++; } return; } if (!preview) { try { Logger.LogTrace("Removing external file at {Path}", location); File.Delete(location); } catch (Exception ex) { Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); return; } } result.RemovedPaths.Add(location); result.RemovedExternalFiles++; } else { if (ShouldIgnoreFile(vfsPath, location)) { result.Paths.Add(location); result.SkippedVideos++; return; } if (!preview) { 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); return; } } result.RemovedPaths.Add(location); result.RemovedVideos++; } }, cancellationToken).Wait(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (preview) return result; nextStep = DateTime.UtcNow; Logger.LogTrace("Removed {FileCount} file system entries in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", result.Removed, directoryToClean, nextStep - previousStep, nextStep - start); previousStep = nextStep; var cleaned = 0; var directoriesToClean = entriesToBeRemoved .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.UtcNow; Logger.LogDebug("Found {DirectoryCount} directories to potentially clean in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", directoriesToClean.Count, directoryToClean, nextStep - previousStep, nextStep - start); previousStep = nextStep; foreach (var directoryPath in directoriesToClean) { if (Directory.Exists(directoryPath) && !ContainsFileSystemEntryPaths(directoryPath)) { 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 bool TryMoveExternalFile(IReadOnlyList<string> allKnownPaths, string externalFilePath, bool preview, out bool skip) { if (!TryGetIdsForPath(externalFilePath, out var fileId, out var seriesId)) { skip = false; return false; } var symbolicLink = allKnownPaths.FirstOrDefault(knownPath => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(knownPath)) && TryGetIdsForPath(knownPath, out var knownFileId, out var knownSeriesId) && seriesId == knownSeriesId && fileId == knownFileId); if (string.IsNullOrEmpty(symbolicLink)) { skip = false; return false; } var sourcePathWithoutExt = symbolicLink[..^Path.GetExtension(symbolicLink).Length]; if (!externalFilePath.StartsWith(sourcePathWithoutExt)) { skip = false; return false; } var extName = externalFilePath[sourcePathWithoutExt.Length..]; string? realTarget = null; try { realTarget = File.ResolveLinkTarget(symbolicLink, false)?.FullName; } catch { } if (string.IsNullOrEmpty(realTarget)) { skip = false; return false; } if (preview) { skip = true; return true; } var realExternalFilePath = realTarget[..^Path.GetExtension(realTarget).Length] + extName; try { var currentTarget = File.ResolveLinkTarget(externalFilePath, false)?.FullName; if (!string.IsNullOrEmpty(currentTarget)) { // Just remove the link if the target doesn't exist. if (!File.Exists(currentTarget)) { skip = false; return false; } // If we're cleaning up during an iterative generation then we // might hit this path, so abort here if everything is as it // should be. if (currentTarget == realExternalFilePath) { skip = true; return true; } // Copy the link so we can move it to where it should be. File.Delete(externalFilePath); File.Copy(currentTarget, externalFilePath); } } catch (Exception ex) { Logger.LogWarning(ex, "Unable to check if {Path} is a symbolic link", externalFilePath); skip = false; return false; } if (!File.Exists(realExternalFilePath)) { try { File.Move(externalFilePath, realExternalFilePath); } catch (Exception) { Logger.LogWarning("Skipped moving {Path} to {RealPath} because we don't have permissions.", externalFilePath, realExternalFilePath); skip = true; return true; } } else { File.Delete(externalFilePath); } File.CreateSymbolicLink(externalFilePath, realExternalFilePath); Logger.LogDebug("Moved {Path} to {RealPath}", externalFilePath, realExternalFilePath); skip = false; return true; } private bool TryMoveTrickplayDirectory(IReadOnlyList<string> allKnownPaths, string trickplayDirectory, bool preview, out bool skip) { // Ignore all trickplay directories that don't have any shoko ids set. if (!TryGetIdsForPath(trickplayDirectory, out var fileId, out var seriesId)) { skip = true; return true; } var linkToMove = allKnownPaths.FirstOrDefault(knownPath => Path.GetExtension(knownPath) is { Length: > 0 } extName && NamingOptions.VideoFileExtensions.Contains(extName, StringComparer.OrdinalIgnoreCase) && string.Equals(trickplayDirectory, knownPath[..^extName.Length] + TrickplayExtensionName) ); if (string.IsNullOrEmpty(linkToMove)) { skip = false; return false; } var sourcePathWithoutExt = linkToMove[..^Path.GetExtension(linkToMove).Length]; string? realTarget = null; try { realTarget = Directory.ResolveLinkTarget(linkToMove, false)?.FullName; } catch { } if (string.IsNullOrEmpty(realTarget)) { skip = false; return false; } if (preview) { skip = true; return true; } var realPath = realTarget[..^Path.GetExtension(realTarget).Length] + TrickplayExtensionName; try { var currentTarget = Directory.ResolveLinkTarget(trickplayDirectory, false)?.FullName; if (!string.IsNullOrEmpty(currentTarget)) { // Just remove the link if the target doesn't exist. if (!Directory.Exists(currentTarget)) { skip = false; return false; } // If we're cleaning up during an iterative generation then we // might hit this path, so abort here if everything is as it // should be. if (currentTarget == realPath) { skip = true; return true; } // Copy the link so we can move it to where it should be. Directory.Delete(trickplayDirectory, recursive: true); CopyDirectory(currentTarget, trickplayDirectory); } } catch (Exception ex) { Logger.LogWarning(ex, "Unable to check if {Path} is a symbolic link", trickplayDirectory); skip = false; return false; } if (!Directory.Exists(realPath)) { try { Directory.Move(trickplayDirectory, realPath); } catch (Exception) { try { Directory.CreateDirectory(realPath); } catch (Exception) { Logger.LogDebug("Skipped moving {Directory} to {RealPath} because we don't have permissions.", trickplayDirectory, realPath); skip = true; return true; } CopyDirectory(trickplayDirectory, realPath); Directory.Delete(trickplayDirectory, recursive: true); } } else { Directory.Delete(trickplayDirectory, recursive: true); } Directory.CreateSymbolicLink(trickplayDirectory, realPath); Logger.LogDebug("Moved {Path} to {RealPath}", trickplayDirectory, realPath); skip = false; return true; } private void CopyDirectory(string source, string destination) { if (!Directory.Exists(destination)) Directory.CreateDirectory(destination); foreach (var file in GetFilePaths(source, true)) { var newFile = Path.Combine(destination, file[(source.Length + 1)..]); var directoryOfFile = Path.GetDirectoryName(newFile)!; if (!Directory.Exists(directoryOfFile)) Directory.CreateDirectory(directoryOfFile); File.Copy(file, newFile, true); } } private static bool ShouldIgnoreFile(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? fileId, [NotNullWhen(true)] out string? seriesId) { var fileName = Path.GetFileNameWithoutExtension(path); if (!fileName.TryGetAttributeValue(ProviderNames.ShokoFile, out fileId) || !int.TryParse(fileId, out _) || !fileName.TryGetAttributeValue(ProviderNames.ShokoSeries, out seriesId) || !int.TryParse(seriesId, out _)) { seriesId = null; fileId = null; return false; } return true; } #endregion #region File System Path private readonly EnumerationOptions _cachedEnumerationOptions = new() { RecurseSubdirectories = false, IgnoreInaccessible = true, AttributesToSkip = 0 }; private bool ContainsFileSystemEntryPaths(string directoryPath) => Directory.EnumerateFileSystemEntries(directoryPath, "*", _cachedEnumerationOptions).Any(); public string[] GetFilePaths(string directoryPath, bool recursive = false, string[]? extensions = null, Func<string, bool, bool>? filter = null, CancellationToken cancellationToken = default) => DataCache.GetOrCreate( (nameof(GetFilePaths), directoryPath, recursive, extensions, filter), () => GetFileSystemEntryPaths(directoryPath, recursive, extensions, filter, outputFiles: true, outputDirectories: false, cancellationToken: cancellationToken), cancellationToken: cancellationToken ); public string[] GetFileSystemEntryPaths(string directoryPath, bool recursive = false, IEnumerable<string>? extensions = null, Func<string, bool, bool>? filter = null, CancellationToken cancellationToken = default) => DataCache.GetOrCreate( (nameof(GetFileSystemEntryPaths), directoryPath, recursive, extensions, filter), () => GetFileSystemEntryPaths(directoryPath, recursive, extensions, filter, outputFiles: true, outputDirectories: true, cancellationToken: cancellationToken), cancellationToken: cancellationToken ); private string[] GetFileSystemEntryPaths(string directoryPath, bool recursive = false, IEnumerable<string>? extensions = null, Func<string, bool, bool>? filter = null, bool outputFiles = true, bool outputDirectories = true, CancellationToken cancellationToken = default) { if (!Directory.Exists(directoryPath)) return []; Logger.LogTrace("Enumerating directory. (Path={Path})", directoryPath); var startedAt = DateTime.UtcNow; var outputBag = new ConcurrentBag<string>(); var canOutputPath = GetPathValidator(extensions, filter); Parallelize(directoryPath, path => { if (outputFiles) { foreach (var file in Directory.EnumerateFiles(path, "*", _cachedEnumerationOptions)) { if (canOutputPath(file, false)) { outputBag.Add(file); } } } if (outputDirectories || recursive) { var outputs = new List<string>(); foreach (var directory in Directory.EnumerateDirectories(path, "*", _cachedEnumerationOptions)) { if (outputDirectories && canOutputPath(directory, true)) { outputBag.Add(directory); } if (recursive && Path.GetExtension(directory) is not TrickplayExtensionName) { outputs.Add(directory); } } return outputs; } return null; }, cancellationToken).Wait(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); Logger.LogTrace("Enumerated {FileCount} outputs in directory in {Elapsed}. (Path={Path})", outputBag.Count, DateTime.UtcNow - startedAt, directoryPath); return outputBag.ToArray(); } private static Func<string, bool, bool> GetPathValidator(IEnumerable<string>? extensions, Func<string, bool, bool>? filter) { if (extensions is null) return filter ?? ((_, _) => true); var extensionSet = new HashSet<string>(extensions, StringComparer.OrdinalIgnoreCase); if (filter is not null) return (path, isDirectory) => (Path.GetExtension(path) is { Length: > 0 } ext) && extensionSet.Contains(Path.GetExtension(path)) && filter(path, isDirectory); return (path, _) => (Path.GetExtension(path) is { Length: > 0 } ext) && extensionSet.Contains(Path.GetExtension(path)); } #endregion #region Parallelize private int GetThreadCount() => Plugin.Instance.Configuration.VFS_Threads is > 0 ? Plugin.Instance.Configuration.VFS_Threads : Plugin.Instance.Configuration.VFS_Threads is -1 ? ConfigurationManager.Configuration.LibraryScanFanoutConcurrency : Environment.ProcessorCount; private Task Parallelize<T>(T initialValue, Func<T, IEnumerable<T>?> action, CancellationToken cancellationToken = default) { var pendingCount = 1; var bufferBlock = new BufferBlock<T>(new() { BoundedCapacity = DataflowBlockOptions.Unbounded }); var actionBlock = new ActionBlock<T>( inputValue => { try { var output = action(inputValue) ?? []; foreach (var outputAction in output) { Interlocked.Increment(ref pendingCount); bufferBlock.Post(outputAction); } } finally { if (Interlocked.Decrement(ref pendingCount) == 0) { bufferBlock.Complete(); } } }, new() { CancellationToken = cancellationToken, MaxDegreeOfParallelism = GetThreadCount(), BoundedCapacity = DataflowBlockOptions.Unbounded } ); bufferBlock.LinkTo(actionBlock, new() { PropagateCompletion = true }); bufferBlock.Post(initialValue); return actionBlock.Completion; } private Task Parallelize<T>(IEnumerable<T> items, Func<T, Task> action, CancellationToken cancellationToken = default) { var bufferBlock = new BufferBlock<T>(new() { BoundedCapacity = DataflowBlockOptions.Unbounded }); var actionBlock = new ActionBlock<T>( action, new() { CancellationToken = cancellationToken, MaxDegreeOfParallelism = GetThreadCount(), BoundedCapacity = DataflowBlockOptions.Unbounded } ); bufferBlock.LinkTo(actionBlock, new() { PropagateCompletion = true }); foreach (var item in items) { bufferBlock.Post(item); } bufferBlock.Complete(); return actionBlock.Completion; } private Task Parallelize<T>(IEnumerable<T> items, Action<T> action, CancellationToken cancellationToken = default) { var bufferBlock = new BufferBlock<T>(new() { BoundedCapacity = DataflowBlockOptions.Unbounded }); var actionBlock = new ActionBlock<T>( action, new() { CancellationToken = cancellationToken, MaxDegreeOfParallelism = GetThreadCount(), BoundedCapacity = DataflowBlockOptions.Unbounded } ); bufferBlock.LinkTo(actionBlock, new() { PropagateCompletion = true }); foreach (var item in items) { bufferBlock.Post(item); } bufferBlock.Complete(); return actionBlock.Completion; } #endregion } ================================================ FILE: Shokofin/Shokofin.csproj ================================================ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFrameworks>net9.0;net8</TargetFrameworks> <OutputType>Library</OutputType> <Nullable>enable</Nullable> </PropertyGroup> <!-- Exclude all DLLs from referenced projects by default --> <ItemDefinitionGroup> <ProjectReference> <Private>false</Private> </ProjectReference> </ItemDefinitionGroup> <!-- Jellyfin Core --> <ItemGroup Condition="'$(TargetFramework)' == 'net9.0'"> <PackageReference Include="Jellyfin.Controller" Version="10.11.0" TargetFramework="net9.0" /> </ItemGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net8'"> <PackageReference Include="Jellyfin.Controller" Version="10.10.0" TargetFramework="net8" /> </ItemGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net9.0'"> <!-- Shared dependencies with Jellyfin Core --> <PackageReference Include="AsyncKeyedLock" Version="7.1.7" /> <PackageReference Include="DotNet.Glob" Version="3.1.3" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" /> <!-- Own dependencies we need to output with the package --> <CommonPackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.10" TargetFramework="net9.0" /> <CommonPackageReference Include="Microsoft.AspNetCore.SignalR.Client.Core" Version="9.0.10" TargetFramework="net9.0" /> <CommonPackageReference Include="Microsoft.AspNetCore.Http.Connections.Client" Version="9.0.10" TargetFramework="net9.0" /> <CommonPackageReference Include="System.Net.ServerSentEvents" Version="9.0.10" TargetFramework="net9.0" /> <PackageReference Include="@(CommonPackageReference)" /> </ItemGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net8'"> <!-- Shared dependencies with Jellyfin Core --> <PackageReference Include="AsyncKeyedLock" Version="6.4.2" /> <PackageReference Include="DotNet.Glob" Version="3.1.2" /> <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> <!-- Own dependencies we need to output with the package --> <CommonPackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.7" TargetFramework="net8" /> <CommonPackageReference Include="Microsoft.AspNetCore.SignalR.Client.Core" Version="8.0.7" TargetFramework="net8" /> <CommonPackageReference Include="Microsoft.AspNetCore.Http.Connections.Client" Version="8.0.7" TargetFramework="net8" /> <PackageReference Include="@(CommonPackageReference)" /> </ItemGroup> <!-- Code analyzers --> <ItemGroup Condition="'$(TargetFramework)' == 'net9.0'"> <PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> <!-- HTML / JS --> <ItemGroup> <None Remove="Pages\**\*" /> <EmbeddedResource Include="Pages\**\*" /> </ItemGroup> <Target Name="CopyExtraOutputAssembliesToOutputPath" AfterTargets="Build"> <GetPublicPackageReferences NuGetPackageRoot="$(NuGetPackageRoot)" PackageReference="@(CommonPackageReference)"> <Output TaskParameter="Results" ItemName="ExtraOutputAssemblies" /> </GetPublicPackageReferences> <Copy SourceFiles="@(ExtraOutputAssemblies)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" /> </Target> <UsingTask TaskName="GetPublicPackageReferences" TaskFactory="RoslynCodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core"> <ParameterGroup> <NuGetPackageRoot ParameterType="System.String" Required="true" /> <PackageReference ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="true" /> <Results Output="true" ParameterType="Microsoft.Build.Framework.ITaskItem[]" /> </ParameterGroup> <Task> <Using Namespace="System.IO" /> <Using Namespace="System.Linq" /> <Using Namespace="Microsoft.Build.Framework" /> <Code Type="Fragment" Language="cs"><![CDATA[ Results = PackageReference .Select(pkg => new TaskItem( Path.Combine( NuGetPackageRoot, pkg.ItemSpec.ToLowerInvariant(), pkg.GetMetadata("Version"), "lib", pkg.GetMetadata("TargetFramework") == "net8" ? "net8.0" : pkg.GetMetadata("TargetFramework"), pkg.ItemSpec + ".dll" ) )) .ToArray(); ]]></Code> </Task> </UsingTask> </Project> ================================================ FILE: Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs ================================================ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Shokofin.API.Converters; 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"), JsonConverter(typeof(JsonAutoStringConverter))] public string ProviderId { get; set; } = string.Empty; /// <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; } = []; /// <summary> /// Shoko series ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] public List<int> SeriesIds { get; set; } = []; #region IMetadataUpdatedEventArgs Impl. BaseItemKind IMetadataUpdatedEventArgs.Kind => BaseItemKind.Episode; int? IMetadataUpdatedEventArgs.ProviderParentId => ProviderParentId; IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => EpisodeIds; IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; #endregion } ================================================ FILE: Shokofin/SignalR/Models/FileEventArgs.cs ================================================ 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 => ManagedFolderId; set => ManagedFolderId = value; } /// <inheritdoc/> [JsonInclude, JsonPropertyName("ManagedFolderID")] public int ManagedFolderId { 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; } = []; #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 } ================================================ FILE: Shokofin/SignalR/Models/FileMovedEventArgs.cs ================================================ 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 => PreviousManagedFolderId; set => PreviousManagedFolderId = value; } /// <inheritdoc/> [JsonInclude, JsonPropertyName("PreviousManagedFolderID")] public int PreviousManagedFolderId { 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; } } } ================================================ FILE: Shokofin/SignalR/Models/FileRenamedEventArgs.cs ================================================ 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 PreviousManagedFolderId => ManagedFolderId; /// <inheritdoc/> [JsonIgnore] public string PreviousRelativePath => RelativePath[..^FileName.Length] + PreviousFileName; } ================================================ FILE: Shokofin/SignalR/Models/MovieInfoUpdatedEventArgs.cs ================================================ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Shokofin.API.Converters; using Shokofin.Events.Interfaces; namespace Shokofin.SignalR.Models; public class MovieInfoUpdatedEventArgs : 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 movie id. /// </summary> [JsonInclude, JsonPropertyName("MovieID"), JsonConverter(typeof(JsonAutoStringConverter))] public string ProviderId { get; set; } = string.Empty; /// <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; } = []; /// <summary> /// Shoko series ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] public List<int> SeriesIds { get; set; } = []; #region IMetadataUpdatedEventArgs Impl. BaseItemKind IMetadataUpdatedEventArgs.Kind => BaseItemKind.Movie; int? IMetadataUpdatedEventArgs.ProviderParentId => ProviderParentId; IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => EpisodeIds; IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; #endregion } ================================================ FILE: Shokofin/SignalR/Models/ReleaseSavedEventArgs.cs ================================================ using System.Text.Json.Serialization; using Shokofin.Events.Interfaces; namespace Shokofin.SignalR.Models; public class ReleaseSavedEventArgs : IReleaseSavedEventArgs { /// <inheritdoc /> [JsonInclude, JsonPropertyName("FileID")] public int FileId { get; set; } } ================================================ FILE: Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs ================================================ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Shokofin.API.Converters; 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"), JsonConverter(typeof(JsonAutoStringConverter))] public string ProviderId { get; set; } = string.Empty; /// <summary> /// Shoko series ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] public List<int> SeriesIds { get; set; } = []; #region IMetadataUpdatedEventArgs Impl. BaseItemKind IMetadataUpdatedEventArgs.Kind => BaseItemKind.Series; int? IMetadataUpdatedEventArgs.ProviderParentId => null; IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => []; IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; #endregion } ================================================ FILE: Shokofin/SignalR/SignalRConnectionManager.cs ================================================ using System; using System.Linq; using System.Net.Http; using System.Net.Sockets; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; using Shokofin.Events; using Shokofin.Events.Interfaces; using Shokofin.Events.Stub; using Shokofin.Extensions; using Shokofin.SignalR.Models; using Shokofin.Utils; namespace Shokofin.SignalR; public class SignalRConnectionManager { private const string HubUrl = "/signalr/aggregate?feeds=shoko,metadata,file,release"; private readonly ILogger<SignalRConnectionManager> Logger; private readonly ShokoApiClient ApiClient; 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, ShokoApiClient apiClient, EventDispatchService events, LibraryScanWatcher libraryScanWatcher ) { Logger = logger; ApiClient = apiClient; 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(new SignalrRetryPolicy([.. config.SignalR_AutoReconnectInSeconds.Select(seconds => TimeSpan.FromSeconds(seconds))])); var connection = Connection = builder.Build(); connection.ServerTimeout = TimeSpan.FromMinutes(1); connection.Closed += OnDisconnected; connection.Reconnecting += OnReconnecting; connection.Reconnected += OnReconnected; if (Plugin.Instance.Configuration.HasPluginsExposed) { // Attach metadata events. connection.On<EpisodeInfoUpdatedEventArgs>("metadata:episode.added", OnInfoUpdated); connection.On<EpisodeInfoUpdatedEventArgs>("metadata:episode.updated", OnInfoUpdated); connection.On<EpisodeInfoUpdatedEventArgs>("metadata:episode.removed", OnInfoUpdated); connection.On<SeriesInfoUpdatedEventArgs>("metadata:series.added", OnInfoUpdated); connection.On<SeriesInfoUpdatedEventArgs>("metadata:series.updated", OnInfoUpdated); connection.On<SeriesInfoUpdatedEventArgs>("metadata:series.removed", OnInfoUpdated); connection.On<MovieInfoUpdatedEventArgs>("metadata:movie.added", OnInfoUpdated); connection.On<MovieInfoUpdatedEventArgs>("metadata:movie.updated", OnInfoUpdated); connection.On<MovieInfoUpdatedEventArgs>("metadata:movie.removed", OnInfoUpdated); // Attach release events. connection.On<ReleaseSavedEventArgs>("release:saved", OnReleaseSaved); // Attach file events. connection.On<FileEventArgs>("file:deleted", OnFileDeleted); connection.On<FileMovedEventArgs>("file:relocated", OnFileRelocated); } else { // Attach refresh events. connection.On<EpisodeInfoUpdatedEventArgs>("ShokoEvent:EpisodeUpdated", OnInfoUpdated); connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnInfoUpdated); connection.On<MovieInfoUpdatedEventArgs>("ShokoEvent:MovieUpdated", OnInfoUpdated); // Attach file events. connection.On<FileEventArgs>("ShokoEvent:FileMatched", OnFileMatched); connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileRelocated); connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRelocated); } EventSubmitterLease = Events.RegisterEventSubmitter(); try { Logger.LogInformation("Connecting to Shoko Server."); await connection.StartAsync().ConfigureAwait(false); Logger.LogInformation("Connected to Shoko Server."); } catch (HttpRequestException ex) when (ex is { HttpRequestError: HttpRequestError.ConnectionError, InnerException: SocketException { SocketErrorCode: SocketError.ConnectionRefused } }) { Logger.LogWarning("Unable to connect to Shoko Server due to a connection error. Please reconnect manually."); await DisconnectAsync().ConfigureAwait(false); } catch (Exception ex) { Logger.LogError(ex, "An unexpected error occurred while attempting to connect to Shoko Server. 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().ConfigureAwait(false); await connection.DisposeAsync().ConfigureAwait(false); if (EventSubmitterLease is not null) { EventSubmitterLease.Dispose(); EventSubmitterLease = null; } } public Task ResetConnectionAsync() => ResetConnectionAsync(Plugin.Instance.Configuration, true); private void ResetConnection(PluginConfiguration config, bool shouldConnect) => Task.Run(() => ResetConnectionAsync(config, shouldConnect)).GetAwaiter().GetResult(); private async Task ResetConnectionAsync(PluginConfiguration config, bool shouldConnect) { await DisconnectAsync().ConfigureAwait(false); if (shouldConnect) await ConnectAsync(config).ConfigureAwait(false); } public async Task RunAsync() { var config = Plugin.Instance.Configuration; CachedKey = ConstructKey(config); Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; await ResetConnectionAsync(config, config.SignalR_AutoConnectEnabled).ConfigureAwait(false); } public async Task StopAsync() { Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; await DisconnectAsync().ConfigureAwait(false); } 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; {ManagedFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.ManagedFolderId, 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.MetadataUpdated, eventArgs.ManagedFolderId, eventArgs.RelativePath, eventArgs); } private async Task OnReleaseSaved(IReleaseSavedEventArgs eventArgs0) { if (await ApiClient.GetFile(eventArgs0.FileId.ToString()).ConfigureAwait(false) is not { } file) { Logger.LogDebug("File not found; {VideoId}", eventArgs0.FileId); return; } if ((file.Locations.FirstOrDefault(location => location.IsAccessible) ?? file.Locations.FirstOrDefault()) is not { } fileLocation) { Logger.LogDebug("File location not found; {VideoId}", eventArgs0.FileId); return; } var eventArgs = new FileEventArgsStub(fileLocation, file); Logger.LogDebug( "Release saved; {ManagedFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.ManagedFolderId, 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.MetadataUpdated, eventArgs.ManagedFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileRelocated(IFileRelocationEventArgs eventArgs) { Logger.LogDebug( "File relocated; {ManagedFolderIdA} {PathA} → {ManagedFolderIdB} {PathB} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.PreviousManagedFolderId, eventArgs.PreviousRelativePath, eventArgs.ManagedFolderId, 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.MetadataRemoved, eventArgs.PreviousManagedFolderId, eventArgs.PreviousRelativePath, eventArgs); Events.AddFileEvent(eventArgs.FileId, UpdateReason.MetadataAdded, eventArgs.ManagedFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileDeleted(IFileEventArgs eventArgs) { Logger.LogDebug( "File deleted; {ManagedFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.ManagedFolderId, 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.MetadataRemoved, eventArgs.ManagedFolderId, eventArgs.RelativePath, eventArgs); } #endregion #region Refresh Events private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) { if (eventArgs.IsUnknownUpdate) { Logger.LogTrace( "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) skipped event with {UpdateReason}; no operation. (Episode={EpisodeId},Series={SeriesId})", eventArgs.ProviderName, eventArgs.Kind, eventArgs.ProviderId, eventArgs.ProviderParentId, eventArgs.Reason, eventArgs.EpisodeIds, eventArgs.SeriesIds ); return; } if (!Plugin.Instance.Configuration.SignalR_EventSources.Contains(eventArgs.ProviderName)) { Logger.LogTrace( "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) skipped event with {UpdateReason}; provider is not enabled in the plugin settings. (Episode={EpisodeId},Series={SeriesId})", eventArgs.ProviderName, eventArgs.Kind, eventArgs.ProviderId, eventArgs.ProviderParentId, eventArgs.Reason, eventArgs.EpisodeIds, eventArgs.SeriesIds ); return; } Logger.LogDebug( "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) dispatched event with {UpdateReason}. (Episode={EpisodeId},Series={SeriesId})", eventArgs.ProviderName, eventArgs.Kind, eventArgs.ProviderId, eventArgs.ProviderParentId, eventArgs.Reason, eventArgs.EpisodeIds, eventArgs.SeriesIds ); if (LibraryScanWatcher.IsScanRunning) { Logger.LogTrace( "Library scan is running. Skipping emit of refresh event. (Episode={EpisodeId},Series={SeriesId})", eventArgs.EpisodeIds, eventArgs.SeriesIds ); return; } if (eventArgs.Kind is BaseItemKind.Episode or BaseItemKind.Series or BaseItemKind.Movie) Events.AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); } #endregion #endregion } ================================================ FILE: Shokofin/SignalR/SignalREntryPoint.cs ================================================ 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(); } ================================================ FILE: Shokofin/SignalR/SignalRRetryPolicy.cs ================================================ using System; using Microsoft.AspNetCore.SignalR.Client; namespace Shokofin.SignalR; public class SignalrRetryPolicy(TimeSpan[] delays) : IRetryPolicy { public TimeSpan? NextRetryDelay(RetryContext retryContext) { if (delays.Length == 0) return null; var count = retryContext.PreviousRetryCount; var delayInSeconds = count >= delays.Length ? delays[^1] : delays[count]; return delayInSeconds; } } ================================================ FILE: Shokofin/Sync/SyncDirection.cs ================================================ 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, } ================================================ FILE: Shokofin/Sync/UserDataSyncManager.cs ================================================ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; 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 Shokofin.Extensions; using Shokofin.Resolvers; 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 VirtualFileSystemService VfsService; private readonly ShokoApiClient ApiClient; private readonly ShokoIdLookup Lookup; public UserDataSyncManager(IUserDataManager userDataManager, IUserManager userManager, ILibraryManager libraryManager, ISessionManager sessionManager, ILogger<UserDataSyncManager> logger, VirtualFileSystemService vfsService, ShokoApiClient apiClient, ShokoIdLookup lookup) { UserDataManager = userDataManager; UserManager = userManager; LibraryManager = libraryManager; SessionManager = sessionManager; Logger = logger; VfsService = vfsService; ApiClient = apiClient; Lookup = lookup; SessionManager.SessionStarted += OnSessionStarted; SessionManager.SessionEnded += OnSessionEnded; SessionManager.PlaybackStart += OnPlaybackStart; SessionManager.PlaybackStopped += OnPlaybackStopped; UserDataManager.UserDataSaved += OnUserDataSaved; LibraryManager.ItemAdded += OnItemAddedOrUpdated; LibraryManager.ItemUpdated += OnItemAddedOrUpdated; } public void Dispose() { SessionManager.SessionStarted -= OnSessionStarted; SessionManager.SessionEnded -= OnSessionEnded; SessionManager.PlaybackStart -= OnPlaybackStart; SessionManager.PlaybackStopped -= OnPlaybackStopped; UserDataManager.UserDataSaved -= OnUserDataSaved; LibraryManager.ItemAdded -= OnItemAddedOrUpdated; LibraryManager.ItemUpdated -= OnItemAddedOrUpdated; } private static bool TryGetUserConfiguration(Guid userId, [NotNullWhen(true)] 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 current session Id. /// </summary> public string SessionId; /// <summary> /// The current user Id we're tracking for the session. /// </summary> public Guid UserId; /// <summary> /// The currently active item Id. Used to detect when the item changes. /// </summary> public Guid ActiveItemId; /// <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, Guid userId) { Logger = logger; SessionId = sessionInfo.Id; UserId = userId; 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<string, SessionMetadata> ActiveSessions = new(); private IEnumerable<SessionMetadata> GetSessionsForSessionId(string sessionId) { foreach (var session in ActiveSessions.Values) { if (session.SessionId == sessionId) { yield return session; } } } private bool TryGetSessionByUserId(Guid userId, Guid itemId, [NotNullWhen(true)] out SessionMetadata? session) { foreach (var metadata in ActiveSessions.Values) { if (metadata.UserId == userId && (metadata.ActiveItemId == itemId || metadata.ItemId == itemId)) { session = metadata; return true; } } session = null; return false; } public void OnPlaybackStart(object? sender, PlaybackProgressEventArgs e) { foreach (var sessionMetadata in GetSessionsForSessionId(e.Session.Id)) sessionMetadata.ActiveItemId = e.Item.Id; } public void OnPlaybackStopped(object? sender, PlaybackProgressEventArgs e) { foreach (var sessionMetadata in GetSessionsForSessionId(e.Session.Id)) sessionMetadata.ActiveItemId = Guid.Empty; } public void OnSessionStarted(object? sender, SessionEventArgs e) { if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && userConfig.EnableSynchronization && (userConfig.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { var sessionMetadata = new SessionMetadata(Logger, e.SessionInfo, e.SessionInfo.UserId); var key = $"{e.SessionInfo.Id}:{e.SessionInfo.UserId}"; ActiveSessions.TryAdd(key, sessionMetadata); } foreach (var user in e.SessionInfo.AdditionalUsers) { if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && userConfig.EnableSynchronization && (userConfig.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { var sessionMetadata = new SessionMetadata(Logger, e.SessionInfo, user.UserId); var key = $"{e.SessionInfo.Id}:{user.UserId}"; ActiveSessions.TryAdd(key, sessionMetadata); } } } public void OnSessionEnded(object? sender, SessionEventArgs e) { foreach (var session in GetSessionsForSessionId(e.SessionInfo.Id).ToArray()) { ActiveSessions.TryRemove($"{e.SessionInfo.Id}:{session.UserId}", out _); } } public async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) { try { if (e == null || e.Item == null || Guid.Empty == e.UserId || e.UserData == null || !Lookup.IsEnabledForItem(e.Item)) 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) && userConfig.EnableSynchronization && (userConfig.SyncRestrictedVideos || e.Item.CustomRating != "XXX") && Lookup.TryGetFileAndSeriesIdFor(e.Item, out var fileId, out var seriesId) && await ApiClient.GetFile(fileId).ConfigureAwait(false) is { } file && file.CrossReferences.FirstOrDefault(xref0 => xref0.Series.Shoko.HasValue && xref0.Series.Shoko.Value.ToString() == seriesId && xref0.Episodes.Any(xref1 => xref1.Shoko.HasValue)) is { } xref )) return; var episodeId = xref.Episodes.First(xref => xref.Shoko.HasValue).Shoko!.Value.ToString(); 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 (!TryGetSessionByUserId(e.UserId, itemId, 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; 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 (TryGetSessionByUserId(e.UserId, e.Item.Id, 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.TryGetEpisodeIdsFor(video, out var episodeIds)) return; SyncVideo(video, userConfig!, userData, SyncDirection.Export, episodeIds[0]).ConfigureAwait(false); break; } case Season season: { if (!Lookup.TryGetSeasonIdFor(season, out var seasonId)) return; SyncSeason(season, userConfig!, userData, SyncDirection.Export, seasonId).ConfigureAwait(false); break; } case Series series: { if (!Lookup.TryGetSeasonIdFor(series, out var seasonId)) return; SyncSeries(series, userConfig!, userData, SyncDirection.Export, seasonId).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 = [MediaType.Video], IsFolder = false, Recursive = true, DtoOptions = new DtoOptions(false) { EnableImages = false }, SourceTypes = [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.IsEnabledForItem(video) || !Lookup.TryGetFileAndSeriesIdFor(video, out var fileId, out var seriesId)) continue; foreach (var userConfig in enabledUsers) { await SyncVideo(video, userConfig, direction, fileId, seriesId).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.IsEnabledForItem(video) || !Lookup.TryGetFileAndSeriesIdFor(video, out var fileId, out var seriesId)) return; var path = video is Episode ep ? ep.Series?.Path : video is Movie mv ? mv.ContainingFolderPath : video.Path; if (VfsService.TryGetCurrentLibraryGenerationMode(path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { Logger.LogTrace("Skipped video during iterative generation. (Path={Path})", video.Path); return; } foreach (var userConfig in Plugin.Instance.Configuration.UserList) { if (!userConfig.EnableSynchronization) continue; if (!userConfig.SyncUserDataOnImport) continue; SyncVideo(video, userConfig, SyncDirection.Import, fileId, seriesId).ConfigureAwait(false); } break; } case Season season: { if (!season.IndexNumber.HasValue || season.IndexNumber.Value == 0) return; if (!Lookup.IsEnabledForItem(season) || !Lookup.TryGetSeasonIdFor(season, out var seasonId)) return; if (season.Series is not { } series) { Logger.LogTrace("Skipping import user data for season; Unable to find series for season to use. (Season={SeasonId})", seasonId); return; } if (VfsService.TryGetCurrentLibraryGenerationMode(series.Path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { Logger.LogTrace("Skipped season during iterative generation. (Season={SeasonId})", seasonId); return; } if (seasonId[0] is IdPrefix.TmdbShow or IdPrefix.TmdbMovie) { Logger.LogTrace("Skipping import user data for season {SeasonNumber} in series {SeriesName}; Season is not a Shoko Series. (Season={SeasonId})", season.IndexNumber, series.Name, seasonId); return; } foreach (var userConfig in Plugin.Instance.Configuration.UserList) { if (!userConfig.EnableSynchronization) continue; if (!userConfig.SyncUserDataOnImport) continue; SyncSeason(season, userConfig, null, SyncDirection.Import, seasonId).ConfigureAwait(false); } break; } case Series series: { if (!Lookup.IsEnabledForItem(series) || !Lookup.TryGetSeasonIdFor(series, out var mainSeasonId)) return; if (VfsService.TryGetCurrentLibraryGenerationMode(series.Path, out var iterativeGeneration, out var wasGenerated) && iterativeGeneration && !wasGenerated) { Logger.LogTrace("Skipped series during iterative generation. (MainSeason={SeasonId})", mainSeasonId); return; } if (mainSeasonId[0] is IdPrefix.TmdbShow or IdPrefix.TmdbMovie) { Logger.LogTrace("Skipping import user data for Series {SeriesName}; Series is not a Shoko Series. (MainSeason={SeasonId})", series.Name, mainSeasonId); return; } foreach (var userConfig in Plugin.Instance.Configuration.UserList) { if (!userConfig.EnableSynchronization) continue; if (!userConfig.SyncUserDataOnImport) continue; SyncSeries(series, userConfig, null, SyncDirection.Import, mainSeasonId).ConfigureAwait(false); } break; } } } #endregion private Task SyncSeries(Series series, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string seasonId) { var user = UserManager.GetUserById(userConfig.UserId); if (user == null) { return Task.CompletedTask; } // Try to load the user-data if it was not provided userData ??= UserDataManager.GetUserData(user, series); // Create some new user-data if none exists. userData ??= new UserItemData { Key = series.GetUserDataKeys()[0], }; Logger.LogDebug("TODO; {SyncDirection} user data for Series {SeriesName}. (Series={SeriesId})", direction.ToString(), series.Name, seasonId); return Task.CompletedTask; } private Task SyncSeason(Season season, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string seasonId) { var user = UserManager.GetUserById(userConfig.UserId); if (user == null) { return Task.CompletedTask; } // Try to load the user-data if it was not provided userData ??= UserDataManager.GetUserData(user, season); // Create some new user-data if none exists. userData ??= new UserItemData { Key = season.GetUserDataKeys()[0], }; Logger.LogDebug("TODO; {SyncDirection} user data for Season {SeasonNumber} in Series {SeriesName}. (Series={SeriesId})", direction.ToString(), season.IndexNumber, season.SeriesName, seasonId); return Task.CompletedTask; } private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string episodeId) { var user = UserManager.GetUserById(userConfig.UserId); if (user == null) { return Task.CompletedTask; } 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(user, video); // Create some new user-data if none exists. userData ??= new UserItemData { Key = video.GetUserDataKeys()[0], LastPlayedDate = null, }; // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token).ConfigureAwait(false); // 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 seriesId) { try { var user = UserManager.GetUserById(userConfig.UserId); if (user is null || (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX")) { Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (User={UserId},File={FileId},Series={SeriesId})", direction.ToString(), userConfig.UserId, video.Name, fileId, seriesId); return; } var localUserStats = UserDataManager.GetUserData(user, video); var remoteUserStats = await ApiClient.GetFileUserStats(fileId, userConfig.Token).ConfigureAwait(false); bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (User={UserId},File={FileId},Series={SeriesId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, userConfig.UserId, fileId, seriesId, 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).ConfigureAwait(false); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Series={SeriesId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, seriesId); } else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { remoteUserStats = localUserStats.ToFileUserStats(); remoteUserStats = await ApiClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token).ConfigureAwait(false); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Series={SeriesId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, seriesId); } 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(user, video, localUserStats = remoteUserStats.ToUserData(video), UserDataSaveReason.Import, CancellationToken.None); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Series={SeriesId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, seriesId); } // Else merge the remote stats into the local stats entry. else if (!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value) { UserDataManager.SaveUserData(user, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Series={SeriesId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, seriesId); } 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).ConfigureAwait(false); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Series={SeriesId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, seriesId); } // Else import if the remote state is fresher then the local state. else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) { UserDataManager.SaveUserData(user, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Series={SeriesId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, seriesId); } 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; } } ================================================ FILE: Shokofin/Tasks/AutoRefreshMetadataTask.cs ================================================ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.Events; using Shokofin.Utils; namespace Shokofin.Tasks; /// <summary> /// Automatically refresh metadata for entries managed by the plugin. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> public class AutoRefreshMetadataTask(MetadataRefreshService _metadataRefreshService, LibraryScanWatcher _libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Auto-Refresh Metadata"; /// <inheritdoc /> public string Description => "Automatically refresh metadata for entries managed by the plugin. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; /// <inheritdoc /> public string Key => "ShokoAutoRefreshMetadata"; /// <inheritdoc /> public bool IsHidden => false; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { if (_libraryScanWatcher.IsScanRunning) return; using (Plugin.Instance.Tracker.Enter("Auto-Refresh Metadata Task")) { await _metadataRefreshService.AutoRefresh(progress, cancellationToken).ConfigureAwait(false); } } } ================================================ FILE: Shokofin/Tasks/CleanupVirtualRootTask.cs ================================================ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; using Shokofin.Configuration; using Shokofin.Utils; namespace Shokofin.Tasks; /// <summary> /// Clean up any old VFS roots leftover from an outdated install or failed removal of the roots. /// </summary> public class CleanupVirtualRootTask( ILogger<CleanupVirtualRootTask> _logger, ILibraryManager _libraryManager, IFileSystem _fileSystem, MediaFolderConfigurationService _configurationService, LibraryScanWatcher _scanWatcher, UsageTracker _usageTracker ) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Clean up Virtual File System Roots"; /// <inheritdoc /> public string Description => "Clean up 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 => !Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => Plugin.Instance.Configuration.AdvancedMode; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => [ new() { #if NET9_0 Type = TaskTriggerInfoType.StartupTrigger, #else Type = TaskTriggerInfo.TriggerStartup, #endif }, ]; public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { if (_scanWatcher.IsScanRunning) return; if (string.IsNullOrEmpty(Plugin.Instance.Configuration.ApiKey)) return; var id = _usageTracker.Add("Clean up Virtual File System Roots"); try { var mediaFolders = (await _configurationService.GetAvailableMediaFoldersForLibraries().ConfigureAwait(false)) .SelectMany(x => x.mediaList) .ToList(); var start = DateTime.Now; var virtualRoots = Plugin.Instance.AllVirtualRoots .Except([Plugin.Instance.VirtualRoot]) .Where(Directory.Exists) .ToList(); _logger.LogDebug("Found {RemoveCount} VFS roots to remove.", virtualRoots.Count); foreach (var virtualRoot in virtualRoots) { var folderStart = DateTime.Now; _logger.LogTrace("Removing VFS root {Path}.", virtualRoot); Directory.Delete(virtualRoot, true); var perFolderDeltaTime = DateTime.Now - folderStart; _logger.LogTrace("Removed VFS root {Path} in {TimeSpan}.", virtualRoot, perFolderDeltaTime); } var libraryIds = mediaFolders.ToList() .Select(config => config.LibraryId.ToString()) .Distinct() .ToList(); var vfsRoots = _fileSystem.GetDirectories(Plugin.Instance.VirtualRoot, false) .ExceptBy(libraryIds, directoryInfo => directoryInfo.Name) .ToList(); _logger.LogDebug("Found {RemoveCount} VFS library roots to remove.", vfsRoots.Count); foreach (var vfsRoot in vfsRoots) { var folderStart = DateTime.Now; _logger.LogTrace("Removing VFS library root for {Id}.", vfsRoot.Name); Directory.Delete(vfsRoot.FullName, true); var perFolderDeltaTime = DateTime.Now - folderStart; _logger.LogTrace("Removed VFS library root for {Id} in {TimeSpan}.", vfsRoot.Name, perFolderDeltaTime); } var deltaTime = DateTime.Now - start; _logger.LogDebug("Removed {RemoveCount} VFS roots in {TimeSpan}.", vfsRoots.Count, deltaTime); start = DateTime.Now; var addedCount = 0; var fixedCount = 0; var vfsPaths = mediaFolders .DistinctBy(config => config.LibraryId) .Where(config => _libraryManager.GetItemById(config.LibraryId) is Folder) .Select(config => config.Library.VirtualRoot) .ToList(); _logger.LogDebug("Ensuring {TotalCount} VFS roots exist.", vfsPaths.Count); foreach (var vfsPath in vfsPaths) { // For Jellyfin to successfully scan the library we need to // a) make sure it exists so we can add it without Jellyfin throwing a fit, and // b) make sure it's not empty to make sure Jellyfin doesn't skip resolving it. if (!Directory.Exists(vfsPath)) { addedCount++; Directory.CreateDirectory(vfsPath); File.WriteAllText(Path.Join(vfsPath, ".keep"), string.Empty); _logger.LogTrace("Added VFS root: {Path}", vfsPath); } else if (!_fileSystem.GetFileSystemEntryPaths(vfsPath).Any()) { fixedCount++; File.WriteAllText(Path.Join(vfsPath, ".keep"), string.Empty); _logger.LogTrace("Fixed VFS root: {Path}", vfsPath); } } deltaTime = DateTime.Now - start; _logger.LogDebug("Added {AddedCount} missing and fixed {FixedCount} broken VFS roots in {TimeSpan}.", addedCount, fixedCount, deltaTime); } finally { _usageTracker.Remove(id); } } } ================================================ FILE: Shokofin/Tasks/ClearPluginCacheTask.cs ================================================ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.API; using Shokofin.Events; using Shokofin.MergeVersions; 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( ShokoApiManager _apiManager, ShokoApiClient _apiClient, VirtualFileSystemService _vfsService, MergeVersionsManager _mergeVersionsManager, EventDispatchService _eventDispatchService ) : 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 => !Plugin.Instance.Configuration.Debug.ShowInUI; /// <inheritdoc /> public bool IsEnabled => Plugin.Instance.Configuration.Debug.ShowInUI; /// <inheritdoc /> public bool IsLogged => true; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { _apiClient.Clear(); _apiManager.Clear(); _vfsService.Clear(); _mergeVersionsManager.Clear(); _eventDispatchService.Clear(); return Task.CompletedTask; } } ================================================ FILE: Shokofin/Tasks/ExportUserDataTask.cs ================================================ 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(UserDataSyncManager _userSyncManager) : 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 => true; /// <inheritdoc /> public bool IsLogged => true; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) => _userSyncManager.ScanAndSync(SyncDirection.Export, progress, cancellationToken); } ================================================ FILE: Shokofin/Tasks/ImportUserDataTask.cs ================================================ 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(UserDataSyncManager _userSyncManager) : 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 => true; /// <inheritdoc /> public bool IsLogged => true; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) => _userSyncManager.ScanAndSync(SyncDirection.Import, progress, cancellationToken); } ================================================ FILE: Shokofin/Tasks/MergeEpisodesTask.cs ================================================ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; namespace Shokofin.Tasks; /// <summary> /// Merge all episode entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT MANUALLY RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> public class MergeEpisodesTask(MergeVersionsManager _mergeVersionsManager) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Merge Episodes"; /// <inheritdoc /> public string Description => "Merge all episode entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT MANUALLY RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; /// <inheritdoc /> public string Key => "ShokoMergeEpisodes"; /// <inheritdoc /> public bool IsHidden => !Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { using (Plugin.Instance.Tracker.Enter("Merge Episodes Task")) { await _mergeVersionsManager.SplitAndMergeAllEpisodes(progress, cancellationToken).ConfigureAwait(false); } } } ================================================ FILE: Shokofin/Tasks/MergeMoviesTask.cs ================================================ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; namespace Shokofin.Tasks; /// <summary> /// Merge all movie entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT MANUALLY RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. <summary> /// </summary> public class MergeMoviesTask(MergeVersionsManager _mergeVersionsManager) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Merge Movies"; /// <inheritdoc /> public string Description => "Merge all movie entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT MANUALLY RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; /// <inheritdoc /> public string Key => "ShokoMergeMovies"; /// <inheritdoc /> public bool IsHidden => !Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { await _mergeVersionsManager.SplitAndMergeAllMovies(progress, cancellationToken).ConfigureAwait(false); } } } ================================================ FILE: Shokofin/Tasks/PostScanTask.cs ================================================ using System; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; namespace Shokofin.Tasks; public class PostScanTask(ITaskManager taskManager) : ILibraryPostScanTask { /// <inheritdoc /> public Task Run(IProgress<double> progress, CancellationToken token) { if (Plugin.Instance.Configuration.AutoReconstructCollections) { taskManager.CancelIfRunningAndQueue<ReconstructCollectionsTask>(); } if (Plugin.Instance.Configuration.AutoMergeVersions) { taskManager.CancelIfRunningAndQueue<MergeMoviesTask>(); taskManager.CancelIfRunningAndQueue<MergeEpisodesTask>(); } return Task.CompletedTask; } } ================================================ FILE: Shokofin/Tasks/ReconstructCollectionsTask.cs ================================================ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.Collections; namespace Shokofin.Tasks; /// <summary> /// Reconstruct all Shoko collections outside a Library Scan. For debugging and troubleshooting. DO NOT MANUALLY RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> public class ReconstructCollectionsTask(CollectionManager _collectionManager) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Reconstruct Collections"; /// <inheritdoc /> public string Description => "Reconstruct all Shoko collections outside a Library Scan. For debugging and troubleshooting. DO NOT MANUALLY RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; /// <inheritdoc /> public string Key => "ShokoReconstructCollections"; /// <inheritdoc /> public bool IsHidden => !Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { using (Plugin.Instance.Tracker.Enter("Reconstruct Collections Task")) { await _collectionManager.ReconstructCollections(progress, cancellationToken).ConfigureAwait(false); } } } ================================================ FILE: Shokofin/Tasks/SplitEpisodesTask.cs ================================================ 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> /// Split all episode entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> public class SplitEpisodesTask(MergeVersionsManager _mergeVersionsManager, LibraryScanWatcher _libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Split Episodes"; /// <inheritdoc /> public string Description => "Split all episode entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; /// <inheritdoc /> public string Key => "ShokoSplitEpisodes"; /// <inheritdoc /> public bool IsHidden => !Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsEnabled => Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsLogged => true; /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { if (_libraryScanWatcher.IsScanRunning) return; using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { await _mergeVersionsManager.SplitAllEpisodes(progress, cancellationToken).ConfigureAwait(false); } } } ================================================ FILE: Shokofin/Tasks/SplitMoviesTask.cs ================================================ 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> /// Split all movie entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> public class SplitMoviesTask(MergeVersionsManager _mergeVersionsManager, LibraryScanWatcher _libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Split Movies"; /// <inheritdoc /> public string Description => "Split all movie entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; /// <inheritdoc /> public string Key => "ShokoSplitMovies"; /// <inheritdoc /> public bool IsHidden => !Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsEnabled => Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsLogged => true; /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { if (_libraryScanWatcher.IsScanRunning) return; using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { await _mergeVersionsManager.SplitAllMovies(progress, cancellationToken).ConfigureAwait(false); } } } ================================================ FILE: Shokofin/Tasks/SyncUserDataTask.cs ================================================ 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(UserDataSyncManager _userSyncManager) : 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 => true; /// <inheritdoc /> public bool IsLogged => true; /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => []; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { await _userSyncManager.ScanAndSync(SyncDirection.Sync, progress, cancellationToken).ConfigureAwait(false); } } ================================================ FILE: Shokofin/Tasks/VersionCheckTask.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Extensions; 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(ILogger<VersionCheckTask> _logger, ILibraryManager _libraryManager, ShokoApiClient _apiClient) : 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 => !Plugin.Instance.Configuration.AdvancedMode; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => Plugin.Instance.Configuration.AdvancedMode; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => [ new() { #if NET9_0 Type = TaskTriggerInfoType.StartupTrigger, #else Type = TaskTriggerInfo.TriggerStartup, #endif }, ]; /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { try { var updated = false; var version = await _apiClient.GetVersion().ConfigureAwait(false); if (version != null && ( Plugin.Instance.Configuration.ServerVersion == null || !string.Equals(version.ToString(), Plugin.Instance.Configuration.ServerVersion.ToString()) )) { _logger.LogDebug("Found new Shoko Server version; {version}", version); Plugin.Instance.Configuration.ServerVersion = version; updated = true; } if (string.IsNullOrEmpty(Plugin.Instance.Configuration.ApiKey)) return; var prefix = await _apiClient.GetWebPrefix().ConfigureAwait(false); if (prefix != null && ( Plugin.Instance.Configuration.WebPrefix == null || !string.Equals(prefix, Plugin.Instance.Configuration.WebPrefix) )) { _logger.LogDebug("Found new Shoko Server web prefix; {prefix}", prefix); Plugin.Instance.Configuration.WebPrefix = prefix; updated = true; } var hasPluginsExposed = await _apiClient.CheckIfPluginsExposed(cancellationToken).ConfigureAwait(false); if (Plugin.Instance.Configuration.HasPluginsExposed != hasPluginsExposed) { _logger.LogDebug("Plugin based API; {hasPluginsExposed}", hasPluginsExposed); Plugin.Instance.Configuration.HasPluginsExposed = hasPluginsExposed; updated = true; } var mediaFolders = Plugin.Instance.Configuration.LibraryFolders.ToList(); var managedFolderNameMap = await Task .WhenAll( mediaFolders .Select(m => m.ManagedFolderId) .Distinct() .Except([0, -1]) .Select(_apiClient.GetManagedFolder) .ToList() ) .ContinueWith(task => task.Result.WhereNotNull().ToDictionary(i => i.Id, i => i.Name)) .ConfigureAwait(false); foreach (var mediaFolderConfig in mediaFolders) { if (!managedFolderNameMap.TryGetValue(mediaFolderConfig.ManagedFolderId, out var managedFolderName)) managedFolderName = null; if (Guid.Empty == mediaFolderConfig.LibraryId && _libraryManager.GetVirtualFolders().FirstOrDefault(p => p.Locations.Contains(mediaFolderConfig.Path)) is { } library && Guid.TryParse(library.ItemId, out var libraryId)) { _logger.LogDebug("Found new library for media folder; {LibraryName} (Library={LibraryId},MediaFolder={MediaFolderPath})", library.Name, libraryId, mediaFolderConfig.Path); mediaFolderConfig.LibraryId = libraryId; updated = true; } if (!string.IsNullOrEmpty(managedFolderName) && !string.Equals(mediaFolderConfig.ManagedFolderName, managedFolderName)) { _logger.LogDebug("Found new name for managed folder; {name} (ManagedFolder={ManagedFolderId})", managedFolderName, mediaFolderConfig.ManagedFolderId); mediaFolderConfig.ManagedFolderName = managedFolderName; updated = true; } } if (updated) { Plugin.Instance.UpdateConfiguration(); } } catch (Exception ex) { _logger.LogError(ex, "Error while checking Shoko Server version."); } } } ================================================ FILE: Shokofin/Utils/ContentRating.cs ================================================ using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; 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(params TvContentIndicator[] values) : Attribute { public TvContentIndicator[] Values { get; init; } = 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.ContentRatingOrder.Where((t) => Plugin.Instance.Configuration.ContentRatingList.Contains(t)).ToArray(); public static string? GetContentRating(IExtendedItemInfo seasonInfo, string? metadataCountryCode) { metadataCountryCode ??= "US"; foreach (var provider in GetOrderedProviders()) { var source = provider.ToString(); var title = provider switch { ProviderName.AniDB => seasonInfo.ContentRatings.FirstOrDefault(x => x.Country == metadataCountryCode)?.Rating, _ => seasonInfo.ContentRatings.FirstOrDefault(x => x.Source == source && x.Country == metadataCountryCode)?.Rating, }; if (!string.IsNullOrEmpty(title)) return title.Trim(); } return null; } public static string? GetCombinedAnidbContentRating(IEnumerable<SeasonInfo> seasonInfos) { var (contentRating, contentIndicators) = seasonInfos .Select(seasonInfo => seasonInfo.ContentRatings.FirstOrDefault(x => x.Source is "AniDB")?.Rating) .Where(contentRating => !string.IsNullOrEmpty(contentRating)) .Distinct() .Select(text => TryConvertRatingFromText(text, out var cR, out var cI) ? (contentRating: cR, contentIndicators: cI ?? []) : (contentRating: TvRating.None, contentIndicators: [])) .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 = []; 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 : []; 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 ?? []) .Intersect(contentIndicators ?? []) .ToList(); if (allowedIndicators.Count is > 0) { var sb = new StringBuilder(); foreach (var indicator in allowedIndicators) sb.Append(indicator.ToString()); contentRating = $"{contentRating}-{sb}"; } return contentRating; } } ================================================ FILE: Shokofin/Utils/DisposableAction.cs ================================================ using System; namespace Shokofin.Utils; public class DisposableAction : IDisposable { private readonly Action DisposeAction; public DisposableAction(Action disposeAction) { DisposeAction = disposeAction; } public void Dispose() => DisposeAction(); } ================================================ FILE: Shokofin/Utils/GuardedMemoryCache.cs ================================================ 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; internal 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…"); // TODO: Improve this logic. Currently it should only be ran programmatically after all interactions with the cache has been done, but in cases it's cleared before that it may result in a bad state. var cache = Cache; var semaphores = Semaphores; Cache = new MemoryCache(CacheOptions); Semaphores = new(AsyncKeyedLockOptions); semaphores.Dispose(); cache.Dispose(); } public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<TItem> createFactory, MemoryCacheEntryOptions? createOptions = null, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) { foundAction(value); return value; } try { using (Semaphores.Lock(key, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<GuardedMemoryCacheEntryOptions, TItem> createFactory, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) { foundAction(value); return value; } try { using (Semaphores.Lock(key, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); if (TryGetValue(key, out value)) { foundAction(value); return value; } var createOptions = CreateNewOptions(); value = createFactory(createOptions); if (!createOptions.NoCache) { using var entry = Cache.CreateEntry(key); entry.SetOptions(createOptions); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) { foundAction(value); return value; } try { using (await Semaphores.LockAsync(key, cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<GuardedMemoryCacheEntryOptions, Task<TItem>> createFactory, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) { foundAction(value); return value; } try { using (await Semaphores.LockAsync(key, cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); if (TryGetValue(key, out value)) { foundAction(value); return value; } var createOptions = CreateNewOptions(); value = await createFactory(createOptions).ConfigureAwait(false); if (!createOptions.NoCache) { using var entry = Cache.CreateEntry(key); entry.SetOptions(createOptions); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } public TItem GetOrCreate<TItem>(object key, Func<TItem> createFactory, MemoryCacheEntryOptions? createOptions = null, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) return value; try { using (Semaphores.Lock(key, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } public TItem GetOrCreate<TItem>(object key, Func<GuardedMemoryCacheEntryOptions, TItem> createFactory, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) return value; try { using (Semaphores.Lock(key, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); if (TryGetValue(key, out value)) return value; var createOptions = CreateNewOptions(); value = createFactory(createOptions); if (!createOptions.NoCache) { using var entry = Cache.CreateEntry(key); entry.SetOptions(createOptions); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) return value; try { using (await Semaphores.LockAsync(key, cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<GuardedMemoryCacheEntryOptions, Task<TItem>> createFactory, CancellationToken cancellationToken = default) { if (TryGetValue<TItem>(key, out var value)) return value; try { using (await Semaphores.LockAsync(key, cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); if (TryGetValue(key, out value)) return value; var createOptions = CreateNewOptions(); value = await createFactory(createOptions).ConfigureAwait(false); if (!createOptions.NoCache) { using var entry = Cache.CreateEntry(key); entry.SetOptions(createOptions); 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; } catch (Exception ex) { Logger.LogTrace(ex, "Got an unexpected exception for key: {Key}", key); throw; } } private GuardedMemoryCacheEntryOptions CreateNewOptions() => new() { AbsoluteExpiration = CacheEntryOptions?.AbsoluteExpiration is { } aE ? new DateTimeOffset(aE.UtcDateTime.Ticks, aE.Offset) : null, AbsoluteExpirationRelativeToNow = CacheEntryOptions?.AbsoluteExpirationRelativeToNow is { } aER ? new TimeSpan(aER.Ticks) : null, SlidingExpiration = CacheEntryOptions?.SlidingExpiration is { } sE ? new TimeSpan(sE.Ticks) : null, Priority = CacheEntryOptions?.Priority ?? CacheItemPriority.Normal, Size = CacheEntryOptions?.Size, }; 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); internal class GuardedMemoryCacheEntryOptions : MemoryCacheEntryOptions { /// <summary> /// Turns the key into a non-cached lock key to ensure only one thread can process the /// value at a time. /// </summary> public bool NoCache { get; set; } = false; } } ================================================ FILE: Shokofin/Utils/IgnorePatterns.cs ================================================ using System; using DotNet.Globbing; namespace Shokofin.Utils; /// <summary> /// Glob patterns for files to ignore. /// </summary> /// <remarks> /// A one-to-one copy of <see href="https://github.com/jellyfin/jellyfin/blob/b92fc7ea9dbf86437a981c3f0477a7b457977b9a/Emby.Server.Implementations/Library/IgnorePatterns.cs"/> /// since it's not exposed through the abstraction used by plugins and I don't /// care enough to rise a PR to expose it. /// </remarks> public static class IgnorePatterns { /// <summary> /// Files matching these glob patterns will be ignored. /// </summary> private static readonly string[] _patterns = { "**/small.jpg", "**/albumart.jpg", // We have neither non-greedy matching or character group repetitions, working around that here. // https://github.com/dazinator/DotNet.Glob#patterns // .*/sample\..{1,5} "**/sample.?", "**/sample.??", "**/sample.???", // Matches sample.mkv "**/sample.????", // Matches sample.webm "**/sample.?????", "**/*.sample.?", "**/*.sample.??", "**/*.sample.???", "**/*.sample.????", "**/*.sample.?????", "**/sample/*", // Directories "**/metadata/**", "**/metadata", "**/ps3_update/**", "**/ps3_update", "**/ps3_vprm/**", "**/ps3_vprm", "**/extrafanart/**", "**/extrafanart", "**/extrathumbs/**", "**/extrathumbs", "**/.actors/**", "**/.actors", "**/.wd_tv/**", "**/.wd_tv", "**/lost+found/**", "**/lost+found", // Trickplay files "**/*.trickplay", "**/*.trickplay/**", // WMC temp recording directories that will constantly be written to "**/TempRec/**", "**/TempRec", "**/TempSBE/**", "**/TempSBE", // Synology "**/eaDir/**", "**/eaDir", "**/@eaDir/**", "**/@eaDir", "**/#recycle/**", "**/#recycle", // Qnap "**/@Recycle/**", "**/@Recycle", "**/.@__thumb/**", "**/.@__thumb", "**/$RECYCLE.BIN/**", "**/$RECYCLE.BIN", "**/System Volume Information/**", "**/System Volume Information", "**/.grab/**", "**/.grab", // Unix hidden files "**/.*", // Mac - if you ever remove the above. // "**/._*", // "**/.DS_Store", // thumbs.db "**/thumbs.db", // bts sync files "**/*.bts", "**/*.sync", // zfs "**/.zfs/**", "**/.zfs" }; private static readonly GlobOptions _globOptions = new GlobOptions { Evaluation = { CaseInsensitive = true } }; private static readonly Glob[] _globs = Array.ConvertAll(_patterns, p => Glob.Parse(p, _globOptions)); /// <summary> /// Returns true if the supplied path should be ignored. /// </summary> /// <param name="path">The path to test.</param> /// <returns>Whether to ignore the path.</returns> public static bool ShouldIgnore(ReadOnlySpan<char> path) { int len = _globs.Length; for (int i = 0; i < len; i++) { if (_globs[i].IsMatch(path)) { return true; } } return false; } } ================================================ FILE: Shokofin/Utils/ImageUtility.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Shokofin.API.Info; using Shokofin.Configuration; namespace Shokofin.Utils; public static class ImageUtility { #region Episode public static async Task<IReadOnlyCollection<RemoteImageInfo>> GetEpisodeImages(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage, bool displayMode, CancellationToken cancellationToken) { var images = await episodeInfo.GetImages(cancellationToken).ConfigureAwait(false); var originLanguages = TextUtility.GuessOriginLanguage(seasonInfo); var config = seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Image.AnidbEpisode.Enabled ? ( Plugin.Instance.Configuration.Image.AnidbEpisode ) : ( Plugin.Instance.Configuration.Image.Default ), SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Image.TmdbEpisode.Enabled ? ( Plugin.Instance.Configuration.Image.TmdbEpisode ) : ( Plugin.Instance.Configuration.Image.Default ), _ => Plugin.Instance.Configuration.Image.ShokoEpisode.Enabled ? ( Plugin.Instance.Configuration.Image.ShokoEpisode ) : ( Plugin.Instance.Configuration.Image.Default ), }; return [..ProcessEpisodeImages(images, metadataLanguage, originLanguages, displayMode, config).DistinctBy(image => image.Url)]; } #endregion #region Season public static async Task<IReadOnlyCollection<RemoteImageInfo>> GetSeasonImages(SeasonInfo seasonInfo, string? metadataLanguage, bool displayMode, CancellationToken cancellationToken) { var images = await seasonInfo.GetImages(cancellationToken).ConfigureAwait(false); var originLanguages = TextUtility.GuessOriginLanguage(seasonInfo); var config = seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Image.AnidbSeason.Enabled ? ( Plugin.Instance.Configuration.Image.AnidbSeason ) : ( Plugin.Instance.Configuration.Image.Default ), SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Image.TmdbSeason.Enabled ? ( Plugin.Instance.Configuration.Image.TmdbSeason ) : ( Plugin.Instance.Configuration.Image.Default ), _ => Plugin.Instance.Configuration.Image.ShokoSeason.Enabled ? ( Plugin.Instance.Configuration.Image.ShokoSeason ) : ( Plugin.Instance.Configuration.Image.Default ), }; return [..ProcessSeriesImages(images, metadataLanguage, originLanguages, displayMode, config).DistinctBy(image => image.Url)]; } #endregion #region Show public static async Task<IReadOnlyCollection<RemoteImageInfo>> GetShowImages(ShowInfo showInfo, string? metadataLanguage, bool displayMode, CancellationToken cancellationToken) { var imagesList = new List<API.Models.Images> { await showInfo.GetImages(cancellationToken).ConfigureAwait(false) }; // Also attach any images linked to the "seasons" if it's not a standalone series. if (!showInfo.IsStandalone) { foreach (var seasonInfo in showInfo.SeasonList) { imagesList.Add(await seasonInfo.GetImages(cancellationToken).ConfigureAwait(false)); } } var images = CombineImages(imagesList); var originLanguages = TextUtility.GuessOriginLanguage(showInfo.DefaultSeason); var config = showInfo.DefaultSeason.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Image.AnidbAnime.Enabled ? ( Plugin.Instance.Configuration.Image.AnidbAnime ) : ( Plugin.Instance.Configuration.Image.Default ), SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Image.TmdbShow.Enabled ? ( Plugin.Instance.Configuration.Image.TmdbShow ) : ( Plugin.Instance.Configuration.Image.Default ), _ => Plugin.Instance.Configuration.Image.ShokoSeries.Enabled ? ( Plugin.Instance.Configuration.Image.ShokoSeries ) : ( Plugin.Instance.Configuration.Image.Default ), }; return [..ProcessSeriesImages(images, metadataLanguage, originLanguages, displayMode, config).DistinctBy(image => image.Url)]; } private static API.Models.Images CombineImages(IEnumerable<API.Models.Images> imagesList) { var images = new API.Models.Images(); var ignorePreferred = false; foreach (var otherImages in imagesList) { images.Posters.AddRange(otherImages.Posters.Select(image => image.IsPreferred && ignorePreferred ? new(image) { IsPreferred = false } : image)); images.Backdrops.AddRange(otherImages.Backdrops.Select(image => image.IsPreferred && ignorePreferred ? new(image) { IsPreferred = false } : image)); images.Banners.AddRange(otherImages.Banners.Select(image => image.IsPreferred && ignorePreferred ? new(image) { IsPreferred = false } : image)); images.Logos.AddRange(otherImages.Logos.Select(image => image.IsPreferred && ignorePreferred ? new(image) { IsPreferred = false } : image)); ignorePreferred = true; } return images; } #endregion #region Movie public static async Task<IReadOnlyCollection<RemoteImageInfo>> GetMovieImages(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage, bool displayMode, CancellationToken cancellationToken) { var images = await episodeInfo.GetImages(cancellationToken).ConfigureAwait(false); var originLanguages = TextUtility.GuessOriginLanguage(seasonInfo); var config = seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Image.AnidbSeason.Enabled ? ( Plugin.Instance.Configuration.Image.AnidbSeason ) : ( Plugin.Instance.Configuration.Image.Default ), SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Image.TmdbSeason.Enabled ? ( Plugin.Instance.Configuration.Image.TmdbSeason ) : ( Plugin.Instance.Configuration.Image.Default ), _ => Plugin.Instance.Configuration.Image.ShokoSeason.Enabled ? ( Plugin.Instance.Configuration.Image.ShokoSeason ) : ( Plugin.Instance.Configuration.Image.Default ), }; return [..ProcessSeriesImages(images, metadataLanguage, originLanguages, displayMode, config).DistinctBy(image => image.Url)]; } #endregion #region Collection public static async Task<IReadOnlyCollection<RemoteImageInfo>> GetCollectionImages(SeasonInfo seasonInfo, string? metadataLanguage, bool displayMode, CancellationToken cancellationToken) { var images = await seasonInfo.GetImages(cancellationToken).ConfigureAwait(false); var originLanguages = TextUtility.GuessOriginLanguage(seasonInfo); var config = seasonInfo.StructureType switch { SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Image.TmdbCollection.Enabled ? ( Plugin.Instance.Configuration.Image.TmdbCollection ) : ( Plugin.Instance.Configuration.Image.Default ), _ => Plugin.Instance.Configuration.Image.ShokoCollection.Enabled ? ( Plugin.Instance.Configuration.Image.ShokoCollection ) : ( Plugin.Instance.Configuration.Image.Default ) }; return [..ProcessSeriesImages(images, metadataLanguage, originLanguages, displayMode, config).DistinctBy(image => image.Url)]; } public static async Task<IReadOnlyCollection<RemoteImageInfo>> GetCollectionImages(ShowInfo showInfo, string? metadataLanguage, bool displayMode, CancellationToken cancellationToken) { var images = await showInfo.GetImages(cancellationToken).ConfigureAwait(false); var originLanguages = TextUtility.GuessOriginLanguage(showInfo.DefaultSeason); var config = Plugin.Instance.Configuration.Image.ShokoCollection.Enabled ? ( Plugin.Instance.Configuration.Image.ShokoCollection ) : ( Plugin.Instance.Configuration.Image.Default ); return [..ProcessSeriesImages(images, metadataLanguage, originLanguages, displayMode, config).DistinctBy(image => image.Url)]; } #endregion #region Process Images private const int Over9K = 9001; private static IEnumerable<RemoteImageInfo> ProcessEpisodeImages(API.Models.EpisodeImages images, string? metadataLanguage, string[] originLanguages, bool displayMode, ImageConfiguration config) { // Set to english if not set to match Jellyfin's internal logic. if (string.IsNullOrWhiteSpace(metadataLanguage)) metadataLanguage = "en"; var thumbnails = images.Thumbnails.Count > 0 ? images.Thumbnails : images.Backdrops; foreach (var image in ProcessImages(thumbnails, ImageType.Primary, metadataLanguage, originLanguages, displayMode, config, config.GetOrderedBackdropTypes())) yield return image; } private static IEnumerable<RemoteImageInfo> ProcessSeriesImages(API.Models.Images images, string? metadataLanguage, string[] originLanguages, bool displayMode, ImageConfiguration config) { // Set to english if not set to match Jellyfin's internal logic. if (string.IsNullOrWhiteSpace(metadataLanguage)) metadataLanguage = "en"; foreach (var image in ProcessImages(images.Posters, ImageType.Primary, metadataLanguage, originLanguages, displayMode, config, config.GetOrderedPosterTypes())) yield return image; foreach (var image in ProcessImages(images.Logos, ImageType.Logo, metadataLanguage, originLanguages, displayMode, config, config.GetOrderedLogoTypes())) yield return image; foreach (var image in ProcessImages(images.Banners, ImageType.Banner, metadataLanguage, originLanguages, displayMode, config, config.GetOrderedBackdropTypes())) yield return image; foreach (var image in ProcessImages(images.Backdrops, ImageType.Backdrop, metadataLanguage, originLanguages, displayMode, config, config.GetOrderedBackdropTypes())) yield return image; } private static IEnumerable<RemoteImageInfo> ProcessImages(IReadOnlyList<API.Models.Image> images, ImageType imageType, string metadataLanguage, string[] originLanguages, bool displayMode, ImageConfiguration config, IReadOnlyList<ImageLanguageType> orderedTypes) { var filteredImages = images .Select(image => (image, type: GetTypeForImage(image, metadataLanguage, originLanguages))); if (!displayMode && orderedTypes.Count > 0) filteredImages = filteredImages .Where(tuple => config.UsePreferred && tuple.image.IsPreferred || orderedTypes.Contains(tuple.type)); var orderedImages = filteredImages .OrderByDescending(tuple => !config.UsePreferred || tuple.image.IsPreferred) .ThenBy(tuple => orderedTypes.IndexOf(tuple.type)) .ThenByDescending(tuple => config.UseCommunityRating ? (tuple.image.CommunityRating?.ToFloat(10) ?? 0, tuple.image.CommunityRating?.Votes ?? 0) : (0, 0) ) .ToList(); // Ensure we have at least 1 poster available if we filtered the list and we're looking for a primary image. if ( !displayMode && orderedImages.Count == 0 && orderedTypes.Count > 0 && imageType is ImageType.Primary && images.Any(image => image is { Source: API.Models.ImageSource.AniDB, Type: API.Models.ShokoImageType.Poster, IsAvailable: true }) ) orderedImages = [(images.First(image => image is { Source: API.Models.ImageSource.AniDB, Type: API.Models.ShokoImageType.Poster, IsAvailable: true }), ImageLanguageType.None)]; var index = orderedImages.Count - 1; var useDimensions = config.UseDimensions; foreach (var (image, _) in orderedImages) { var remoteImage = SelectImage(image, imageType, metadataLanguage, displayMode, useDimensions, index); if (remoteImage is not null) yield return remoteImage; index--; } } private static ImageLanguageType GetTypeForImage(API.Models.Image image, string metadataLanguage, string[] originLanguages) { if (string.IsNullOrEmpty(image.LanguageCode)) return ImageLanguageType.None; if (string.Equals(image.LanguageCode, metadataLanguage, StringComparison.OrdinalIgnoreCase)) return ImageLanguageType.Metadata; if (originLanguages.Contains(image.LanguageCode, StringComparison.OrdinalIgnoreCase)) return ImageLanguageType.Original; if (string.Equals(image.LanguageCode, "en", StringComparison.OrdinalIgnoreCase) || string.Equals(image.LanguageCode, "eng", StringComparison.OrdinalIgnoreCase)) return ImageLanguageType.English; return ImageLanguageType.Unknown; } private static RemoteImageInfo? SelectImage(API.Models.Image? image, ImageType imageType, string? metadataLanguage, bool displayMode, bool useDimensions, int index) { if (image is not { IsAvailable: true }) return null; var remoteImage = new RemoteImageInfo { ProviderName = $"{image.Source.ToString().Replace("TMDB", "TheMovieDb")} ({Plugin.MetadataProviderName})", Type = imageType, Url = image.ToURLString(), }; if (displayMode) { remoteImage.Width = image.Width; remoteImage.Height = image.Height; remoteImage.Language = image.LanguageCode; if (image.CommunityRating is { } rating) { remoteImage.CommunityRating = rating.ToFloat(10); remoteImage.VoteCount = rating.Votes; remoteImage.RatingType = RatingType.Score; } } else { remoteImage.Width = useDimensions ? image.Width : null; remoteImage.Height = useDimensions ? image.Height : null; remoteImage.Language = metadataLanguage; remoteImage.CommunityRating = Over9K + index; remoteImage.VoteCount = Over9K + index; remoteImage.RatingType = RatingType.Score; } return remoteImage; } #endregion } ================================================ FILE: Shokofin/Utils/LibraryScanWatcher.cs ================================================ 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); } } ================================================ FILE: Shokofin/Utils/Ordering.cs ================================================ using System; using System.Linq; using Jellyfin.Extensions; using Shokofin.API.Info; using Shokofin.API.Models; using Shokofin.API.Models.AniDB; using Shokofin.Extensions; using ExtraType = MediaBrowser.Model.Entities.ExtraType; namespace Shokofin.Utils; public class Ordering { /// <summary> /// Library operation mode. /// </summary> public enum LibraryOperationMode { /// <summary> /// Will use the Virtual File System (VFS) on the library. /// </summary> VFS = 0, /// <summary> /// Will use legacy filtering in strict mode, which will only allow /// files/folders that are recognized and it knows should be part of the /// library. /// </summary> Strict = 1, /// <summary> /// Obsolete. Use <see cref="Strict"/> instead. /// </summary> /// TODO: Break this during the next major version of the plugin. Auto = Strict, /// <summary> /// Will use legacy filtering in lax mode, which 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> /// No ordering. /// </summary> None = -1, /// <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> /// Only for use with the series settings. /// </summary> None = -1, /// <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> /// Obsolete. Use <see cref="Excluded" /> instead. /// </summary> /// TODO: Break this during the next major version of the plugin. Default = Excluded, /// <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 when the episodes aired. /// </summary> InBetweenSeasonByAirDate = 4, /// <summary> /// Place the specials in-between normal episodes based upon data from 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. (MainSeason={showInfo.Id},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. (MainSeason={showInfo.Id},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. (MainSeason={showInfo.Id},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. (MainSeason={showInfo.Id},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.Title}\". (MainSeason=\"{showInfo.Id}\",Series=\"{seasonInfo.Id}\",ExtraSeries={(seasonInfo.ExtraIds.Count > 0 ? $"[\"{seasonInfo.ExtraIds.Join("\",\"")}\"]" : "[]")},Episode={episodeInfo.Id})"); return index + 1; } public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) { // Return early if we want to exclude them from the normal seasons. if (seasonInfo.SpecialsPlacement is SpecialOrderType.Excluded) { // Check if this should go in the specials season. return (null, null, null, showInfo.IsSpecial(episodeInfo)); } // Abort if episode is not a TMDB special or AniDB special if (!showInfo.IsSpecial(episodeInfo)) return (null, null, null, false); int seasonNumber = GetSeasonNumber(showInfo, seasonInfo, episodeInfo); int? airsBeforeEpisodeNumber = null; int? airsBeforeSeasonNumber = null; int? airsAfterSeasonNumber = null; switch (seasonInfo.SpecialsPlacement) { default: airsAfterSeasonNumber = seasonNumber; break; case SpecialOrderType.InBetweenSeasonMixed: case SpecialOrderType.InBetweenSeasonByAirDate: // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. int? episodeNumber = null; if (seasonInfo.SpecialsBeforeEpisodes.Contains(episodeInfo.Id)) { airsBeforeSeasonNumber = seasonNumber; break; } if (seasonInfo.SpecialsAnchors.TryGetValue(episodeInfo.Id, 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.InBetweenSeasonByOtherData: 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(AnidbEpisode episode) { switch (episode.Type) { case EpisodeType.Episode: return null; case EpisodeType.Credits: case EpisodeType.OpeningSong: case EpisodeType.EndingSong: return ExtraType.ThemeVideo; case EpisodeType.Trailer: return ExtraType.Trailer; case EpisodeType.Other: case EpisodeType.Special: { var title = TextUtility.GetTitleForLanguage(episode.Titles, false, false, "en"); if (string.IsNullOrEmpty(title)) return null; // Interview if (title.Contains("interview", StringComparison.OrdinalIgnoreCase)) return ExtraType.Interview; // Cinema/theatrical intro/outro if ( (title.StartsWith("cinema ", StringComparison.OrdinalIgnoreCase) || title.StartsWith("theatrical ", StringComparison.OrdinalIgnoreCase)) && (title.Contains("intro", StringComparison.OrdinalIgnoreCase) || title.Contains("outro", StringComparison.OrdinalIgnoreCase)) || title.Contains("manners movie", StringComparison.OrdinalIgnoreCase) ) return ExtraType.Clip; // Special endings for episodes if (title.StartsWith("episode", StringComparison.OrdinalIgnoreCase) && title.Contains("ending")) return ExtraType.Clip; // Behind the Scenes if (title.Contains("behind the scenes", StringComparison.OrdinalIgnoreCase)) return ExtraType.BehindTheScenes; if (title.Contains("making of", StringComparison.OrdinalIgnoreCase)) return ExtraType.BehindTheScenes; if (title.Contains("music in", StringComparison.OrdinalIgnoreCase)) return ExtraType.BehindTheScenes; if (title.Contains("advance screening", StringComparison.OrdinalIgnoreCase)) return ExtraType.BehindTheScenes; if (title.Contains("premiere", StringComparison.OrdinalIgnoreCase)) return ExtraType.BehindTheScenes; if (title.Contains("talk show", StringComparison.OrdinalIgnoreCase)) return ExtraType.Featurette; return null; } default: return ExtraType.Unknown; } } } ================================================ FILE: Shokofin/Utils/PropertyWatcher.cs ================================================ 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).ConfigureAwait(false); CheckForChange(); } }); } public void StopMonitoring() { _continueMonitoring = false; } private void CheckForChange() { var currentValue = _valueGetter()!; if (!Value!.Equals(currentValue)) { ValueChanged?.Invoke(null, currentValue); Value = currentValue; } } } ================================================ FILE: Shokofin/Utils/SeriesInfoRelationComparer.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using Shokofin.API.Info; using Shokofin.API.Models; namespace Shokofin.Utils; public class SeriesInfoRelationComparer(bool useIndirect) : 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 (useIndirect) { 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.PremiereDate, b.PremiereDate); } 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) => a.HasValue ? b.HasValue ? DateTime.Compare(a.Value, b.Value) : 1 : b.HasValue ? -1 : 0; } ================================================ FILE: Shokofin/Utils/TagFilter.cs ================================================ 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 : ulong { /// <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> /// TMDB Keywords. /// </summary> TmdbKeywords = 1 << 28, /// <summary> /// TMDB Genres. /// </summary> TmdbGenres = 1 << 29, /// <summary> /// Custom user tags. /// </summary> [TagSourceInclude("/custom user tags")] CustomTags = 1 << 30, /// <summary> /// The first yearly season for the series. /// </summary> FirstYearlySeason = 1L << 31, /// <summary> /// All yearly seasons for the series, including the first. Useful /// if the anime spans multiple yearly seasons. E.g. Winter 2011/12, /// Spring 2012, etc.. /// </summary> AllYearlySeasons = 1L << 32, } [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.ProductionLocationOrder.Where((t) => Plugin.Instance.Configuration.ProductionLocationList.Contains(t)).ToArray(); public static string[] GetProductionLocations(IExtendedItemInfo seasonInfo) { foreach (var provider in GetOrderedProductionLocationProviders()) { if (seasonInfo.ProductionLocations.TryGetValue(provider, out var locations) && locations.Count > 0) return [.. locations]; } return []; } public static string[] FilterTags(IReadOnlyDictionary<string, ResolvedTag> tags) { var config = Plugin.Instance.Configuration; return FilterInternal(tags, config.TagSources, config.TagIncludeFilters, config.TagMinimumWeight, config.TagMaximumDepth, config.TagExcludeList); } public static string[] FilterGenres(IReadOnlyDictionary<string, ResolvedTag> tags) { var config = Plugin.Instance.Configuration; return FilterInternal(tags, config.GenreSources, config.GenreIncludeFilters, config.GenreMinimumWeight, config.GenreMaximumDepth, config.GenreExcludeList); } private static readonly HashSet<TagSource> AllFlagsToUse = Enum.GetValues<TagSource>().Except([TagSource.CustomTags]).ToHashSet(); private static readonly HashSet<TagSource> AllFlagsToUseForCustomTags = AllFlagsToUse.Except([TagSource.SourceMaterial, TagSource.TargetAudience]).ToHashSet(); private static string[] FilterInternal(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight = TagWeight.Weightless, int maxDepth = 0, IReadOnlyCollection<string>? excludedTags = null) { var tagSet = new List<string>(); foreach (var flag in AllFlagsToUse.Where(flag => source.HasFlag(flag))) tagSet.AddRange(GetTagsFromSource(tags, flag, includeFilter, minWeight, maxDepth, excludedTags ?? [])); 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, excludedTags ?? [])); } return tagSet .Distinct() .OrderBy(a => a) .ToArray(); } private static HashSet<string> GetTagsFromSource(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight, int maxDepth, IReadOnlyCollection<string> excludedTags) { if (source is TagSource.SourceMaterial) return [GetSourceMaterial(tags)]; var tagSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); 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)); } if (excludedTags.Count > 0) tagSet.ExceptWith(excludedTags); 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 []; 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" => ["China"], "french-chinese co-production" => ["France", "China"], "french-japanese co-production" => ["Japan", "France"], "indo-japanese co-production" => ["Japan", "India"], "japanese production" => ["Japan"], "korean-japanese co-production" => ["Japan", "Republic of Korea"], "north korean production" => ["Democratic People's Republic of Korea"], "polish-japanese co-production" => ["Japan", "Poland"], "russian-japanese co-production" => ["Japan", "Russia"], "saudi arabian-japanese co-production" => ["Japan", "Saudi Arabia"], "italian-japanese co-production" => ["Japan", "Italy"], "singaporean production" => ["Singapore"], "sino-japanese co-production" => ["Japan", "China"], "south korea production" => ["Republic of Korea"], "taiwanese production" => ["Taiwan"], "thai production" => ["Thailand"], _ => [], }); } 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); } ================================================ FILE: Shokofin/Utils/TextUtility.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; using Shokofin.API; using Shokofin.API.Info; using Shokofin.API.Models; using Shokofin.Configuration; using Shokofin.Extensions; namespace Shokofin.Utils; public static partial class TextUtility { private static readonly HashSet<char> PunctuationMarks = [ // 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 ]; internal static readonly HashSet<string> IgnoredSubTitles = new(StringComparer.InvariantCultureIgnoreCase) { "Complete Movie", "Music Video", "OAD", "OVA", "Short Movie", "Special", "TV Special", "Web", }; private static readonly Regex SynopsisCleanLinks = new(@"(?<url>https?:\/\/\w+.\w+(?:\/?\w+)?) \[(?<text>[^\]]+)\]|\[URL=(?<url>[^\]]+)\](?<text>[^\[]+)\[\/URL\]", RegexOptions.Compiled); private static readonly Regex SynopsisSpoiler = new(@"\[spoiler=\""([^""]+)\""\](.+?)\[/spoiler\]", RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex SynopsisCleanBBCodes = new(@"\[\/?[ib]\]", RegexOptions.Multiline | RegexOptions.Compiled); private static readonly Regex SynopsisCleanMiscLines = new(@"(?:^\*|\n\* Based)[^\r\n$]+|\n--{2,5}\s*", RegexOptions.Multiline | RegexOptions.Compiled); private static readonly Regex SynopsisExtractNote = new(@"(?:^|\r\n|\r|\n|\* ?)Note(?: [1-9][0-9]?)?:", RegexOptions.Multiline | RegexOptions.Compiled); private static readonly Regex SynopsisRemoveSummary = new(@"\nSummary(:| by| written by| of the previous)[^\r\n]+", RegexOptions.Compiled); private static readonly Regex SynopsisRemoveSource = new(@"(?:\r\n|\r|\n| )?(?:\(Sources?(?: *:?) *(?<source>[^\r\n\)]+)\)|\[Sources?(?: *:?) *(?<source>[^\r\n\]]+)\]|Sources?(?: *:?) *(?<source>[^\n\r\[\)\(]+)(?: ?\(edited\)|:)?)|\[[^\]]+\]$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex SynopsisRemoveEmptyBrackets = new(@"\[\s*\]|\(\s*\)|\{\s*\}", 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); [GeneratedRegex(@"^(?:Special|Episode|Volume|OVA|OAD|Web) \d+$|^Part \d+ of \d+$|^Episode [COPRST]\d+$|^(?:OVA|OAD|Movie|Complete Movie|Short Movie|TV Special|Music Video|Web|Volume)$", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] private static partial Regex InvalidEpisodeTitleRegex(); // Currently copied from these two locations until I have more time to research if there are even more patterns across languages to support; // https://github.com/jellyfin/jellyfin/blob/4b6fb6c4bb2478badad068ce18aabe0c2955db48/Emby.Naming/TV/SeasonPathParser.cs#L13 // https://github.com/jellyfin/jellyfin/blob/4b6fb6c4bb2478badad068ce18aabe0c2955db48/Emby.Naming/TV/SeasonPathParser.cs#L16 [GeneratedRegex(@"^\s*((?:(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|esong|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$|^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|esong|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?:(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] private static partial Regex InvalidSeriesOrSeasonTitleRegex(); /// <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> /// Deprecated, but kept until the next major release for backwards compatibility. /// </summary> /// TODO: Break this during the next major version of the plugin. TvDB = 3, /// <summary> /// Provide the description from TMDB. /// </summary> TMDB = 4 } /// <summary> /// Determines how to convert the description. /// </summary> public enum DescriptionConversionMode { /// <summary> /// Don't convert the description. /// </summary> Disabled = 0, /// <summary> /// Convert the description to plain text. /// </summary> PlainText = 1, /// <summary> /// Convert the description to markdown. /// </summary> Markdown = 2, } /// <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, } 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; if (filteredList.Count == 1) return filteredList[0].TrimEnd(); var sb = new StringBuilder(filteredList[0]); for (var index = 1; index < filteredList.Count; index++) { var lastChar = sb[^1]; sb.Append(PunctuationMarks.Contains(lastChar) ? " " : ". "); sb.Append(filteredList[index]); } return sb.ToString().TrimEnd(); } #region Description #region Description | Episode public static string GetEpisodeDescription(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) => seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Description.AnidbEpisode.Enabled ? ( GetDescription(episodeInfo, Plugin.Instance.Configuration.Description.AnidbEpisode, metadataLanguage) ) : ( GetDescription(episodeInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Description.TmdbEpisode.Enabled ? ( GetDescription(episodeInfo, Plugin.Instance.Configuration.Description.TmdbEpisode, metadataLanguage) ) : ( GetDescription(episodeInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), _ => Plugin.Instance.Configuration.Description.ShokoEpisode.Enabled ? ( GetDescription(episodeInfo, Plugin.Instance.Configuration.Description.ShokoEpisode, metadataLanguage) ) : ( GetDescription(episodeInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), }; public static string GetEpisodeDescription(IEnumerable<EpisodeInfo> episodeList, SeasonInfo seasonInfo, string? metadataLanguage) => JoinText(episodeList.Select(baseInfo => GetEpisodeDescription(baseInfo, seasonInfo, metadataLanguage))) ?? string.Empty; #endregion #region Description | Season public static string GetSeasonDescription(SeasonInfo seasonInfo, string? metadataLanguage) => seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Description.AnidbSeason.Enabled ? ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.AnidbSeason, metadataLanguage) ) : ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Description.TmdbSeason.Enabled ? ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.TmdbSeason, metadataLanguage) ) : ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), _ => Plugin.Instance.Configuration.Description.ShokoSeason.Enabled ? ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.ShokoSeason, metadataLanguage) ) : ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), }; #endregion #region Description | Show public static string GetShowDescription(ShowInfo showInfo, string? metadataLanguage) => showInfo.DefaultSeason.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Description.AnidbAnime.Enabled ? ( GetDescription(showInfo, Plugin.Instance.Configuration.Description.AnidbAnime, metadataLanguage) ) : ( GetDescription(showInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Description.TmdbShow.Enabled ? ( GetDescription(showInfo, Plugin.Instance.Configuration.Description.TmdbShow, metadataLanguage) ) : ( GetDescription(showInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), _ => Plugin.Instance.Configuration.Description.ShokoSeries.Enabled ? ( GetDescription(showInfo, Plugin.Instance.Configuration.Description.ShokoSeries, metadataLanguage) ) : ( GetDescription(showInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), }; #endregion #region Description | Movie public static string GetMovieDescription(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) { // TMDB movies have a proper "episode" description. if (episodeInfo.Id[0] is IdPrefix.TmdbMovie) return GetEpisodeDescription(episodeInfo, seasonInfo, metadataLanguage); return seasonInfo.IsMultiEntry && !episodeInfo.IsMainEntry ? GetEpisodeDescription(episodeInfo, seasonInfo, metadataLanguage) : GetSeasonDescription(seasonInfo, metadataLanguage); } #endregion public static string GetCollectionDescription(SeasonInfo seasonInfo, string? metadataLanguage) => seasonInfo.StructureType switch { SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Description.TmdbCollection.Enabled ? ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.TmdbCollection, metadataLanguage) ) : ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), _ => Plugin.Instance.Configuration.Description.ShokoCollection.Enabled ? ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.ShokoCollection, metadataLanguage) ) : ( GetDescription(seasonInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ), }; public static string GetCollectionDescription(CollectionInfo collectionInfo, string? metadataLanguage) => Plugin.Instance.Configuration.Description.ShokoCollection.Enabled ? ( GetDescription(collectionInfo, Plugin.Instance.Configuration.Description.ShokoCollection, metadataLanguage) ) : ( GetDescription(collectionInfo, Plugin.Instance.Configuration.Description.Default, metadataLanguage) ); private static string GetDescription(IBaseItemInfo baseInfo, DescriptionConfiguration config, string? metadataLanguage) { foreach (var provider in config.GetOrderedDescriptionProviders()) { var overview = provider switch { DescriptionProvider.Shoko => baseInfo.Overview, DescriptionProvider.AniDB => baseInfo.Overviews.Where(o => o.Source is "AniDB" && string.Equals(o.LanguageCode, metadataLanguage, StringComparison.InvariantCultureIgnoreCase)).FirstOrDefault()?.Value, DescriptionProvider.TMDB => baseInfo.Overviews.Where(o => o.Source is "TMDB" && string.Equals(o.LanguageCode, metadataLanguage, StringComparison.InvariantCultureIgnoreCase)).FirstOrDefault()?.Value, _ => null }; if (!string.IsNullOrEmpty(overview)) return AppendNotes(baseInfo, config, overview); } return AppendNotes(baseInfo, config, string.Empty); } private static string AppendNotes(IBaseItemInfo baseInfo, DescriptionConfiguration config, string overview) { if (config.AddNotes && baseInfo.Notes.Count == 1) { if (overview.Length > 0) overview = overview.TrimEnd() + "\n\n"; if (Plugin.Instance.Configuration.SynopsisEnableMarkdown) overview += "**Note:** " + baseInfo.Notes[0]; else overview += "Note: " + baseInfo.Notes[0]; } else if (config.AddNotes && baseInfo.Notes.Count > 1) { if (overview.Length > 0) overview = overview.TrimEnd() + "\n\n"; var count = 1; foreach (var note in baseInfo.Notes) { if (Plugin.Instance.Configuration.SynopsisEnableMarkdown) overview += "**Note " + count++ + ":** " + note + "\n\n"; else overview += "Note " + count++ + ": " + note + "\n\n"; } } return overview.Trim(); } /// <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> [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string SanitizeAnidbDescription(string summary) => SanitizeAnidbDescription(summary, out _); /// <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> /// <param name="notes">The extracted notes.</param> /// <returns>The sanitized AniDB description.</returns> public static string SanitizeAnidbDescription(string summary, out IReadOnlyList<string> notes) { var noteList = new List<string>(); notes = noteList; if (string.IsNullOrWhiteSpace(summary)) { return string.Empty; } var config = Plugin.Instance.Configuration; if (config.SynopsisCleanLinks) summary = summary.Replace(SynopsisCleanLinks, match => config.SynopsisEnableMarkdown ? $"[{match.Groups["text"].Value}]({match.Groups["url"].Value})" : match.Groups["text"].Value); if (config.SynopsisCleanMiscLines) summary = summary .Replace(SynopsisCleanBBCodes, string.Empty) .Replace(SynopsisCleanMiscLines, string.Empty) .Replace(SynopsisSpoiler, match => config.SynopsisEnableMarkdown ? $"**{match.Groups[1].Value}**:\n_{match.Groups[2].Value.Split('\n').Join("_\n_")}_" : string.Empty); if (config.SynopsisRemoveSummary) summary = summary .Replace(SynopsisRemoveSource, string.Empty) .Replace(SynopsisRemoveSummary, string.Empty) .Replace(SynopsisRemoveEmptyBrackets, string.Empty); if (config.SynopsisCleanMultiEmptyLines) summary = summary .Replace(SynopsisConvertNewLines, "\n") .Replace(SynopsisCleanMultiEmptyLines, "\n"); if (SynopsisExtractNote.Match(summary) is { Success: true } anyNoteMatch) { var noteText = summary[(anyNoteMatch.Index + anyNoteMatch.Length)..].TrimStart(); summary = summary[..anyNoteMatch.Index]; while (SynopsisExtractNote.Match(noteText) is { Success: true } additionalNoteMatch) { var note = noteText[0..additionalNoteMatch.Index]; noteList.Add(note); noteText = noteText[(additionalNoteMatch.Index + additionalNoteMatch.Length)..].TrimStart(); } noteList.Add(noteText); } return summary.Trim(); } #endregion #region Titles #region Titles | Episode public static (string? displayTitle, string? alternateTitle) GetEpisodeTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) { var config = seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Title.AnidbEpisode.Enabled ? Plugin.Instance.Configuration.Title.AnidbEpisode : Plugin.Instance.Configuration.Title.Default, SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Title.TmdbEpisode.Enabled ? Plugin.Instance.Configuration.Title.TmdbEpisode : Plugin.Instance.Configuration.Title.Default, _ => Plugin.Instance.Configuration.Title.ShokoEpisode.Enabled ? Plugin.Instance.Configuration.Title.ShokoEpisode : Plugin.Instance.Configuration.Title.Default, }; var displayTitle = GetEpisodeTitleByType(episodeInfo, seasonInfo, config.MainTitle, metadataLanguage); var alternateTitle = JoinTitles(config.AlternateTitles.Select(t => GetEpisodeTitleByType(episodeInfo, seasonInfo, t, metadataLanguage)), displayTitle, !config.RemoveDuplicates); return (displayTitle, alternateTitle); } private static string? GetEpisodeTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleConfiguration configuration, string? metadataLanguage) { var anidbTitles = new Lazy<IReadOnlyList<Title>>(() => episodeInfo.Titles.Where(t => t.Source is "AniDB").ToList()); var tmdbTitles = new Lazy<IReadOnlyList<Title>>(() => episodeInfo.Titles.Where(t => t.Source is "TMDB").ToList()); foreach (var provider in configuration.GetOrderedTitleProviders()) { var title = provider switch { TitleProvider.Shoko_Default => episodeInfo.Title, TitleProvider.AniDB_Default => episodeInfo.Titles.FirstOrDefault(title => title.Source is "AniDB" && title.LanguageCode is "en")?.Value, TitleProvider.AniDB_LibraryLanguage => GetTitleForLanguage(anidbTitles.Value, false, configuration.AllowAny, metadataLanguage), TitleProvider.AniDB_CountryOfOrigin => GetTitleForLanguage(anidbTitles.Value, false, configuration.AllowAny, GuessOriginLanguage(seasonInfo)), TitleProvider.TMDB_Default => episodeInfo.Titles.FirstOrDefault(title => title.Source is "TMDB" && title.LanguageCode is "en")?.Value, TitleProvider.TMDB_LibraryLanguage => GetTitleForLanguage(tmdbTitles.Value, false, configuration.AllowAny, metadataLanguage), TitleProvider.TMDB_CountryOfOrigin => GetTitleForLanguage(tmdbTitles.Value, false, configuration.AllowAny, episodeInfo.OriginalLanguageCode), _ => null, }; if (!string.IsNullOrEmpty(title) && !InvalidEpisodeTitleRegex().IsMatch(title)) return title.Trim(); } return null; } #endregion #region Titles | Season public static (string? displayTitle, string? alternateTitle) GetSeasonTitles(SeasonInfo seasonInfo, int baseSeasonOffset, string? metadataLanguage) { var config = seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Title.AnidbSeason.Enabled ? Plugin.Instance.Configuration.Title.AnidbSeason : Plugin.Instance.Configuration.Title.Default, SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Title.TmdbSeason.Enabled ? Plugin.Instance.Configuration.Title.TmdbSeason : Plugin.Instance.Configuration.Title.Default, _ => Plugin.Instance.Configuration.Title.ShokoSeason.Enabled ? Plugin.Instance.Configuration.Title.ShokoSeason : Plugin.Instance.Configuration.Title.Default, }; var displayTitle = GetSeriesTitleByType(seasonInfo, config.MainTitle, metadataLanguage); var alternateTitle = JoinTitles(config.AlternateTitles.Select(t => GetSeriesTitleByType(seasonInfo, t, metadataLanguage)), displayTitle, !config.RemoveDuplicates); if (baseSeasonOffset > 0) { string type = string.Empty; switch (baseSeasonOffset) { default: break; case 1: type = "Alternate Version"; break; } if (!string.IsNullOrEmpty(type)) { if (!string.IsNullOrEmpty(displayTitle)) displayTitle += $" ({type})"; if (!string.IsNullOrEmpty(alternateTitle)) alternateTitle += $" ({type})"; } } return (displayTitle, alternateTitle); } #endregion #region Titles | Show public static (string? displayTitle, string? alternateTitle) GetShowTitles(ShowInfo showInfo, string? metadataLanguage) { var config = showInfo.DefaultSeason.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Title.AnidbAnime.Enabled ? Plugin.Instance.Configuration.Title.AnidbAnime : Plugin.Instance.Configuration.Title.Default, SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Title.TmdbShow.Enabled ? Plugin.Instance.Configuration.Title.TmdbShow : Plugin.Instance.Configuration.Title.Default, _ => Plugin.Instance.Configuration.Title.ShokoSeries.Enabled ? Plugin.Instance.Configuration.Title.ShokoSeries : Plugin.Instance.Configuration.Title.Default, }; var displayTitle = GetSeriesTitleByType(showInfo, config.MainTitle, metadataLanguage); var alternateTitle = JoinTitles(config.AlternateTitles.Select(t => GetSeriesTitleByType(showInfo, t, metadataLanguage)), displayTitle, !config.RemoveDuplicates); return (displayTitle, alternateTitle); } private static string? GetSeriesTitleByType(IBaseItemInfo baseInfo, TitleConfiguration configuration, string? metadataLanguage) { var anidbTitles = new Lazy<IReadOnlyList<Title>>(() => baseInfo.Titles.Where(t => t.Source is "AniDB").ToList()); var tmdbTitles = new Lazy<IReadOnlyList<Title>>(() => baseInfo.Titles.Where(t => t.Source is "TMDB").ToList()); foreach (var provider in configuration.GetOrderedTitleProviders()) { var title = provider switch { TitleProvider.Shoko_Default => baseInfo.Title, TitleProvider.AniDB_Default => anidbTitles.Value.FirstOrDefault(title => title.IsDefault)?.Value, TitleProvider.AniDB_LibraryLanguage => GetTitleForLanguage(anidbTitles.Value, true, configuration.AllowAny, metadataLanguage), TitleProvider.AniDB_CountryOfOrigin => GetTitleForLanguage(anidbTitles.Value, true, configuration.AllowAny, GuessOriginLanguage(baseInfo)), TitleProvider.TMDB_Default => tmdbTitles.Value.FirstOrDefault(title => title.IsDefault)?.Value, TitleProvider.TMDB_LibraryLanguage => GetTitleForLanguage(tmdbTitles.Value, true, configuration.AllowAny, metadataLanguage), TitleProvider.TMDB_CountryOfOrigin => GetTitleForLanguage(tmdbTitles.Value, true, configuration.AllowAny, baseInfo.OriginalLanguageCode), _ => null, }; if (!string.IsNullOrEmpty(title) && !InvalidSeriesOrSeasonTitleRegex().IsMatch(title)) return title.Trim(); } return null; } #endregion #region Titles | Movie public static (string? displayTitle, string? alternateTitle) GetMovieTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) { var config = seasonInfo.StructureType switch { SeriesStructureType.AniDB_Anime => Plugin.Instance.Configuration.Title.AnidbSeason.Enabled ? Plugin.Instance.Configuration.Title.AnidbSeason : Plugin.Instance.Configuration.Title.Default, SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Title.TmdbSeason.Enabled ? Plugin.Instance.Configuration.Title.TmdbSeason : Plugin.Instance.Configuration.Title.Default, _ => Plugin.Instance.Configuration.Title.ShokoSeason.Enabled ? Plugin.Instance.Configuration.Title.ShokoSeason : Plugin.Instance.Configuration.Title.Default, }; var displayTitle = GetMovieTitleByType(episodeInfo, seasonInfo, config.MainTitle, metadataLanguage); var alternateTitle = JoinTitles(config.AlternateTitles.Select(t => GetMovieTitleByType(episodeInfo, seasonInfo, t, metadataLanguage)), displayTitle, !config.RemoveDuplicates); return (displayTitle, alternateTitle); } private static string? GetMovieTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleConfiguration configuration, string? metadataLanguage) { if (episodeInfo.Id[0] is IdPrefix.TmdbMovie) return GetEpisodeTitleByType(episodeInfo, seasonInfo, configuration, metadataLanguage); var mainTitle = GetSeriesTitleByType(seasonInfo, configuration, metadataLanguage); var subTitle = GetEpisodeTitleByType(episodeInfo, seasonInfo, configuration, metadataLanguage); if (!string.IsNullOrEmpty(subTitle)) return $"{mainTitle}: {subTitle}".Trim(); else if (episodeInfo.EpisodeNumber > 1) return $"{mainTitle} {NumericToRoman(episodeInfo.EpisodeNumber)}".Trim(); return mainTitle?.Trim(); } #endregion #region Titles | Collection public static (string? displayTitle, string? alternateTitle) GetCollectionTitles(SeasonInfo seasonInfo, string? metadataLanguage) { var config = seasonInfo.StructureType switch { SeriesStructureType.TMDB_SeriesAndMovies => Plugin.Instance.Configuration.Title.TmdbCollection.Enabled ? Plugin.Instance.Configuration.Title.TmdbCollection : Plugin.Instance.Configuration.Title.Default, _ => Plugin.Instance.Configuration.Title.ShokoCollection.Enabled ? Plugin.Instance.Configuration.Title.ShokoCollection : Plugin.Instance.Configuration.Title.Default, }; var displayTitle = GetSeriesTitleByType(seasonInfo, config.MainTitle, metadataLanguage); var alternateTitle = JoinTitles(config.AlternateTitles.Select(t => GetSeriesTitleByType(seasonInfo, t, metadataLanguage)), displayTitle, !config.RemoveDuplicates); return (displayTitle, alternateTitle); } public static (string? displayTitle, string? alternateTitle) GetCollectionTitles(CollectionInfo collectionInfo, string? metadataLanguage) { var config = Plugin.Instance.Configuration.Title.ShokoCollection.Enabled ? Plugin.Instance.Configuration.Title.ShokoCollection : Plugin.Instance.Configuration.Title.Default; var displayTitle = GetSeriesTitleByType(collectionInfo, config.MainTitle, metadataLanguage); var alternateTitle = JoinTitles(config.AlternateTitles.Select(t => GetSeriesTitleByType(collectionInfo, t, metadataLanguage)), displayTitle, !config.RemoveDuplicates); return (displayTitle, alternateTitle); } #endregion #region Titles | Helpers /// <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="allowAny">Allow any title to be returned.</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? GetTitleForLanguage(IReadOnlyList<Title> titles, bool usingTypes, bool allowAny, 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; string? title = null; if (usingTypes) { title = titleList.FirstOrDefault(t => t.Type == TitleType.Official)?.Value; if (string.IsNullOrEmpty(title) && allowAny) title = titleList.FirstOrDefault()?.Value; } else { title = titles.FirstOrDefault()?.Value; } if (!string.IsNullOrWhiteSpace(title) && !InvalidEpisodeTitleRegex().IsMatch(title)) 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> internal static string[] GuessOriginLanguage(IBaseItemInfo baseItemInfo) { var langCode = GetMainLanguage(baseItemInfo.Titles.Where(t => t.Source is "AniDB")); return langCode switch { "x-other" => ["ja", "jap"], "x-jat" => ["ja", "jap"], "x-zht" => ["zn-hans", "zn-hant", "zn-c-mcm", "zn", "zht"], _ => [langCode], }; } private static string NumericToRoman(int number) => number switch { 1 => "I", 2 => "II", 3 => "III", 4 => "IV", 5 => "V", 6 => "VI", 7 => "VII", 8 => "VIII", 9 => "IX", 10 => "X", 11 => "XI", 12 => "XII", 13 => "XIII", 14 => "XIV", 15 => "XV", 16 => "XVI", 17 => "XVII", 18 => "XVIII", 19 => "XIX", 20 => "XX", 21 => "XXI", 22 => "XXII", 23 => "XXIII", 24 => "XXIV", _ => number.ToString(), }; private static string? JoinTitles(IEnumerable<string?> titleList, string? mainTitle, bool distinct) { if (distinct) { return titleList .Where(title => !string.IsNullOrWhiteSpace(title) && (string.IsNullOrEmpty(mainTitle) || !string.Equals(title, mainTitle, StringComparison.Ordinal))) .Select(title => title!.Trim()) .Distinct(StringComparer.Ordinal) .Join(" | ") is { Length: > 0 } result ? result : null; } else { return titleList .Where(title => !string.IsNullOrWhiteSpace(title)) .Select(title => title!.Trim()) .Join(" | ") is { Length: > 0 } result ? result : null; } } #endregion #endregion } ================================================ FILE: Shokofin/Utils/UsageTracker.cs ================================================ using System; using System.Collections.Concurrent; using System.Timers; using Microsoft.Extensions.Logging; namespace Shokofin.Utils; public class UsageTracker { private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60); 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) { Logger = logger; Timeout = DefaultTimeout; StalledTimer = new(DefaultTimeout.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(); } } } } } } ================================================ FILE: Shokofin/Web/ImageHostUrl.cs ================================================ 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 internal base url. Will be null if the base url haven't been used /// yet. /// </summary> private static string? InternalBaseUrl { get; set; } = null; /// <summary> /// The current image host base url to use. /// </summary> public static string BaseUrl { get => InternalBaseUrl ??= Plugin.Instance.BaseUrl; } /// <summary> /// The internal base path. Will be null if the base path haven't been used /// yet. /// </summary> private static string? InternalBasePath { get; set; } = null; /// <summary> /// The current image host base path to use. /// </summary> public static string BasePath { get => InternalBasePath ??= Plugin.Instance.BasePath; } private static Guid? _currentItemId; public static Guid? CurrentItemId { get { lock (LockObj) { return _currentItemId; } } } private static readonly object LockObj = new(); private static readonly 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); var itemId = Guid.Empty; if (result.Success) { itemId = Guid.Parse(result.Groups["itemId"].Value); var path = result.Length == uriBuilder.Path.Length ? "" : uriBuilder.Path[..^result.Length]; uriBuilder.Path = ""; uriBuilder.Query = ""; var uri = uriBuilder.ToString(); lock (LockObj) { _currentItemId = itemId; if (!string.Equals(uri, InternalBaseUrl)) InternalBaseUrl = uri; if (!string.Equals(path, InternalBasePath)) InternalBasePath = path; } } try { await next().ConfigureAwait(false); } finally { if (Guid.Empty != itemId && _currentItemId == itemId) { lock (LockObj) { if (Guid.Empty != itemId && _currentItemId == itemId) { _currentItemId = null; } } } } } } ================================================ FILE: Shokofin/Web/Models/SimpleSeries.cs ================================================ namespace Shokofin.Web.Models; /// <summary> /// A simple series model. /// </summary> public class SimpleSeries { /// <summary> /// Shoko Series ID. /// </summary> public required int Id { get; init; } /// <summary> /// AniDB Anime ID. /// </summary> public required int AnidbId { get; init; } /// <summary> /// Preferred Title. /// </summary> public required string Title { get; init; } /// <summary> /// Default Title. /// </summary> public required string DefaultTitle { get; init; } } ================================================ FILE: Shokofin/Web/Models/VfsLibraryPreview.cs ================================================ using System.Collections.Generic; using System.IO; using System.Linq; using MediaBrowser.Model.Entities; using Shokofin.Extensions; using Shokofin.Resolvers.Models; namespace Shokofin.Web.Models; public class VfsLibraryPreview(HashSet<string> filesBefore, HashSet<string> filesAfter, VirtualFolderInfo virtualFolder, LinkGenerationResult? result, string vfsPath) { public string LibraryId = virtualFolder.ItemId; public string LibraryName { get; } = virtualFolder.Name; public string CollectionType { get; } = virtualFolder.CollectionType.ConvertToCollectionType()?.ToString() ?? "-"; public string VfsRoot { get; } = Plugin.Instance.VirtualRoot; public bool IsSuccess = result is not null; public IReadOnlyList<string> FilesBeforeChanges { get; } = filesBefore .Select(path => path.Replace(vfsPath, string.Empty).Replace(Path.DirectorySeparatorChar, '/')) .OrderBy(path => path) .ToList(); public IReadOnlyList<string> FilesAfterChanges { get; } = filesAfter .Select(path => path.Replace(vfsPath, string.Empty).Replace(Path.DirectorySeparatorChar, '/')) .OrderBy(path => path) .ToList(); public VfsLibraryPreviewStats Stats { get; } = new(result); public class VfsLibraryPreviewStats(LinkGenerationResult? result) { public int Total { get; } = result?.Total ?? 0; public int Created { get; } = result?.Created ?? 0; public int Fixed { get; } = result?.Fixed ?? 0; public int Skipped { get; } = result?.Skipped ?? 0; public int Removed { get; } = result?.Removed ?? 0; public int TotalVideos { get; } = result?.TotalVideos ?? 0; public int CreatedVideos { get; } = result?.CreatedVideos ?? 0; public int FixedVideos { get; } = result?.FixedVideos ?? 0; public int SkippedVideos { get; } = result?.SkippedVideos ?? 0; public int RemovedVideos { get; } = result?.RemovedVideos ?? 0; public int TotalExternalFiles { get; } = result?.TotalExternalFiles ?? 0; public int CreatedExternalFiles { get; } = result?.CreatedExternalFiles ?? 0; public int FixedExternalFiles { get; } = result?.FixedExternalFiles ?? 0; public int SkippedExternalFiles { get; } = result?.SkippedExternalFiles ?? 0; public int RemovedExternalFiles { get; } = result?.RemovedExternalFiles ?? 0; public int RemovedNfos { get; } = result?.RemovedNfos ?? 0; } } ================================================ FILE: Shokofin/Web/ShokofinHostController.cs ================================================ 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.Authorization; 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> /// <remarks> /// Initializes a new instance of the <see cref="ShokofinHostController"/> class. /// </remarks> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> [Authorize] [ApiController] [Route("Shokofin/Host")] [Produces(MediaTypeNames.Application.Json)] public class ShokofinHostController(ILogger<ShokofinHostController> logger, ShokoApiClient apiClient) : ControllerBase { private readonly ILogger<ShokofinHostController> Logger = logger; private readonly ShokoApiClient 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> [AllowAnonymous] [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] ShokoImageType imageType, [FromRoute, Range(1, int.MaxValue)] int imageId ) { var response = await APIClient.GetImageAsync(imageSource, imageType, imageId).ConfigureAwait(false); 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().ConfigureAwait(false); 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; } ================================================ FILE: Shokofin/Web/ShokofinSignalRController.cs ================================================ using System; using System.Net.Mime; using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; 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> /// <remarks> /// Initializes a new instance of the <see cref="ShokofinSignalRController"/> class. /// </remarks> [Authorize] [ApiController] [Route("Shokofin/SignalR")] [Produces(MediaTypeNames.Application.Json)] public class ShokofinSignalRController(ILogger<ShokofinSignalRController> logger, SignalRConnectionManager connectionManager) : ControllerBase { private readonly ILogger<ShokofinSignalRController> Logger = logger; private readonly SignalRConnectionManager 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().ConfigureAwait(false); 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().ConfigureAwait(false); 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; } } ================================================ FILE: Shokofin/Web/ShokofinUtilityController.cs ================================================ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Mime; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Info; using Shokofin.Configuration; using Shokofin.Resolvers; using Shokofin.Utils; using Shokofin.Web.Models; namespace Shokofin.Web; /// <summary> /// Shoko Utility Web Controller. /// </summary> /// <remarks> /// Initializes a new instance of the <see cref="ShokofinUtilityController"/> class. /// </remarks> [Authorize] [ApiController] [Route("Shokofin/Utility")] [Produces(MediaTypeNames.Application.Json)] public partial class ShokofinUtilityController( ILogger<ShokofinUtilityController> logger, ShokoApiClient apiClient, ShokoApiManager apiManager, SeriesConfigurationService seriesConfigurationService, VirtualFileSystemService virtualFileSystemService ) : ControllerBase { private readonly ILogger<ShokofinUtilityController> Logger = logger; private readonly GuardedMemoryCache Cache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { SlidingExpiration = new(0, 30, 0) }); /// <summary> /// Previews the VFS structure for the given library. /// </summary> /// <param name="libraryId">The id of the library to preview.</param> /// <returns>A <see cref="VfsLibraryPreview"/> or <see cref="ValidationProblemDetails"/> if the library is not found.</returns> [HttpPost("VFS/Library/{libraryId}/Preview")] public async Task<ActionResult<VfsLibraryPreview>> PreviewVFS(Guid libraryId) { var trackerId = Plugin.Instance.Tracker.Add("Preview VFS"); try { var (filesBefore, filesAfter, virtualFolder, result, vfsPath) = await virtualFileSystemService.PreviewChangesForLibrary(libraryId, HttpContext.RequestAborted).ConfigureAwait(false); if (virtualFolder is null) return NotFound("Unable to find library with the given id."); return new VfsLibraryPreview(filesBefore, filesAfter, virtualFolder, result, vfsPath); } finally { Plugin.Instance.Tracker.Remove(trackerId); } } /// <summary> /// Retrieves a simple series list. /// </summary> /// <param name="query">Query to filter the list.</param> /// <returns>The series list.</returns> [HttpGet("Series")] public async Task<ActionResult<IReadOnlyList<SimpleSeries>>> GetSeriesList( [FromQuery] string? query = null ) { IReadOnlyList<SimpleSeries>? list; if (!string.IsNullOrWhiteSpace(query)) { if (IdRegex().Match(query) is { Success: true } match) { var id = int.Parse(match.Groups["id"].Value); var isShoko = match.Groups["type"].Value is "s"; if (Cache.TryGetValue("SeriesList", out list)) return list .Where(s => isShoko ? s.Id == id : s.AnidbId == id) .ToList(); var result = await (isShoko ? GetSeriesByShokoSeriesId(id) : GetSeriesByAnidbId(id)).ConfigureAwait(false); return new(result is not null ? [result] : []); } list = await GetSeriesListWithQueryInternal(query).ConfigureAwait(false); return new(list); } list = await GetSeriesListInternal().ConfigureAwait(false); return new(list); } private async Task<IReadOnlyList<SimpleSeries>> GetSeriesListWithQueryInternal(string query) { var simpleList = new List<SimpleSeries>(); var trackerId = Plugin.Instance.Tracker.Add($"Get Simple Series List with Query: {query}"); try { var listResult = await apiClient.GetAllAnidbAnime(query, pageSize: 0).ConfigureAwait(false); foreach (var anime in listResult.List) { simpleList.Add(new() { Id = anime.ShokoId!.Value, AnidbId = anime.Id, Title = anime.Title, DefaultTitle = anime.Titles?.FirstOrDefault(title => title.Type is API.Models.TitleType.Main)?.Value ?? anime.Title, }); } } finally { Plugin.Instance.Tracker.Remove(trackerId); } return simpleList; } private Task<IReadOnlyList<SimpleSeries>> GetSeriesListInternal() => Cache.GetOrCreateAsync<IReadOnlyList<SimpleSeries>>("SeriesList", async () => { var simpleList = new List<SimpleSeries>(); var trackerId = Plugin.Instance.Tracker.Add($"Get Simple Series List"); try { var listResult = await apiClient.GetAllAnidbAnime(pageSize: 0).ConfigureAwait(false); foreach (var anime in listResult.List) { simpleList.Add(new() { Id = anime.ShokoId!.Value, AnidbId = anime.Id, Title = anime.Title, DefaultTitle = anime.Titles?.FirstOrDefault(title => title.Type is API.Models.TitleType.Main)?.Value ?? anime.Title, }); } } finally { Plugin.Instance.Tracker.Remove(trackerId); } return simpleList; }); private async Task<SimpleSeries?> GetSeriesByShokoSeriesId(int seriesId) { using (Plugin.Instance.Tracker.Enter($"Get Series by Shoko Series ID {seriesId}")) { if (await apiClient.GetShokoSeries(seriesId.ToString()).ConfigureAwait(false) is not { } shokoSeries) return null; return new() { Id = shokoSeries.IDs.Shoko, AnidbId = shokoSeries.IDs.AniDB, Title = shokoSeries.AniDB.Title, DefaultTitle = shokoSeries.AniDB.Titles?.FirstOrDefault(title => title.Type is API.Models.TitleType.Main)?.Value ?? shokoSeries.AniDB.Title, }; } } private async Task<SimpleSeries?> GetSeriesByAnidbId(int anidbId) { using (Plugin.Instance.Tracker.Enter($"Get Series by Anidb ID {anidbId}")) { if (await apiClient.GetShokoSeriesForAnidbAnime(anidbId.ToString()).ConfigureAwait(false) is not { } shokoSeries) return null; return new() { Id = shokoSeries.IDs.Shoko, AnidbId = anidbId, Title = shokoSeries.AniDB.Title, DefaultTitle = shokoSeries.AniDB.Titles?.FirstOrDefault(title => title.Type is API.Models.TitleType.Main)?.Value ?? shokoSeries.AniDB.Title, }; } } [GeneratedRegex(@"^\s*(?<type>[as])(?<id>\d+)\s*$")] private static partial Regex IdRegex(); /// <summary> /// Retrieves the series configuration for the given series id. /// </summary> /// <param name="seriesId">Shoko series ID.</param> /// <returns>The series configuration, if found.</returns> [HttpGet("Series/{seriesId}/Configuration")] public async Task<ActionResult<SeriesConfiguration>> GetSeriesConfigurationForId( [FromRoute, Range(1, int.MaxValue)] int seriesId ) { var trackerId = Plugin.Instance.Tracker.Add($"Get Series Configuration for {seriesId}"); try { var config = await seriesConfigurationService.GetSeriesConfigurationForId(seriesId).ConfigureAwait(false); if (config is null) return NotFound("Unable to find series with the given id."); return config; } finally { Plugin.Instance.Tracker.Remove(trackerId); } } /// <summary> /// Updates the series configuration for the given series id. /// </summary> /// <param name="seriesId">Shoko series ID.</param> /// <param name="seriesConfiguration">The series configuration.</param> /// <returns>The updated series configuration.</returns> [HttpPost("Series/{seriesId}/Configuration")] public async Task<ActionResult<SeriesConfiguration>> UpdateSeriesConfigurationForId( [FromRoute, Range(1, int.MaxValue)] int seriesId, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] NullableSeriesConfiguration seriesConfiguration ) { var trackerId = Plugin.Instance.Tracker.Add($"Update Series Configuration for {seriesId} (Add)"); try { return await seriesConfigurationService.UpdateSeriesConfigurationForId(seriesId, seriesConfiguration).ConfigureAwait(false); } finally { Plugin.Instance.Tracker.Remove(trackerId); } } /// <summary> /// Updates the series configuration for the given series id. /// </summary> /// <param name="seriesId">Shoko series ID.</param> /// <param name="seriesConfiguration">The series configuration.</param> /// <returns>The updated series configuration.</returns> [HttpPut("Series/{seriesId}/Configuration")] public async Task<ActionResult<SeriesConfiguration>> UpdateSeriesConfigurationForId( [FromRoute, Range(1, int.MaxValue)] int seriesId, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] SeriesConfiguration seriesConfiguration ) { var trackerId = Plugin.Instance.Tracker.Add($"Update Series Configuration for {seriesId} (Replace)"); try { return await seriesConfigurationService.UpdateSeriesConfigurationForId(seriesId, seriesConfiguration).ConfigureAwait(false); } finally { Plugin.Instance.Tracker.Remove(trackerId); } } [HttpGet("Series/{seriesId}/ShowInfo")] public async Task<IReadOnlyList<ShowInfo>> GetShowInfoForSeriesId([FromRoute, Range(1, int.MaxValue)] int seriesId) { var trackerId = Plugin.Instance.Tracker.Add($"Get Show Info for {seriesId}"); try { var showInfo = await apiManager.GetShowInfosForShokoSeries(seriesId.ToString()).ConfigureAwait(false); return showInfo; } finally { Plugin.Instance.Tracker.Remove(trackerId); } } } ================================================ FILE: Shokofin/Web/VfsActionFilter.cs ================================================ using System.IO; using System.Threading.Tasks; using MediaBrowser.Model.Dto; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; namespace Shokofin.Web; public class VfsActionFilter : IAsyncActionFilter { public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var executedResult = await next().ConfigureAwait(false); if ( executedResult.Result is ObjectResult result1 && result1.Value is BaseItemDto { MediaSources.Length: > 0, Path: { Length: > 0 } dtoPath } dto && dtoPath.StartsWith(Plugin.Instance.VirtualRoot) && Plugin.Instance.Configuration.VFS_UseSourceFileAsVersionIdentifier ) { foreach (var mediaSource in dto.MediaSources) { var path = File.ResolveLinkTarget(mediaSource.Path, returnFinalTarget: false)?.FullName ?? mediaSource.Path; var fileName = Path.GetFileNameWithoutExtension(path); mediaSource.Name = fileName; } } if ( executedResult.Result is PhysicalFileResult result && result.FileName.StartsWith(Plugin.Instance.VirtualRoot) && File.Exists(result.FileName) && File.ResolveLinkTarget(result.FileName, returnFinalTarget: true) is { } linkTargetInfo && linkTargetInfo.Exists ) { context.Result = executedResult.Result = new PhysicalFileResult(linkTargetInfo.FullName, result.ContentType) { EnableRangeProcessing = result.EnableRangeProcessing, EntityTag = result.EntityTag, FileDownloadName = result.FileDownloadName, FileName = linkTargetInfo.FullName, LastModified = result.LastModified, }; } } } ================================================ FILE: Shokofin.sln ================================================  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.10.35013.160 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {61FD2206-EAAB-47E0-BB8D-1033C70B3973} EndGlobalSection EndGlobal ================================================ FILE: build.yaml ================================================ name: "Shoko" guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png targetAbi: "1.0.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: "Anime" artifacts: - "Shokofin.dll" changelog: "" ================================================ FILE: build_plugin.py ================================================ from datetime import datetime 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(";") else: return None def extract_packages_to_output(csproj_path, framework): with open(csproj_path, "r") as file: content = file.read() pattern = re.compile( rf'<CommonPackageReference\s+Include="([^"]+)"\s+' rf'Version="[^"]+"\s+' rf'TargetFramework="{re.escape(framework)}"\s*/>' ) matches = [match.group(1) + ".dll" for match in pattern.finditer(content)] return list(set(matches)) def extract_target_abi(csproj_path, framework): with open(csproj_path, "r") as file: content = file.read() pattern = re.compile( rf'<PackageReference\s+Include="Jellyfin\.Controller"\s+' rf'Version="([^"]+)"\s+' rf'TargetFramework="{re.escape(framework)}"\s*/>', re.IGNORECASE, ) match = pattern.search(content) if not match: raise Exception( f"Jellyfin.Controller not found for framework '{framework}' in {os.path.basename(csproj_path)}" ) return match.group(1) 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=False) opts = parser.parse_args() project_file = "./Shokofin/Shokofin.csproj" version = opts.version tag = opts.tag prerelease = bool(opts.prerelease) short_version = ".".join(version.split(".")[:3]) build_number = int(version.split(".")[-1]) 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" # Load the build.yaml file into memory. build_file = "./build.yaml" with open(build_file, "r") as file: build_file_contents = file.read() data = yaml.safe_load(build_file_contents) # Add changelog to the build yaml before we generate the release. if "changelog" in data: if "CHANGELOG" in os.environ: data["changelog"] = os.environ["CHANGELOG"].strip() else: data["changelog"] = "" changelog = data["changelog"] # For every found framework, generate a zip file for the target framework and ABI. try: for framework in extract_target_framework(project_file): target_abi = extract_target_abi(project_file, framework) target_abi_high = ".".join(target_abi.split(".")[:-1]) target_abi_low = target_abi.split(".")[1] artifacts = extract_packages_to_output(project_file, framework) if build_number != "0": generated_version = f"{short_version}.{build_number}{target_abi_low}" else: generated_version = f"{short_version}.{target_abi_low}" generated_changelog = f"Only compatible with **{target_abi_high}.z**.\n\nSee the [release notes](https://github.com/ShokoAnime/Shokofin/releases/tag/{tag}) for more info." if changelog: generated_changelog += f"\n\n---\n\n{changelog}" data = yaml.safe_load(build_file_contents) data["changelog"] = generated_changelog data["artifacts"] = list(set(data["artifacts"] + artifacts)) data["targetAbi"] = target_abi + ".0" 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, generated_version, framework)).read().strip() # read the checksum file jprm wrote checksum = open(zipfile + ".md5sum", "r").read().strip()[:32] timestamp = os.path.getmtime(zipfile) new_zipfile = os.path.join(artifact_dir, f"shoko_{version}_for_{target_abi_high}.zip") os.rename(zipfile, new_zipfile) os.remove(zipfile + ".md5sum") os.remove(zipfile + ".meta.json") jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{tag}/shoko_{version}_for_{target_abi_high}.zip" os.system("jprm repo add --plugin-url=%s %s %s" % (jellyfin_plugin_release_url, jellyfin_repo_file, new_zipfile)) finally: # Restore the original build.yaml after we're done with open(build_file, "w") as file: file.write(build_file_contents) # Compact the unstable manifest after building, so it only contains the last 10 versions. if prerelease: with open(jellyfin_repo_file, "r") as file: repos = json.load(file) repo = repos[0] if "versions" in repo and len(repo["versions"]) > 10: repo["versions"] = repo["versions"][:10] # Update the repository file with open(jellyfin_repo_file, "w") as file: json.dump(repos, file, indent=4) print(version) ================================================ FILE: manifest.json ================================================ [ { "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", "name": "Shoko", "overview": "Stub. manifest.", "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/jellyfin/installing-shokofin", "owner": "ShokoAnime", "category": "Anime", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png", "versions": [ ] } ]