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; } = [];
///
/// Collection Description.
///
public string Overview { get; init; } = group.Description;
public IReadOnlyList Overviews { get; init; } = [];
public IReadOnlyList Notes { get; init; } = [];
public string? OriginalLanguageCode => null;
public DateTime CreatedAt { get; init; }
public DateTime LastUpdatedAt { get; init; }
///
/// Number of files across all shows and movies in the collection and all sub-collections.
///
public int FileCount { get; init; } = group.Sizes.Files;
///
/// Shows in the collection and not in any sub-collections.
///
public IReadOnlyList Shows { get; init; } = shows
.Where(showInfo => !showInfo.IsMovieCollection)
.ToList();
///
/// Movies in the collection and not in any sub-collections.
///
public IReadOnlyList Movies { get; init; } = shows
.Where(showInfo => showInfo.IsMovieCollection)
.ToList();
///
/// Sub-collections of the collection.
///
public IReadOnlyList SubCollections { get; init; } = subCollections;
public CollectionInfo(ShokoGroup group, ShokoSeries series, string? mainSeasonId, List shows, List 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 _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 Titles { get; init; }
public string? Overview { get; init; }
public IReadOnlyList Overviews { get; init; }
public IReadOnlyList 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 Genres { get; init; }
public IReadOnlyList Tags { get; init; }
public IReadOnlyList Studios { get; init; }
public IReadOnlyDictionary> ProductionLocations { get; init; }
public IReadOnlyList ContentRatings { get; init; }
public IReadOnlyList Staff { get; init; }
public List 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 cast,
List genres,
List tags,
string[] productionLocations,
string? anidbContentRating,
TmdbMovieInfo[] tmdbMovies,
TmdbEpisodeInfo[] tmdbEpisodes,
ITmdbEntity? tmdbEntity = null,
ITmdbParentEntity? tmdbParentEntity = null
) {
var contentRatings = new List();
var productionLocationDict = new Dictionary>();
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)[];
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()),
..(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();
var genres = new List();
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.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();
var genres = new List();
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.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 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 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;
///
/// Information about a base item.
///
public interface IBaseItemInfo {
///
/// Unique identifier for the base item.
///
string Id { get; }
///
/// Preferred title according to title settings on the server for the base item type.
///
string Title { get; }
///
/// List of all available titles for the base item.
///
IReadOnlyList Titles { get; }
///
/// Preferred overview according to description settings on the server.
///
string? Overview { get; }
///
/// List of all available overviews for the base item.
///
IReadOnlyList Overviews { get; }
///
/// Notes.
///
IReadOnlyList Notes { get => []; }
///
/// Original language code for the base item if available.
///
string? OriginalLanguageCode { get; }
///
/// Date and time the base item was created.
///
DateTime CreatedAt { get; }
///
/// Date and time the base item was last updated.
///
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 Tags { get; }
IReadOnlyList Genres { get; }
IReadOnlyList Studios { get; }
IReadOnlyDictionary> ProductionLocations { get; }
IReadOnlyList ContentRatings { get; }
IReadOnlyList 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 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 Titles { get; init; }
public string? Overview { get; init; }
public IReadOnlyList Overviews { get; init; }
public IReadOnlyList Notes { get; init; } = [];
public string? OriginalLanguageCode { get; init; }
public Rating CommunityRating { get; init; }
///
/// First premiere date of the season.
///
public DateTime? PremiereDate { get; init; }
///
/// Ended date of the season.
///
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 Genres { get; init; }
public IReadOnlyList Tags { get; init; }
public IReadOnlyList Studios { get; init; }
public IReadOnlyDictionary> ProductionLocations { get; init; }
public IReadOnlyList ContentRatings { get; init; }
///
/// The inferred days of the week this series airs on.
///
/// Each weekday
public IReadOnlyList DaysOfWeek { get; init; }
///
/// The yearly seasons this series belongs to.
///
public IReadOnlyList YearlySeasons { get; init; }
///
/// All staff for the season across all episodes.
///
public IReadOnlyList Staff { get; init; }
///
/// A pre-filtered list of normal episodes that belong to this series.
///
/// Ordered by AniDb air-date.
///
public IReadOnlyList EpisodeList { get; init; }
///
/// A pre-filtered list of "unknown" episodes that belong to this series.
///
/// Ordered by AniDb air-date.
///
public IReadOnlyList AlternateEpisodesList { get; init; }
///
/// A pre-filtered list of "extra" videos that belong to this series.
///
/// Ordered by AniDb air-date.
///
public IReadOnlyList ExtrasList { get; init; }
///
/// A pre-filtered list of special episodes without an ExtraType
/// attached.
///
/// Ordered by AniDb episode number.
///
public IReadOnlyList SpecialsList { get; init; }
///
/// A list of special episodes that come before normal episodes.
///
public IReadOnlySet SpecialsBeforeEpisodes { get; init; }
///
/// A dictionary holding mappings for the previous normal episode for every special episode in a series.
///
public IReadOnlyDictionary SpecialsAnchors { get; init; }
///
/// Related series data available in Shoko.
///
public IReadOnlyList Relations { get; init; }
///
/// Map of related series with type.
///
public IReadOnlyDictionary RelationMap { get; init; }
#region Shoko Series Metadata
///
/// The main Shoko series ID for the season info.
///
public string? ShokoSeriesId => ShokoSeries.FirstOrDefault()?.ShokoSeriesId;
///
/// The main Shoko group ID for the season info.
///
public string? ShokoGroupId => ShokoSeries.FirstOrDefault()?.ShokoGroupId;
///
/// All Shoko series linked to the season info.
///
public ShokoSeriesInfo[] ShokoSeries { get; init; }
#endregion
#region AniDB Anime Metadata
///
/// The main AniDB anime ID for the season info.
///
public string? AnidbAnimeId => AnidbAnime.FirstOrDefault()?.AnidbAnimeId;
///
/// All AniDB anime linked to the season info.
///
public AnidbAnimeInfo[] AnidbAnime { get; init; }
#endregion
#region TMDB Season Metadata
///
/// All TMDB seasons linked to the season info.
///
public TmdbSeasonInfo[] TmdbSeasons { get; init; }
#endregion
#region TMDB Movie Metadata
///
/// All TMDB movies linked to the season info.
///
public TmdbMovieInfo[] TmdbMovies { get; init; }
#endregion
public SeasonInfo(
ShokoApiClient client,
ShokoSeries series,
IEnumerable extraIds,
List episodes,
IReadOnlyList relations,
ITmdbEntity? tmdbEntity,
IReadOnlyDictionary 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();
var specialsAnchorDictionary = new Dictionary();
var specialsList = new List();
var episodesList = new List();
var extrasList = new List();
var altEpisodesList = new List();
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)[];
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()),
..(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);
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 episodes, string? topLevelShokoGroupId, AnidbAnimeInfo[] anidbAnime, ShokoSeriesInfo[] shokoSeries) {
var tags = new List();
var genres = new List();
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.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();
SpecialsAnchors = new Dictionary();
Relations = [];
RelationMap = new Dictionary();
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();
SpecialsAnchors = new Dictionary();
Relations = [];
RelationMap = new Dictionary();
ShokoSeries = shokoSeries;
AnidbAnime = anidbAnime;
TmdbSeasons = [];
TmdbMovies = [new() {
TmdbMovieId = tmdbMovie.Id.ToString(),
TmdbMovieCollectionId = tmdbMovie.CollectionId?.ToString(),
}];
}
public SeasonInfo(ShokoApiClient client, TmdbMovieCollection tmdbMovieCollection, IReadOnlyList movies, IReadOnlyList 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);
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();
SpecialsAnchors = new Dictionary();
Relations = [];
RelationMap = new Dictionary();
ShokoSeries = shokoSeries;
AnidbAnime = anidbAnime;
TmdbSeasons = [];
TmdbMovies = [
..movies.Select(movie => new TmdbMovieInfo {
TmdbMovieId = movie.Id.ToString(),
TmdbMovieCollectionId = movie.CollectionId?.ToString(),
}),
];
}
private void AddYearlySeasons(ref List genres, ref List tags, IEnumerable 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 episodeIds)>> GetFiles() {
var list = new List<(File file, string seriesId, HashSet 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 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;
///
/// Shoko Group Id used for Collection Support.
///
public string? CollectionId { get; init; }
public string Title { get; init; }
public IReadOnlyList Titles { get; init; }
public string? Overview { get; init; }
public IReadOnlyList Overviews { get; init; }
public IReadOnlyList Notes { get; init; } = [];
public string? OriginalLanguageCode { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime LastUpdatedAt { get; init; }
///
/// Indicates that this show is consistent of only movies.
///
public bool IsMovieCollection { get; init; }
///
/// Indicates this is a standalone show without a group attached to it.
///
public bool IsStandalone { get; init; }
///
/// First premiere date of the show.
///
public DateTime? PremiereDate { get; init; }
///
/// Ended date of the show.
///
public DateTime? EndDate { get; init; }
///
/// Custom rating of the show.
///
public string? CustomRating =>
DefaultSeason.IsRestricted ? "XXX" : null;
///
/// Overall community rating of the show.
///
public float CommunityRating { get; init; }
///
/// All tags from across all seasons.
///
public IReadOnlyList Tags { get; init; }
///
/// All genres from across all seasons.
///
public IReadOnlyList Genres { get; init; }
///
/// All production locations from across all seasons.
///
public IReadOnlyDictionary> ProductionLocations { get; init; }
public IReadOnlyList ContentRatings { get; init; }
///
/// All studios from across all seasons.
///
public IReadOnlyList Studios { get; init; }
///
/// The inferred days of the week this series airs on.
///
/// Each weekday
public IReadOnlyList DaysOfWeek { get; init; }
///
/// The yearly seasons this series belongs to.
///
public IReadOnlyList YearlySeasons { get; init; }
///
/// All staff from across all seasons.
///
public IReadOnlyList Staff { get; init; }
///
/// All seasons.
///
public IReadOnlyList SeasonList { get; init; }
///
/// The season order dictionary.
///
public IReadOnlyDictionary SeasonOrderDictionary { get; init; }
///
/// A pre-filtered set of special episode ids without an ExtraType
/// attached.
///
public IReadOnlyDictionary SpecialsDict { get; init; }
///
/// The season number base-number dictionary.
///
private Dictionary SeasonNumberBaseDictionary { get; init; }
///
/// Indicates that the show has specials.
///
public bool HasSpecials =>
SpecialsDict.Count > 0;
///
/// Indicates that the show has specials with files.
///
public bool HasSpecialsWithFiles =>
SpecialsDict.Values.Contains(true);
private bool? _isAvailable = null;
public bool IsAvailable => _isAvailable ??= SeasonOrderDictionary.Values.Any(sI => sI.IsAvailable) || HasSpecialsWithFiles;
///
/// The default season for the show.
///
public readonly SeasonInfo DefaultSeason;
///
/// Episode number padding for file name generation.
///
public readonly int EpisodePadding;
#region Shoko Series Metadata
///
/// Main Shoko Series Id.
///
public string? ShokoSeriesId => ShokoSeries?.FirstOrDefault()?.ShokoSeriesId;
///
/// Main Shoko Group Id.
///
public string? ShokoGroupId => ShokoSeries?.FirstOrDefault()?.ShokoGroupId;
///
/// All Shoko series linked to the show info.
///
public ShokoSeriesInfo[] ShokoSeries { get; init; }
#endregion
#region AniDB Anime Metadata
///
/// Main AniDB Anime Id.
///
public string? AnidbAnimeId => DefaultSeason.StructureType is not Configuration.SeriesStructureType.TMDB_SeriesAndMovies ? AnidbAnime.FirstOrDefault()?.AnidbAnimeId : null;
///
/// All AniDB anime linked to the show info.
///
public AnidbAnimeInfo[] AnidbAnime { get; init; }
#endregion
#region TMDB Show Metadata
///
/// Main TMDB Show Id.
///
public string? TmdbShowId => TmdbShows.FirstOrDefault()?.TmdbShowId;
///
/// Main TvDB Show Id.
///
public string? TvdbShowId => TmdbShows.FirstOrDefault()?.TvdbShowId;
///
/// All TMDB shows linked to the show info.
///
public TmdbShowInfo[] TmdbShows { get; init; }
#endregion
#region TMDB Movie Metadata
///
/// Main TMDB Movie Collection Id.
///
public string? TmdbMovieCollectionId => TmdbMovies.FirstOrDefault()?.TmdbMovieCollectionId;
///
/// All TMDB movies linked to the show info.
///
public TmdbMovieInfo[] TmdbMovies { get; init; }
#endregion
public ShowInfo(ShokoApiClient client, SeasonInfo seasonInfo, TmdbShow? tmdbShow = null, string? collectionId = null) {
var seasonNumberBaseDictionary = new Dictionary();
var seasonOrderDictionary = new Dictionary();
var seasonNumberOffset = 1;
if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0)
seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset);
if (seasonInfo.EpisodeList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
if (seasonInfo.AlternateEpisodesList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
_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 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();
var seasonOrderDictionary = new Dictionary();
var seasonNumberBaseDictionary = new Dictionary();
var seasonNumberOffset = 1;
foreach (var seasonInfo in seasonList) {
if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0)
seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset);
if (seasonInfo.EpisodeList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
if (seasonInfo.AlternateEpisodesList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
foreach (var episodeInfo in seasonInfo.SpecialsList)
specialsSet.Add(episodeInfo.Id, episodeInfo.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);
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 seasonList) {
var defaultSeason = seasonList[0];
var specialsSet = new Dictionary();
var seasonOrderDictionary = new Dictionary();
var seasonNumberBaseDictionary = new Dictionary();
var seasonNumberOffset = 1;
foreach (var seasonInfo in seasonList) {
if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0)
seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset);
if (seasonInfo.EpisodeList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
if (seasonInfo.AlternateEpisodesList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
foreach (var episodeInfo in seasonInfo.SpecialsList)
specialsSet.Add(episodeInfo.Id, episodeInfo.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);
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 { { seasonInfo.Id, 1 } };
SeasonOrderDictionary = new Dictionary { { 1, seasonInfo } };
SpecialsDict = new Dictionary();
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 seasonList) {
var defaultSeason = seasonList[0];
var specialsSet = new Dictionary();
var seasonOrderDictionary = new Dictionary();
var seasonNumberBaseDictionary = new Dictionary();
var seasonNumberOffset = 1;
foreach (var seasonInfo in seasonList) {
if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0)
seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset);
if (seasonInfo.EpisodeList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
if (seasonInfo.AlternateEpisodesList.Count > 0)
seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo);
foreach (var episodeInfo in seasonInfo.SpecialsList)
specialsSet.Add(episodeInfo.Id, episodeInfo.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);
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 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, IEquatable {
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, IEquatable {
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 {
///
/// AniDB Id
///
[JsonPropertyName("ID")]
public int Id { get; set; }
///
/// Id if the series is available locally.
///
[JsonPropertyName("ShokoID")]
public int? ShokoId { get; set; }
///
/// Series type. Series, OVA, Movie, etc
///
public SeriesType Type { get; set; }
///
/// Main Title, usually matches x-jat
///
public string Title { get; set; } = string.Empty;
///
/// There should always be at least one of these, the . May be omitted if needed.
///
public IReadOnlyList? Titles { get; set; }
///
/// Description.
///
public string Description { get; set; } = string.Empty;
///
/// Restricted content. Mainly porn.
///
public bool Restricted { get; set; }
///
/// The main or default poster.
///
public Image Poster { get; set; } = new();
///
/// Number of episodes contained within the series if it's known.
///
public int? EpisodeCount { get; set; }
///
/// The average rating for the anime. Only available on
///
public Rating? Rating { get; set; }
///
/// User approval rate for the similar submission. Only available for similar.
///
public Rating? UserApproval { get; set; }
///
/// Relation type. Only available for relations.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public RelationType? Relation { get; set; }
}
public class AnidbAnimeWithDate : AnidbAnime {
///
/// Description.
///
public new string Description { get; set; } = string.Empty;
///
/// There should always be at least one of these, the . May be omitted if needed.
///
public new List Titles { get; set; } = [];
///
/// The average rating for the anime. Only available on
///
public new Rating Rating { get; set; } = new();
///
/// Number of episodes contained within the series if it's known.
///
public new int EpisodeCount { get; set; }
[JsonIgnore]
private DateTime? InternalAirDate { get; set; } = null;
///
/// Air date (2013-02-27). Anything without an air date is going to be missing a lot of info.
///
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;
///
/// End date, can be omitted. Omitted means that it's still airing (2013-02-27)
///
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; }
///
/// The duration of the episode.
///
public TimeSpan Duration { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public EpisodeType Type { get; set; }
public int EpisodeNumber { get; set; }
public DateTime? AirDate { get; set; }
public IReadOnlyList 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 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 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? 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(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;
}
///
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 {
///
/// The Api Key Token.
///
[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 {
///
/// Shoko.Server version.
///
public ComponentVersion Server { get; set; } = new();
}
public class ComponentVersion {
///
/// Version number.
///
[DefaultValue("1.0.0")]
public string Version { get; set; } = "1.0.0";
///
/// Commit SHA.
///
public string? Commit { get; set; }
///
/// Release channel.
///
public ReleaseChannel? ReleaseChannel { get; set; }
///
/// Release date.
///
public DateTime? ReleaseDate { get; set; } = null;
public override string ToString() {
var extraDetails = new string?[3] {
ReleaseChannel?.ToString(),
Commit?[0..7],
ReleaseDate?.ToUniversalTime().ToString("yyyy-MM-ddThh:mm:ssZ"),
}.Where(s => !string.IsNullOrEmpty(s)).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 {
///
/// The content rating for the specified language.
///
public string Rating { get; set; } = string.Empty;
///
/// The country code the rating applies for.
///
public string Country { get; set; } = string.Empty;
///
/// The language code the rating applies for.
///
public string Language { get; set; } = string.Empty;
///
/// The source of the content rating.
///
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 {
///
/// The Series IDs
///
[JsonPropertyName("SeriesID")]
public SeriesCrossReferenceIDs Series { get; set; } = new();
///
/// The Episode IDs
///
[JsonPropertyName("EpisodeIDs")]
public List Episodes { get; set; } = [];
///
/// File episode cross-reference for a series.
///
public class EpisodeCrossReferenceIDs {
///
/// The Shoko ID, if the local metadata has been created yet.
///
[JsonPropertyName("ID")]
public int? Shoko { get; set; }
///
/// The AniDB ID.
///
public int AniDB { get; set; }
///
/// The Movie DataBase (TMDB) Cross-Reference IDs.
///
public ShokoEpisode.TmdbEpisodeIDs TMDB { get; set; } = new();
///
/// The Release Group ID.
///
public int? ReleaseGroup { get; set; }
///
/// ED2K hash.
///
public string ED2K { get; set; } = string.Empty;
///
/// File size.
///
public long FileSize { get; set; }
///
/// Percentage file is matched to the episode.
///
public CrossReferencePercentage Percentage { get; set; } = new();
}
public class CrossReferencePercentage {
///
/// File/episode cross-reference percentage range start.
///
public int Start { get; set; }
///
/// File/episode cross-reference percentage range end.
///
public int End { get; set; }
///
/// The raw percentage to "group" the cross-references by.
///
public int Size { get; set; }
///
/// The assumed number of groups in the release, to group the
/// cross-references by.
///
public int? Group { get; set; }
}
///
/// File series cross-reference.
///
public class SeriesCrossReferenceIDs {
///
/// The Shoko ID, if the local metadata has been created yet.
/// ///
[JsonPropertyName("ID")]
public int? Shoko { get; set; }
///
/// The AniDB ID.
///
public int AniDB { get; set; }
///
/// The Movie DataBase (TMDB) Cross-Reference IDs.
///
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 {
///
/// 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.
///
Other = 2,
///
/// The episode type is unknown.
///
Unknown = Other,
///
/// A normal episode.
///
Episode = 1,
///
/// A normal episode.
///
Normal = Episode,
///
/// A special episode.
///
Special = 3,
///
/// A trailer.
///
Trailer = 4,
///
/// An opening song, ending song, or other type of credits.
///
Credits = 5,
///
/// Either an opening-song, or an ending-song.
///
ThemeSong = Credits,
///
/// Intro, and/or opening-song.
///
OpeningSong = 6,
///
/// Outro, end-roll, credits, and/or ending-song.
///
EndingSong = 7,
///
/// AniDB parody type. Where else would this be useful?
///
Parody = 8,
///
/// A interview tied to the series.
///
Interview = 9,
///
/// A DVD or BD extra, e.g. BD-menu or deleted scenes.
///
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 {
///
/// The id of the .
///
[JsonPropertyName("ID")]
public int Id { get; set; }
///
/// 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
///
[JsonPropertyName("SeriesIDs")]
public List CrossReferences { get; set; } = [];
///
/// Indicates this file is marked as a variation in Shoko Server.
///
public bool IsVariation { get; set; }
///
/// All the s this is present at.
///
public List Locations { get; set; } = [];
///
/// Try to fit this file's resolution to something like 1080p, 480p, etc.
///
public string Resolution { get; set; } = string.Empty;
///
/// The duration of the file.
///
public TimeSpan Duration { get; set; }
///
/// The file creation date of this file.
///
[JsonPropertyName("Created")]
public DateTime CreatedAt { get; set; }
///
/// When the file was last imported. Usually is a file only imported once,
/// but there may be exceptions.
///
[JsonPropertyName("Imported")]
public DateTime? ImportedAt { get; set; }
[JsonPropertyName("Release")]
public ReleaseInfo? Release { get; set; }
[JsonPropertyName("AniDB")]
public ReleaseInfo? LegacyRelease {
get => Release;
set => Release = value;
}
///
/// The size of the file in bytes.
///
public long Size { get; set; }
///
/// 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.
///
public class Location {
///
/// File location ID.
///
[JsonPropertyName("ID")]
public int? Id { get; set; }
///
/// The id of the this
/// resides in.
///
[JsonPropertyName("ImportFolderID")]
public int ImportFolderId {
get => ManagedFolderId;
set => ManagedFolderId = value;
}
///
/// The id of the this
/// resides in.
///
[JsonPropertyName("ManagedFolderID")]
public int ManagedFolderId { get; set; }
///
/// The relative path from the base of the to
/// where the lies.
///
[JsonPropertyName("RelativePath")]
public string InternalPath { get; set; } = string.Empty;
///
/// Cached path for later re-use.
///
[JsonIgnore]
private string? CachedPath { get; set; }
///
/// The relative path from the base of the to
/// where the lies, with a leading slash applied at
/// the start.
///
[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;
}
}
///
/// True if the server can access the the at
/// the moment of requesting the data.
///
[JsonPropertyName("Accessible")]
public bool IsAccessible { get; set; } = false;
}
///
/// User stats for the file.
///
public class UserStats {
///
/// Where to resume the next playback.
///
public TimeSpan? ResumePosition { get; set; }
///
/// Total number of times the file have been watched.
///
public int WatchedCount { get; set; }
///
/// When the file was last watched. Will be null if the full is
/// currently marked as unwatched.
///
public DateTime? LastWatchedAt { get; set; }
///
/// When the entry was last updated.
///
public DateTime LastUpdatedAt { get; set; }
///
/// True if the object is considered empty.
///
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 {
///
/// AniDB, TMDB, etc.
///
public ImageSource Source { get; set; } = ImageSource.AniDB;
///
/// Poster, Banner, etc.
///
public ShokoImageType Type { get; set; } = ShokoImageType.Poster;
///
/// The image's id.
///
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public int ID { get; set; } = 0;
///
/// True if the image is marked as the preferred for the given
/// . Only one preferred is possible for a given
/// .
///
[JsonPropertyName("Preferred")]
public bool IsPreferred { get; set; } = false;
///
/// True if the image has been disabled. You must explicitly ask for these,
/// for hopefully obvious reasons.
///
[JsonPropertyName("Disabled")]
public bool IsDisabled { get; set; } = false;
///
/// The language code for the image, if available.
///
public string? LanguageCode { get; set; } = null;
///
/// Width of the image, if available.
///
public int? Width { get; set; }
///
/// Height of the image, if available.
///
public int? Height { get; set; }
///
/// The relative path from the image base directory if the image is present
/// on the server.
///
[JsonPropertyName("RelativeFilepath")]
public string? LocalPath { get; set; }
///
/// True if the image is available.
///
[JsonIgnore]
public virtual bool IsAvailable
=> !string.IsNullOrEmpty(LocalPath);
///
/// Community rating for the image, if available.
///
public Rating? CommunityRating { get; set; }
///
/// Json deserialization constructor.
///
public Image() { }
///
/// Copy constructor.
///
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;
}
///
/// Get an URL to both download the image on the backend and preview it for
/// the clients.
///
///
/// May or may not work 100% depending on how the servers and clients are
/// set up, but better than nothing.
///
/// The image URL
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();
}
///
/// Image source.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ImageSource {
///
///
///
AniDB = 1,
///
///
///
TMDB = 2,
///
///
///
Shoko = 100,
}
///
/// Image type.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ShokoImageType {
///
///
///
Poster = 1,
///
///
///
Banner = 2,
///
///
///
Thumb = 3,
///
///
///
Thumbnail = Thumb,
///
///
///
Fanart = 4,
///
///
///
Backdrop = Fanart,
///
///
///
Character = 5,
///
///
///
Staff = 6,
///
/// Clear-text logo.
///
Logo = 7,
}
================================================
FILE: Shokofin/API/Models/Images.cs
================================================
using System.Collections.Generic;
namespace Shokofin.API.Models;
public class Images {
public List Posters { get; set; } = [];
public List Backdrops { get; set; } = [];
public List Banners { get; set; } = [];
public List Logos { get; set; } = [];
}
public class EpisodeImages : Images {
public List Thumbnails { get; set; } = [];
}
================================================
FILE: Shokofin/API/Models/ListResult.cs
================================================
using System.Collections.Generic;
namespace Shokofin.API.Models;
///
/// A list with the total count of entries that
/// match the filter and a sliced or the full list of
/// entries.
///
public class ListResult {
///
/// Total number of entries that matched the
/// applied filter.
///
public int Total { get; set; } = 0;
///
/// A sliced page or the whole list of entries.
///
public IReadOnlyList List { get; set; } = [];
}
================================================
FILE: Shokofin/API/Models/ManagedFolder.cs
================================================
using System.Text.Json.Serialization;
namespace Shokofin.API.Models;
public class ManagedFolder {
///
/// The ID of the managed folder.
///
[JsonPropertyName("ID")]
public int Id { get; set; }
///
/// The friendly name of the managed folder, if any.
///
public string? Name { get; set; }
}
================================================
FILE: Shokofin/API/Models/Rating.cs
================================================
namespace Shokofin.API.Models;
public class Rating {
///
/// The rating value relative to the .
///
public decimal Value { get; set; } = 0;
///
/// Max value for the rating.
///
public int MaxValue { get; set; } = 0;
///
/// AniDB, etc.
///
public string Source { get; set; } = string.Empty;
///
/// number of votes
///
public int? Votes { get; set; }
///
/// for temporary vs permanent, or any other situations that may arise later
///
public string? Type { get; set; }
///
/// Json deserialization constructor.
///
public Rating() { }
///
/// Copy constructor.
///
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;
///
/// Describes relations between two series entries.
///
public class Relation {
///
/// The IDs of the series.
///
public RelationIDs IDs { get; set; } = new();
///
/// The IDs of the related series.
///
public RelationIDs RelatedIDs { get; set; } = new();
///
/// The relation between and .
///
public RelationType Type { get; set; }
///
/// AniDB, etc.
///
public string Source { get; set; } = "Unknown";
///
/// Relation IDs.
///
public class RelationIDs {
///
/// The ID of the entry.
///
public int? Shoko { get; set; }
///
/// The ID of the entry.
///
public int? AniDB { get; set; }
}
}
///
/// Explains how the main entry relates to the related entry.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RelationType {
///
/// The relation between the entries cannot be explained in simple terms.
///
Other = 0,
///
/// The entries use the same setting, but follow different stories.
///
SameSetting = 1,
///
/// The entries use the same base story, but is set in alternate settings.
///
AlternativeSetting = 2,
///
/// The entries tell the same story in the same settings but are made at different times.
///
AlternativeVersion = 3,
///
/// The entries tell different stories in different settings but otherwise shares some character(s).
///
SharedCharacters = 4,
///
/// The first story either continues, or expands upon the story of the related entry.
///
Prequel = 20,
///
/// The related entry is the main-story for the main entry, which is a side-story.
///
MainStory = 21,
///
/// The related entry is a longer version of the summarized events in the main entry.
///
FullStory = 22,
///
/// The related entry either continues, or expands upon the story of the main entry.
///
Sequel = 40,
///
/// The related entry is a side-story for the main entry, which is the main-story.
///
SideStory = 41,
///
/// The related entry summarizes the events of the story in the main entry.
///
Summary = 42,
}
================================================
FILE: Shokofin/API/Models/ReleaseGroup.cs
================================================
using System.Text.Json.Serialization;
using Shokofin.API.Converters;
namespace Shokofin.API.Models;
public class ReleaseGroup {
///
/// The AniDB Release Group ID (e.g. 1)
/// ///
[JsonPropertyName("ID"), JsonConverter(typeof(JsonAutoStringConverter))]
public string? Id { get; set; }
///
/// The release group's Name (e.g. "Unlimited Translation Works")
///
public string? Name { get; set; }
///
/// The release group's Name (e.g. "UTW")
///
public string? ShortName { get; set; }
///
/// The release group's Source (e.g. "AniDB")
///
public string? Source { get; set; }
}
================================================
FILE: Shokofin/API/Models/ReleaseInfo.cs
================================================
using System.Text.Json.Serialization;
namespace Shokofin.API.Models;
public class ReleaseInfo {
///
/// Blu-ray, DVD, LD, TV, etc..
///
[JsonInclude, JsonConverter(typeof(JsonStringEnumConverter))]
public ReleaseSource Source { get; set; }
///
/// The Release Group.
///
[JsonInclude, JsonPropertyName("Group")]
public ReleaseGroup? Group { get; set; }
///
/// The Release Group.
///
[JsonInclude, JsonPropertyName("ReleaseGroup")]
public ReleaseGroup? LegacyGroup {
get => Group;
set => Group = value;
}
///
/// The file's version.
///
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 {
///
/// Extra info about the role. For example, role can be voice actor, while role_details is Main Character
///
[JsonPropertyName("RoleDetails")]
public string Name { get; set; } = string.Empty;
///
/// The role that the staff plays, cv, writer, director, etc
///
[JsonConverter(typeof(JsonStringEnumConverter))]
[JsonPropertyName("RoleName")]
public CreatorRoleType Type { get; set; }
///
/// Most will be Japanese. Once AniList is in, it will have multiple options
///
public string? Language { get; set; }
public Person Staff { get; set; } = new();
///
/// The character played, the is of type
/// .
///
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 {
///
/// Id of the person.
///
[JsonPropertyName("ID")]
public int? Id { get; set; }
///
/// Whether the object is a person, company or collab. Only set for AniDB creators.
///
public string? Type { get; set; }
///
/// Main Name, romanized if needed
/// ex. John Smith
///
public string Name { get; set; } = string.Empty;
///
/// Alternate Name, this can be any other name, whether kanji, an alias, etc
/// ex. 澤野弘之
///
public string? AlternateName { get; set; }
///
/// A description, bio, etc
/// ex. John Smith was born September 12, 1980 in Tokyo, Japan. He is a composer and arranger.
///
public string Description { get; set; } = string.Empty;
///
/// Visual representation of the character or staff. Usually a profile
/// picture.
///
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 {
///
/// Voice actor or voice actress.
///
Actor,
///
/// Synonym of for backwards compatibility for a few server versions.
///
Seiyuu = Actor,
///
/// This can be anything involved in writing the show.
///
Staff,
///
/// The studio responsible for publishing the show.
///
Studio,
///
/// The main producer(s) for the show.
///
Producer,
///
/// Direction.
///
Director,
///
/// Series Composition.
///
SeriesComposer,
///
/// Character Design.
///
CharacterDesign,
///
/// Music composer.
///
Music,
///
/// Responsible for the creation of the source work this show is derived from.
///
SourceWork,
}
================================================
FILE: Shokofin/API/Models/SeriesType.cs
================================================
using System.Text.Json.Serialization;
namespace Shokofin.API.Models;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SeriesType {
///
/// Only for use with .
///
None,
///
/// The series type is unknown as of yet. This may be updated in the future.
///
Unknown,
///
/// 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.
///
Other,
///
/// Standard TV series.
///
TV,
///
/// TV special.
///
TVSpecial,
///
/// Original Net Animations (ONAs), AKA standalone releases that aired on the web.
///
Web,
///
/// All movies, regardless of source (e.g. web or theater)
///
Movie,
///
/// Original Video Animations, AKA standalone releases that don't air on TV or the web.
///
OVA,
///
/// Standalone music videos.
///
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();
///
/// All identifiers related to the episode entry, e.g. the Shoko, AniDB,
/// TMDB, etc.
///
public EpisodeIDs IDs { get; set; } = new();
///
/// The preferred name of the episode based on the selected episode language
/// settings on the server.
///
public string Name { get; set; } = string.Empty;
///
/// The preferred description of the episode based on the selected episode
/// language settings on the server.
///
public string Description { get; set; } = string.Empty;
///
/// The duration of the episode.
///
public TimeSpan Duration { get; set; }
///
/// Indicates the episode is hidden.
///
public bool IsHidden { get; set; }
///
/// Number of files linked to the episode.
///
///
public int Size { get; set; }
///
/// The , if is
/// included in the data to add.
///
public AnidbEpisode AniDB { get; set; } = new();
///
/// File cross-references for the episode.
///
public List CrossReferences { get; set; } = [];
///
/// When the episode entry was created.
///
[JsonPropertyName("Created")]
public DateTime CreatedAt { get; set; }
///
/// When the episode entry was last updated.
///
[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 TvDB { get; set; } = [];
public List IMDB { get; set; } = [];
#endif
public TmdbEpisodeIDs TMDB { get; init; } = new();
}
public class TmdbEpisodeIDs {
public List Episode { get; init; } = [];
public List Movie { get; init; } = [];
#if DEBUG
public List 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();
///
/// When the group entry was created.
///
[JsonPropertyName("Created")]
public DateTime CreatedAt { get; set; }
///
/// When the group entry was last updated.
///
[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; }
}
///
/// Downloaded, Watched, Total, etc
///
public class GroupSizes : ShokoSeries.SeriesSizes {
///
/// Number of direct sub-groups within the group.
/// ///
///
public int SubGroups { get; set; }
#if DEBUG
///
/// Count of the different series types within the group.
///
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();
///
/// All identifiers related to the series entry, e.g. the Shoko, AniDB,
/// TMDB, etc.
///
public SeriesIDs IDs { get; set; } = new();
///
/// The preferred name of the series based on the selected series language
/// settings on the server.
///
public string Name { get; set; } = string.Empty;
///
/// The preferred description of the series based on the selected series
/// language settings on the server.
///
public string Description { get; set; } = string.Empty;
///
/// The yearly seasons this series belongs to.
///
public List YearlySeasons { get; set; } = [];
///
/// The AniDB entry.
///
public AnidbAnimeWithDate AniDB { get; set; } = new();
///
/// Different size metrics for the series.
///
public SeriesSizes Sizes { get; set; } = new();
///
/// When the series entry was created during the process of the first file
/// being added to Shoko.
///
[JsonPropertyName("Created")]
public DateTime CreatedAt { get; set; }
///
/// When the series entry was last updated.
///
[JsonPropertyName("Updated")]
public DateTime LastUpdatedAt { get; set; }
public class SeriesIDs : IDs {
///
/// The ID of the direct parent group, if it has one.
///
public int ParentGroup { get; set; } = 0;
///
/// The ID of the top-level (ancestor) group this series belongs to.
///
public int TopLevelGroup { get; set; } = 0;
///
/// The AniDB ID
///
public int AniDB { get; set; } = 0;
///
/// The TvDB IDs
///
public List TvDB { get; set; } = [];
///
/// The IMDB Movie IDs.
///
public List IMDB { get; set; } = [];
///
/// The Movie Database (TMDB) IDs.
///
public TmdbSeriesIDs TMDB { get; set; } = new();
}
public class TmdbSeriesIDs {
public List Movie { get; init; } = [];
public List Show { get; init; } = [];
}
///
/// Different size metrics for the series.
///
public class SeriesSizes {
#if DEBUG
///
/// Count of hidden episodes, be it available or missing.
///
public int Hidden { get; set; }
#endif
///
/// Combined count of all files across all file sources within the series or group.
///
public int Files =>
FileSources.Unknown +
FileSources.Other +
FileSources.TV +
FileSources.DVD +
FileSources.BluRay +
FileSources.Web +
FileSources.VHS +
FileSources.VCD +
FileSources.LaserDisc +
FileSources.Camera;
///
/// Counts of each file source type available within the local collection
///
public FileSourceCounts FileSources { get; set; } = new();
#if DEBUG
///
/// What is downloaded and available
///
public EpisodeTypeCounts Local { get; set; } = new();
///
/// What is local and watched.
///
public EpisodeTypeCounts Watched { get; set; } = new();
#endif
///
/// Total count of each type
///
public EpisodeTypeCounts Total { get; set; } = new();
///
/// Lists the count of each type of episode.
///
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;
///
/// APIv3 Studio Data Transfer Object (DTO).
///
public class Studio {
///
/// Studio ID relative to the .
///
[JsonPropertyName("ID")]
public int Id { get; init; }
///
/// The name of the studio.
///
public string Name { get; init; } = string.Empty;
///
/// The country the studio originates from.
///
public string CountryOfOrigin { get; init; } = string.Empty;
///
/// Entities produced by the studio in the local collection, both movies
/// and/or shows.
///
public int Size { get; init; }
///
/// Logos used by the studio.
///
public IReadOnlyList Logos { get; init; } = [];
///
/// The source of which the studio metadata belongs to.
///
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; }
///
/// Preferred title based upon Shoko's title preference.
///
string Title { get; }
///
/// All available titles for the entity, if they should be included.
///
IReadOnlyList Titles { get; }
///
/// Preferred overview based upon description preference.
///
string Overview { get; }
///
/// All available overviews for the entity, if they should be included.
///
IReadOnlyList Overviews { get; }
///
/// When the local metadata was first created.
///
DateTime CreatedAt { get; }
///
/// When the local metadata was last updated with new changes from the
/// remote.
///
DateTime LastUpdatedAt { get; }
}
================================================
FILE: Shokofin/API/Models/TMDB/ITmdbParentEntity.cs
================================================
using System.Collections.Generic;
namespace Shokofin.API.Models.TMDB;
public interface ITmdbParentEntity : ITmdbEntity {
///
/// Original language the entity was shot in.
/// ///
string OriginalLanguage { get; }
///
/// Indicates the entity is restricted to an age group above the legal age,
/// because it's a pornography.
///
bool IsRestricted { get; }
///
/// Genres.
///
IReadOnlyList Genres { get; }
///
/// Keywords.
///
IReadOnlyList Keywords { get; }
///
/// User rating of the entity from TMDB users.
///
Rating UserRating { get; }
///
/// Content ratings for different countries for this entity.
///
IReadOnlyList ContentRatings { get; }
///
/// The production countries.
///
IReadOnlyDictionary ProductionCountries { get; }
///
/// The production companies (studios) that produced the entity.
///
IReadOnlyList 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;
///
/// APIv3 The Movie DataBase (TMDB) Episode Data Transfer Object (DTO).
///
public class TmdbEpisode : ITmdbEntity {
///
/// TMDB Episode ID.
///
[JsonPropertyName("ID")]
public int Id { get; set; }
///
/// TMDB Season ID.
///
[JsonPropertyName("SeasonID")]
public string SeasonId { get; set; } = string.Empty;
///
/// TMDB Show ID.
///
[JsonPropertyName("ShowID")]
public int ShowId { get; set; }
///
/// The ID of the alternate ordering currently in use for the episode.
///
[JsonPropertyName("AlternateOrderingID")]
public string AlternateOrderingId { get; init; } = string.Empty;
///
/// TVDB Episode ID, if available.
///
[JsonPropertyName("TvdbEpisodeID")]
public int? TvdbEpisodeId { get; set; }
///
/// Preferred title based upon episode title preference.
///
public string Title { get; set; } = string.Empty;
///
/// All available titles for the episode, if they should be included.
///
public IReadOnlyList Titles { get; set; } = [];
///
/// Preferred overview based upon episode title preference.
///
public string Overview { get; set; } = string.Empty;
///
/// All available overviews for the episode, if they should be included.
///
public IReadOnlyList Overviews { get; set; } = [];
///
/// The episode number for the main ordering or alternate ordering in use.
///
public int EpisodeNumber { get; set; }
///
/// The season number for the main ordering or alternate ordering in use.
///
public int SeasonNumber { get; set; }
///
/// User rating of the episode from TMDB users.
///
public Rating UserRating { get; set; } = new();
///
/// The episode run-time, if it is known.
///
public TimeSpan? Runtime { get; set; }
///
/// The cast that have worked on this show across all episodes and all seasons.
///
public IReadOnlyList Cast { get; set; } = [];
///
/// The crew that have worked on this show across all episodes and all seasons.
///
public IReadOnlyList Crew { get; set; } = [];
///
/// All available ordering for the episode, if they should be included.
///
public IReadOnlyList Ordering { get; init; } = [];
///
/// TMDB episode to file cross-references.
///
public IReadOnlyList FileCrossReferences { get; set; } = [];
///
/// The date the episode first aired, if it is known.
///
public DateOnly? AiredAt { get; set; }
///
/// When the local metadata was first created.
///
public DateTime CreatedAt { get; set; }
///
/// When the local metadata was last updated with new changes from the
/// remote.
///
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
///
/// The ordering ID.
///
public string OrderingID { get; set; } = string.Empty;
///
/// The alternate ordering type. Will not be set if the main ordering is
/// used.
///
[JsonConverter(typeof(JsonStringEnumConverter))]
public AlternateOrderingType? OrderingType { get; set; }
///
/// English name of the alternate ordering scheme.
///
public string OrderingName { get; set; } = string.Empty;
///
/// The season id. Will be a stringified integer for the main ordering,
/// or a hex id any alternate ordering.
///
public string SeasonID { get; set; } = string.Empty;
///
/// English name of the season.
///
public string SeasonName { get; set; } = string.Empty;
#endif
///
/// The season number for the ordering.
///
public int SeasonNumber { get; set; }
///
/// The episode number for the ordering.
///
public int EpisodeNumber { get; set; }
///
/// Indicates the current ordering is the default ordering for the episode.
///
public bool IsDefault { get; set; }
#if DEBUG
///
/// Indicates the current ordering is the preferred ordering for the episode.
///
public bool IsPreferred { get; set; }
///
/// Indicates the current ordering is in use for the episode.
///
public bool InUse { get; set; }
#endif
}
}
================================================
FILE: Shokofin/API/Models/TMDB/TmdbEpisodeCrossReference.cs
================================================
using System.Text.Json.Serialization;
namespace Shokofin.API.Models.TMDB;
///
/// APIv3 The Movie DataBase (TMDB) Episode Cross-Reference Data Transfer Object (DTO).
///
public class TmdbEpisodeCrossReference {
///
/// AniDB Anime ID.
///
[JsonPropertyName("AnidbAnimeID")]
public int AnidbAnimeId { get; init; }
///
/// AniDB Episode ID.
///
[JsonPropertyName("AnidbEpisodeID")]
public int AnidbEpisodeId { get; init; }
///
/// TMDB Show ID.
///
[JsonPropertyName("TmdbShowID")]
public int TmdbShowId { get; init; }
///
/// TMDB Episode ID. Will be 0 if the
/// is not mapped to a TMDB Episode yet.
///
[JsonPropertyName("TmdbEpisodeID")]
public int TmdbEpisodeId { get; init; }
///
/// The index to order the cross-references if multiple references
/// exists for the same anidb or tmdb episode.
///
public int Index { get; init; }
///
/// The match rating.
///
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 {
///
/// TMDB Movie ID.
///
[JsonPropertyName("ID")]
public int Id { get; set; }
///
/// TMDB Movie Collection ID, if the movie is in a movie collection on TMDB.
///
[JsonPropertyName("CollectionID")]
public int? CollectionId { get; set; }
///
/// IMDB Movie ID, if available.
///
[JsonPropertyName("ImdbMovieID")]
public string? ImdbMovieId { get; set; }
///
/// Preferred title based upon series title preference.
///
public string Title { get; set; } = string.Empty;
///
/// All available titles for the movie, if they should be included.
///
public IReadOnlyList Titles { get; set; } = [];
///
/// Preferred overview based upon description preference.
///
public string Overview { get; set; } = string.Empty;
///
/// All available overviews for the movie, if they should be included.
///
public IReadOnlyList Overviews { get; set; } = [];
///
/// Original language the movie was shot in.
///
public string OriginalLanguage { get; set; } = string.Empty;
///
/// Indicates the movie is restricted to an age group above the legal age,
/// because it's a pornography.
///
public bool IsRestricted { get; set; }
///
/// 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.
///
public bool IsVideo { get; set; }
///
/// User rating of the movie from TMDB users.
///
public Rating UserRating { get; set; } = new();
///
/// The movie run-time, if it is known.
///
public TimeSpan? Runtime { get; set; } = null;
///
/// Genres.
///
public IReadOnlyList Genres { get; set; } = [];
///
/// Keywords.
///
public IReadOnlyList Keywords { get; set; } = [];
///
/// Content ratings for different countries for this movie.
///
public IReadOnlyList ContentRatings { get; set; } = [];
///
/// The production countries.
///
public IReadOnlyDictionary ProductionCountries { get; set; } = new Dictionary();
///
/// The production companies (studios) that produced the movie.
///
public IReadOnlyList Studios { get; set; } = [];
///
/// The cast that have worked on this movie.
///
public IReadOnlyList Cast { get; set; } = [];
///
/// The crew that have worked on this movie.
///
public IReadOnlyList Crew { get; set; } = [];
///
/// The yearly seasons this series belongs to.
///
public List YearlySeasons { get; set; } = [];
///
/// TMDB movie to file cross-references.
///
public IReadOnlyList FileCrossReferences { get; set; } = [];
///
/// The date the movie first released, if it is known.
///
public DateOnly? ReleasedAt { get; set; }
///
/// When the local metadata was first created.
///
public DateTime CreatedAt { get; set; }
///
/// When the local metadata was last updated with new changes from the
/// remote.
///
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 {
///
/// TMDB Movie Collection ID.
///
[JsonPropertyName("ID")]
public int Id { get; init; }
///
/// Preferred title based upon series title preference.
///
public string Title { get; init; } = string.Empty;
///
/// All available titles for the movie collection, if they should be included.
///
public IReadOnlyList Titles { get; init; } = [];
///
/// Preferred overview based upon description preference.
///
public string Overview { get; init; } = string.Empty;
///
/// All available overviews for the movie collection, if they should be included.
///
public IReadOnlyList Overviews { get; init; } = [];
public int MovieCount { get; init; }
///
/// When the local metadata was first created.
///
public DateTime CreatedAt { get; init; }
///
/// When the local metadata was last updated with new changes from the
/// remote.
///
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;
///
/// APIv3 The Movie DataBase (TMDB) Movie Cross-Reference Data Transfer Object (DTO).
///
public class TmdbMovieCrossReference {
///
/// AniDB Anime ID.
///
[JsonPropertyName("AnidbAnimeID")]
public int AnidbAnimeId { get; init; }
///
/// AniDB Episode ID.
///
[JsonPropertyName("AnidbEpisodeID")]
public int AnidbEpisodeId { get; init; }
///
/// TMDB Show ID.
///
[JsonPropertyName("TmdbMovieID")]
public int TmdbMovieId { get; init; }
///
/// The match rating.
///
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 {
///
/// TMDB Season ID.
///
[JsonPropertyName("ID")]
public string Id { get; set; } = string.Empty;
///
/// TMDB Show ID.
///
[JsonPropertyName("ShowID")]
public int ShowId { get; set; }
///
/// The alternate ordering this season is associated with. Will be null
/// for main series seasons.
///
[JsonPropertyName("AlternateOrderingID")]
public string AlternateOrderingId { get; set; } = string.Empty;
///
/// Preferred title based upon episode title preference.
///
public string Title { get; set; } = string.Empty;
///
/// All available titles for the season, if they should be included.
/// ///
public IReadOnlyList Titles { get; set; } = [];
///
/// Preferred overview based upon episode title preference.
///
public string Overview { get; set; } = string.Empty;
///
/// All available overviews for the season, if they should be included.
///
public IReadOnlyList Overviews { get; set; } = [];
///
/// The season number for the main ordering or alternate ordering in use.
///
public int SeasonNumber { get; set; }
///
/// Count of episodes associated with the season.
///
public int EpisodeCount { get; set; }
///
/// Indicates the alternate ordering season is locked. Will not be set if
/// is not set.
///
public bool? IsLocked { get; set; }
///
/// The yearly seasons this series belongs to.
///
public List YearlySeasons { get; set; } = [];
///
/// When the local metadata was first created.
///
public DateTime CreatedAt { get; set; }
///
/// When the local metadata was last updated with new changes from the
/// remote.
///
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 {
///
/// TMDB Show ID.
///
[JsonPropertyName("ID")]
public int Id { get; set; }
///
/// TvDB Show ID, if available.
///
[JsonPropertyName("TvdbID")]
public int? TvdbId { get; set; }
///
/// The ID of the alternate ordering currently in use for the show.
///
[JsonPropertyName("AlternateOrderingID")]
public string AlternateOrderingId { get; init; } = string.Empty;
///
/// Preferred title based upon series title preference.
///
public string Title { get; set; } = string.Empty;
///
/// All available titles, if they should be included.
///
public IReadOnlyList Titles { get; set; } = [];
///
/// Preferred overview based upon description preference.
///
public string Overview { get; set; } = string.Empty;
///
/// All available overviews for the series, if they should be included.
///
public IReadOnlyList Overviews { get; set; } = [];
///
/// Original language the show was shot in.
///
public string OriginalLanguage { get; set; } = string.Empty;
///
/// Indicates the show is restricted to an age group above the legal age,
/// because it's a pornography.
///
public bool IsRestricted { get; set; }
///
/// User rating of the show from TMDB users.
///
public Rating UserRating { get; set; } = new();
///
/// Genres.
///
public IReadOnlyList Genres { get; set; } = [];
///
/// Keywords.
///
public IReadOnlyList Keywords { get; set; } = [];
///
/// Content ratings for different countries for this show.
///
public IReadOnlyList ContentRatings { get; set; } = [];
///
/// The production countries.
///
public IReadOnlyDictionary ProductionCountries { get; set; } = new Dictionary();
///
/// The production companies (studios) that produced the show.
///
public IReadOnlyList Studios { get; set; } = [];
///
/// Count of episodes associated with the show.
///
public int EpisodeCount { get; set; }
///
/// Count of seasons associated with the show.
///
public int SeasonCount { get; set; }
///
/// Count of locally alternate ordering schemes associated with the show.
///
public int AlternateOrderingCount { get; set; }
///
/// The date the first episode aired at, if it is known.
///
public DateOnly? FirstAiredAt { get; set; }
///
/// The date the last episode aired at, if it is known.
///
public DateOnly? LastAiredAt { get; set; }
///
/// When the local metadata was first created.
///
public DateTime CreatedAt { get; set; }
///
/// When the local metadata was last updated with new changes from the
/// remote.
///
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 {
///
/// Tag id. Relative to it's source for now.
///
[JsonPropertyName("ID")]
public int Id { get; set; }
///
/// Parent id relative to the source, if any.
///
[JsonPropertyName("ParentID")]
public int? ParentId { get; set; }
///
/// The tag itself
///
public string Name { get; set; } = string.Empty;
///
/// What does the tag mean/what's it for
///
public string? Description { get; set; }
///
/// True if the tag has been verified.
///
///
/// 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.
///
public bool? IsVerified { get; set; }
///
/// True if the tag is considered a spoiler for all series it appears on.
///
[JsonPropertyName("IsSpoiler")]
public bool IsGlobalSpoiler { get; set; }
///
/// True if the tag is considered a spoiler for that particular series it is
/// set on.
///
public bool? IsLocalSpoiler { get; set; }
///
/// How relevant is it to the series
///
public TagWeight? Weight { get; set; }
///
/// When the tag info was last updated.
///
public DateTime? LastUpdated { get; set; }
///
/// Source. AniDB, User, etc.
///
public string Source { get; set; } = string.Empty;
}
public class ResolvedTag : Tag {
// All the abstract tags I know about.
private static readonly HashSet 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 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 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;
///
/// True if the tag is considered a spoiler for that particular series it is
/// set on.
///
public new bool IsLocalSpoiler;
///
/// How relevant is it to the series
///
public new TagWeight Weight;
public string Namespace;
public IReadOnlyDictionary Children;
public IReadOnlyDictionary RecursiveNamespacedChildren;
public ResolvedTag(Tag tag, ResolvedTag? parent, Func?> 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 {
///
/// The text value.
///
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
///
/// Setter for titles.
///
[JsonPropertyName("Name")]
public string LegacyValue { set => Value = value; }
///
/// alpha 3 language codes with custom extensions (e.g. "x-jat" for romaji, etc.).
///
[JsonPropertyName("Language")]
public string LanguageCode { get; set; } = "unk";
///
/// True if this is the default text value among all values for the entity.
///
[JsonPropertyName("Default")]
public bool IsDefault { get; set; }
///
/// True if this is the preferred text value among all values for the entity.
///
[JsonPropertyName("Preferred")]
public bool IsPreferred { get; set; }
///
/// AniDB, TMDB, AniList, etc.
///
[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 {
///
/// AniDB anime title type. Only available on series level titles.
///
[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, IEquatable {
///
/// The year of the season.
///
[JsonPropertyName("Year")]
public int Year { get; set; }
///
/// The name of the season.
///
[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;
///
/// The name of a yearly season.
///
public enum YearlySeasonName {
///
/// Winter.
///
Winter = 0,
///
/// Spring.
///
Spring = 1,
///
/// Summer.
///
Summer = 2,
///
/// Autumn.
///
Autumn = 3,
///
/// Fall. This is an alias for .
///
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;
///
/// All API calls to Shoko needs to go through this gateway.
///
public class ShokoApiClient : IDisposable {
private static readonly TimeSpan _requestWaitLogThreshold = TimeSpan.FromMilliseconds(50);
private readonly ILogger _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 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 GetOrNull(string url, string? apiKey = null, bool skipCache = false, CancellationToken cancellationToken = default) {
try {
return await Get(url, HttpMethod.Get, apiKey, skipCache, cancellationToken).ConfigureAwait(false);
}
catch (ApiException e) when (e.StatusCode == HttpStatusCode.NotFound) {
return default;
}
}
private Task Get(string url, string? apiKey = null, bool skipCache = false, CancellationToken cancellationToken = default)
=> Get(url, HttpMethod.Get, apiKey, skipCache, cancellationToken);
private async Task Get(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(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(responseStream, cancellationToken: cancellationToken).ConfigureAwait(false) ??
throw new ApiException(response.StatusCode, nameof(ShokoApiClient), "Unexpected null return value.");
return value;
}
).ConfigureAwait(false);
}
private async Task 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 Post(string url, Type body, string? apiKey = null, bool skipCache = true, CancellationToken cancellationToken = default)
=> Post(url, HttpMethod.Post, body, apiKey, skipCache, cancellationToken);
private async Task Post(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(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(responseStream, cancellationToken: cancellationToken).ConfigureAwait(false) ??
throw new ApiException(response.StatusCode, nameof(ShokoApiClient), "Unexpected null return value.");
return value;
}
).ConfigureAwait(false);
}
private async Task Post(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 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 {
{"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(stream).ConfigureAwait(false);
return result;
}
#endregion
#region Version
public async Task 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(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 CheckIfPluginsExposed(CancellationToken cancellationToken = default)
=> (await Get($"/api/v3/Plugin", HttpMethod.Get, cancellationToken: cancellationToken).ConfigureAwait(false)) is { StatusCode: HttpStatusCode.OK };
public async Task 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();
if (value is null)
return "webui";
return value;
}
catch (HttpRequestException httpEx) when (httpEx.Message.Contains("Shoko Server")) {
return null;
}
}
#endregion
#region Image
public Task 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 GetManagedFolder(int managedFolderId)
=> HasPluginsExposed
? await GetOrNull($"/api/v3/ManagedFolder/{managedFolderId}").ConfigureAwait(false)
: await GetOrNull($"/api/v3/ImportFolder/{managedFolderId}").ConfigureAwait(false);
public async Task> GetFilesInManagedFolder(int managedFolderId, string subPath, int page = 1)
=> HasPluginsExposed
? await GetOrNull