Skip to content

Commit

Permalink
fix: add workaround for mixed library support
Browse files Browse the repository at this point in the history
- Implement a workaround for using the mixed library with no movies yet,
  by adding empty 'tvshow.nfo' and 'season.nfo' files within the
  show/season directories (but only in mixed libraries) so the jellyfin
  internal logic will assign the directories as a show/season/episode
  structure. ||This is untested code btw. I'll test tomorrow. Now good night.||
  • Loading branch information
revam committed Apr 12, 2024
1 parent 09b488a commit 0336b02
Show file tree
Hide file tree
Showing 2 changed files with 55 additions and 18 deletions.
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,7 @@ Learn more about Shoko at https://shokoanime.com/.

- [X] Movie library

- [X] Mixed show/movie library¹.

¹ _You need at least one movie in your library for this to currently work as expected. This is an issue with Jellyfin 10.8._
- [X] Mixed show/movie library.

- [X] Supports adding local trailers

Expand Down
69 changes: 54 additions & 15 deletions Shokofin/Resolvers/ShokoResolveManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,16 +352,18 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string
var subtitles = 0;
var fixedSubtitles = 0;
var skippedSubtitles = 0;
var skippedNfo = 0;
var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder);
var collectionType = LibraryManager.GetInheritedContentType(mediaFolder);
var allNfoFiles = new HashSet<string>();
var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>();
var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads);
await Task.WhenAll(files.Select(async (tuple) => {
await semaphore.WaitAsync().ConfigureAwait(false);

try {
// Skip any source files we weren't meant to have in the library.
var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false);
var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false);
if (string.IsNullOrEmpty(sourceLocation))
return;

Expand Down Expand Up @@ -440,6 +442,25 @@ await Task.WhenAll(files.Select(async (tuple) => {
}
}
}

foreach (var nfoFile in nfoFiles)
{
if (allNfoFiles.Contains(nfoFile))
continue;
allNfoFiles.Add(nfoFile);

var nfoDirectory = Path.GetDirectoryName(nfoFile)!;
if (!Directory.Exists(nfoDirectory))
Directory.CreateDirectory(nfoDirectory);

if (!File.Exists(nfoFile)) {
File.WriteAllText(nfoFile, "");
}
else {
skippedNfo++;
}

}
}
finally {
semaphore.Release();
Expand All @@ -449,10 +470,12 @@ await Task.WhenAll(files.Select(async (tuple) => {

var removedLinks = 0;
var removedSubtitles = 0;
var removedNfo = 0;
var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true)
.Select(path => (path, extName: Path.GetExtension(path)))
.Where(tuple => _namingOptions.VideoFileExtensions.Contains(tuple.extName) || _namingOptions.SubtitleFileExtensions.Contains(tuple.extName))
.Where(tuple => _namingOptions.VideoFileExtensions.Contains(tuple.extName) || _namingOptions.SubtitleFileExtensions.Contains(tuple.extName) || tuple.extName == ".nfo")
.ExceptBy(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet(), tuple => tuple.path)
.ExceptBy(allNfoFiles, tuple => tuple.path)
.ToList();
foreach (var (symbolicLink, extName) in toBeRemoved) {
// Continue in case we already removed the (subtitle) file.
Expand All @@ -471,6 +494,9 @@ await Task.WhenAll(files.Select(async (tuple) => {
File.Delete(symbolicLink);
}
}
else if (extName == ".nfo") {
removedNfo++;
}
else {
removedSubtitles++;
}
Expand All @@ -480,15 +506,18 @@ await Task.WhenAll(files.Select(async (tuple) => {

var timeSpent = DateTime.UtcNow - start;
Logger.LogInformation(
"Created {CreatedMedia} ({CreatedSubtitles}), fixed {FixedMedia} ({FixedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links in media folder at {Path} in {TimeSpan}",
"Created {CreatedMedia} ({CreatedSubtitles},{CreatedNFO}), fixed {FixedMedia} ({FixedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles},{SkippedNFO}), and removed {RemovedMedia} ({RemovedSubtitles},{RemovedNFO}) symbolic links in media folder at {Path} in {TimeSpan}",
allPathsForVFS.Count - skippedLinks - fixedLinks - subtitles,
subtitles - fixedSubtitles - skippedSubtitles,
allNfoFiles.Count - skippedNfo,
fixedLinks,
fixedSubtitles,
skippedLinks,
skippedSubtitles,
toBeRemoved.Count,
skippedNfo,
removedLinks,
removedSubtitles,
removedNfo,
mediaFolder.Path,
timeSpent
);
Expand All @@ -497,11 +526,11 @@ await Task.WhenAll(files.Select(async (tuple) => {
// Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters.
private const int NameCutOff = 64;

private async Task<(string sourceLocation, string[] symbolicLinks)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId)
private async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId)
{
var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false);
if (season == null)
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>());
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>());

var isMovieSeason = season.Type == SeriesType.Movie;
var shouldAbort = collectionType switch {
Expand All @@ -510,19 +539,19 @@ await Task.WhenAll(files.Select(async (tuple) => {
_ => false,
};
if (shouldAbort)
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>());
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>());

var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false);
if (show == null)
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>());
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>());

var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false);
var episode = file?.EpisodeList.FirstOrDefault();
if (file == null || episode == null)
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>());
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>());

if (season == null || episode == null)
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>());
return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>());

var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}";
var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode);
Expand All @@ -534,6 +563,7 @@ await Task.WhenAll(files.Select(async (tuple) => {
if (episodeName.Length >= NameCutOff)
episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…";

var nfoFiles = new List<string>();
var folders = new List<string>();
var extrasFolder = file.ExtraType switch {
null => null,
Expand Down Expand Up @@ -565,18 +595,27 @@ await Task.WhenAll(files.Select(async (tuple) => {
else {
var isSpecial = show.IsSpecial(episode);
var seasonNumber = Ordering.GetSeasonNumber(show, season, episode);
var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}";
var seasonFolder = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}";
var showFolder = $"{showName} [{ShokoSeriesId.Name}={show.Id}]";
if (!string.IsNullOrEmpty(extrasFolder)) {
folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", extrasFolder));
folders.Add(Path.Combine(vfsPath, showFolder, extrasFolder));

// Only place the extra within the season if we have a season number assigned to the episode.
if (seasonNumber != 0)
folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName, extrasFolder));
folders.Add(Path.Combine(vfsPath, showFolder, seasonFolder, extrasFolder));
}
else {
folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName));
folders.Add(Path.Combine(vfsPath, showFolder, seasonFolder));
episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}";
}

// Add NFO files for the show and season if we're in a mixed library,
// to allow the built-in movie resolver to detect the directories
// properly as tv shows.
if (collectionType == null) {
nfoFiles.Add(Path.Combine(vfsPath, showFolder, "tvshow.nfo"));
nfoFiles.Add(Path.Combine(vfsPath, showFolder, seasonFolder, "season.nfo"));
}
}

var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffix}{Path.GetExtension(sourceLocation)}";
Expand All @@ -586,7 +625,7 @@ await Task.WhenAll(files.Select(async (tuple) => {

foreach (var symbolicLink in symbolicLinks)
ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id));
return (sourceLocation, symbolicLinks);
return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray());
}

private static void CleanupDirectoryStructure(string? path)
Expand Down

0 comments on commit 0336b02

Please sign in to comment.