Exemple #1
0
    private async Task SaveTroubleshootingData(string channelNumber, string output)
    {
        try
        {
            var        directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
            FileInfo[] allFiles  = directory.GetFiles();

            string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
            string playlistContents = string.Empty;
            if (_localFileSystem.FileExists(playlistFileName))
            {
                playlistContents = await File.ReadAllTextAsync(playlistFileName);
            }

            var    data       = new TroubleshootingData(allFiles, playlistContents, output);
            string serialized = data.Serialize();

            string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
            await File.WriteAllTextAsync(file, serialized);

            _logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
        }
        catch (Exception ex)
        {
            _client.Notify(ex);
        }
    }
        public async Task <Either <BaseError, Unit> > ScanFolder(LibraryPath libraryPath, string ffprobePath)
        {
            if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
            {
                return(new MediaSourceInaccessible());
            }

            var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
                                 .Filter(ShouldIncludeFolder)
                                 .OrderBy(identity)
                                 .ToList();

            foreach (string showFolder in allShowFolders)
            {
                Either <BaseError, MediaItemScanResult <Show> > maybeShow =
                    await FindOrCreateShow(libraryPath.Id, showFolder)
                    .BindT(show => UpdateMetadataForShow(show, showFolder))
                    .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
                    .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));

                await maybeShow.Match(
                    async result =>
                {
                    if (result.IsAdded)
                    {
                        await _searchIndex.AddItems(new List <MediaItem> {
                            result.Item
                        });
                    }
                    else if (result.IsUpdated)
                    {
                        await _searchIndex.UpdateItems(new List <MediaItem> {
                            result.Item
                        });
                    }

                    await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder);
                },
                    _ => Task.FromResult(Unit.Default));
            }

            foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
            {
                if (!_localFileSystem.FileExists(path))
                {
                    _logger.LogInformation("Removing missing episode at {Path}", path);
                    await _televisionRepository.DeleteByPath(libraryPath, path);
                }
            }

            await _televisionRepository.DeleteEmptySeasons(libraryPath);

            List <int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);

            await _searchIndex.RemoveItems(ids);

            return(Unit.Default);
        }
    private async Task <Either <BaseError, PlayoutItemWithPath> > ValidatePlayoutItemPath(PlayoutItem playoutItem)
    {
        string path = await GetPlayoutItemPath(playoutItem);

        if (_localFileSystem.FileExists(path))
        {
            return(new PlayoutItemWithPath(playoutItem, path));
        }

        return(new PlayoutItemDoesNotExistOnDisk(path));
    }
Exemple #4
0
        private async Task <Either <BaseError, PlayoutItemWithPath> > ValidatePlayoutItemPath(PlayoutItem playoutItem)
        {
            string path = await GetPlayoutItemPath(playoutItem);

            // TODO: this won't work with url streaming from plex
            if (_localFileSystem.FileExists(path))
            {
                return(new PlayoutItemWithPath(playoutItem, path));
            }

            return(new PlayoutItemDoesNotExistOnDisk(path));
        }
Exemple #5
0
    private async Task <Unit> ExtractSubtitles(
        TvContext dbContext,
        int mediaItemId,
        string ffmpegPath,
        CancellationToken cancellationToken)
    {
        Option <MediaItem> maybeMediaItem = await dbContext.MediaItems
                                            .Include(mi => (mi as Episode).MediaVersions)
                                            .ThenInclude(mv => mv.MediaFiles)
                                            .Include(mi => (mi as Episode).MediaVersions)
                                            .ThenInclude(mv => mv.Streams)
                                            .Include(mi => (mi as Episode).EpisodeMetadata)
                                            .ThenInclude(em => em.Subtitles)
                                            .Include(mi => (mi as Movie).MediaVersions)
                                            .ThenInclude(mv => mv.MediaFiles)
                                            .Include(mi => (mi as Movie).MediaVersions)
                                            .ThenInclude(mv => mv.Streams)
                                            .Include(mi => (mi as Movie).MovieMetadata)
                                            .ThenInclude(em => em.Subtitles)
                                            .Include(mi => (mi as MusicVideo).MediaVersions)
                                            .ThenInclude(mv => mv.MediaFiles)
                                            .Include(mi => (mi as MusicVideo).MediaVersions)
                                            .ThenInclude(mv => mv.Streams)
                                            .Include(mi => (mi as MusicVideo).MusicVideoMetadata)
                                            .ThenInclude(em => em.Subtitles)
                                            .Include(mi => (mi as OtherVideo).MediaVersions)
                                            .ThenInclude(mv => mv.MediaFiles)
                                            .Include(mi => (mi as OtherVideo).MediaVersions)
                                            .ThenInclude(mv => mv.Streams)
                                            .Include(mi => (mi as OtherVideo).OtherVideoMetadata)
                                            .ThenInclude(em => em.Subtitles)
                                            .SelectOneAsync(e => e.Id, e => e.Id == mediaItemId);

        foreach (MediaItem mediaItem in maybeMediaItem)
        {
            foreach (List <Subtitle> allSubtitles in GetSubtitles(mediaItem))
            {
                var subtitlesToExtract = new List <SubtitleToExtract>();

                // find each subtitle that needs extraction
                IEnumerable <Subtitle> subtitles = allSubtitles
                                                   .Filter(
                    s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false &&
                    s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle");

                // find cache paths for each subtitle
                foreach (Subtitle subtitle in subtitles)
                {
                    Option <string> maybePath = GetRelativeOutputPath(mediaItem.Id, subtitle);
                    foreach (string path in maybePath)
                    {
                        subtitlesToExtract.Add(new SubtitleToExtract(subtitle, path));
                    }
                }

                string mediaItemPath = await GetMediaItemPath(mediaItem);

                ArgumentsBuilder args = new ArgumentsBuilder()
                                        .Add("-nostdin")
                                        .Add("-hide_banner")
                                        .Add("-i").Add(mediaItemPath);

                foreach (SubtitleToExtract subtitle in subtitlesToExtract)
                {
                    string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath);
                    Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath));
                    if (_localFileSystem.FileExists(fullOutputPath))
                    {
                        File.Delete(fullOutputPath);
                    }

                    args.Add("-map").Add($"0:{subtitle.Subtitle.StreamIndex}").Add("-c").Add("copy")
                    .Add(fullOutputPath);
                }

                BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
                                               .WithArguments(args.Build())
                                               .WithValidation(CommandResultValidation.None)
                                               .ExecuteBufferedAsync(cancellationToken);

                if (result.ExitCode == 0)
                {
                    foreach (SubtitleToExtract subtitle in subtitlesToExtract)
                    {
                        subtitle.Subtitle.IsExtracted = true;
                        subtitle.Subtitle.Path        = subtitle.OutputPath;
                    }

                    int count = await dbContext.SaveChangesAsync(cancellationToken);

                    _logger.LogDebug("Successfully extracted {Count} subtitles", count);
                }
                else
                {
                    _logger.LogError("Failed to extract subtitles. {Error}", result.StandardError);
                }
            }
        }

        return(Unit.Default);
    }
Exemple #6
0
    private async Task <Either <BaseError, Unit> > ProcessSeasons(
        string address,
        string apiKey,
        EmbyLibrary library,
        string ffmpegPath,
        string ffprobePath,
        List <EmbyPathReplacement> pathReplacements,
        EmbyShow show,
        List <EmbyItemEtag> existingSeasons,
        List <EmbySeason> seasons,
        CancellationToken cancellationToken)
    {
        foreach (EmbySeason incoming in seasons)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                return(new ScanCanceled());
            }

            Option <EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
            if (maybeExisting.IsNone)
            {
                incoming.LibraryPathId = library.Paths.Head().Id;

                _logger.LogDebug(
                    "INSERT: Item id is new for show {Show} season {Season}",
                    show.ShowMetadata.Head().Title,
                    incoming.SeasonMetadata.Head().Title);

                if (await _televisionRepository.AddSeason(show, incoming))
                {
                    incoming.Show = show;
                    await _searchIndex.AddItems(_searchRepository, new List <MediaItem> {
                        incoming
                    });
                }
            }

            foreach (EmbyItemEtag existing in maybeExisting)
            {
                if (existing.Etag != incoming.Etag)
                {
                    _logger.LogDebug(
                        "UPDATE: Etag has changed for show {Show} season {Season}",
                        show.ShowMetadata.Head().Title,
                        incoming.SeasonMetadata.Head().Title);

                    incoming.ShowId        = show.Id;
                    incoming.LibraryPathId = library.Paths.Head().Id;

                    foreach (EmbySeason updated in await _televisionRepository.Update(incoming))
                    {
                        incoming.Show = show;

                        foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id))
                        {
                            await _searchIndex.UpdateItems(_searchRepository, new List <MediaItem> {
                                toIndex
                            });
                        }
                    }
                }
            }

            List <EmbyItemEtag> existingEpisodes =
                await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);

            Either <BaseError, List <EmbyEpisode> > maybeEpisodes =
                await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId);

            foreach (BaseError error in maybeEpisodes.LeftToSeq())
            {
                _logger.LogWarning(
                    "Error synchronizing emby library {Path}: {Error}",
                    library.Name,
                    error.Value);
            }

            foreach (List <EmbyEpisode> episodes in maybeEpisodes.RightToSeq())
            {
                var validEpisodes = new List <EmbyEpisode>();
                foreach (EmbyEpisode episode in episodes)
                {
                    string localPath = _pathReplacementService.GetReplacementEmbyPath(
                        pathReplacements,
                        episode.MediaVersions.Head().MediaFiles.Head().Path,
                        false);

                    if (!_localFileSystem.FileExists(localPath))
                    {
                        _logger.LogWarning(
                            "Skipping emby episode that does not exist at {Path}",
                            localPath);
                    }
                    else
                    {
                        validEpisodes.Add(episode);
                    }
                }

                Either <BaseError, Unit> scanResult = await ProcessEpisodes(
                    show.ShowMetadata.Head().Title,
                    incoming.SeasonMetadata.Head().Title,
                    library,
                    ffmpegPath,
                    ffprobePath,
                    pathReplacements,
                    incoming,
                    existingEpisodes,
                    validEpisodes,
                    cancellationToken);

                foreach (ScanCanceled error in scanResult.LeftToSeq().OfType <ScanCanceled>())
                {
                    return(error);
                }

                foreach (Unit _ in scanResult.RightToSeq())
                {
                    var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
                    var episodeIds         = existingEpisodes
                                             .Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
                                             .Map(m => m.ItemId)
                                             .ToList();
                    List <int> missingEpisodeIds =
                        await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);

                    await _searchIndex.RemoveItems(missingEpisodeIds);

                    _searchIndex.Commit();
                }
            }
        }

        return(Unit.Default);
    }
    public async Task <Either <BaseError, Unit> > ScanFolder(
        LibraryPath libraryPath,
        string ffmpegPath,
        string ffprobePath,
        decimal progressMin,
        decimal progressMax,
        CancellationToken cancellationToken)
    {
        try
        {
            decimal progressSpread = progressMax - progressMin;

            var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
                                 .Filter(ShouldIncludeFolder)
                                 .OrderBy(identity)
                                 .ToList();

            foreach (string showFolder in allShowFolders)
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    return(new ScanCanceled());
                }

                decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
                await _mediator.Publish(
                    new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion *progressSpread),
                    cancellationToken);

                Either <BaseError, MediaItemScanResult <Show> > maybeShow =
                    await FindOrCreateShow(libraryPath.Id, showFolder)
                    .BindT(show => UpdateMetadataForShow(show, showFolder))
                    .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken))
                    .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken))
                    .BindT(
                        show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));

                foreach (BaseError error in maybeShow.LeftToSeq())
                {
                    _logger.LogWarning(
                        "Error processing show in folder {Folder}: {Error}",
                        showFolder,
                        error.Value);
                }

                foreach (MediaItemScanResult <Show> result in maybeShow.RightToSeq())
                {
                    Either <BaseError, Unit> scanResult = await ScanSeasons(
                        libraryPath,
                        ffmpegPath,
                        ffprobePath,
                        result.Item,
                        showFolder,
                        cancellationToken);

                    foreach (ScanCanceled error in scanResult.LeftToSeq().OfType <ScanCanceled>())
                    {
                        return(error);
                    }

                    if (result.IsAdded)
                    {
                        await _searchIndex.AddItems(_searchRepository, new List <MediaItem> {
                            result.Item
                        });
                    }
                    else if (result.IsUpdated)
                    {
                        await _searchIndex.UpdateItems(_searchRepository, new List <MediaItem> {
                            result.Item
                        });
                    }
                }
            }

            foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
            {
                if (!_localFileSystem.FileExists(path))
                {
                    _logger.LogInformation("Flagging missing episode at {Path}", path);
                    List <int> episodeIds = await FlagFileNotFound(libraryPath, path);

                    await _searchIndex.RebuildItems(_searchRepository, episodeIds);
                }
                else if (Path.GetFileName(path).StartsWith("._"))
                {
                    _logger.LogInformation("Removing dot underscore file at {Path}", path);
                    await _televisionRepository.DeleteByPath(libraryPath, path);
                }
            }

            await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);

            await _televisionRepository.DeleteEmptySeasons(libraryPath);

            List <int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);

            await _searchIndex.RemoveItems(ids);

            return(Unit.Default);
        }
        catch (Exception ex) when(ex is TaskCanceledException or OperationCanceledException)
        {
            return(new ScanCanceled());
        }
        finally
        {
            _searchIndex.Commit();
        }
    }
    private async Task <Either <BaseError, Unit> > ScanLibrary(
        IMediaServerMovieRepository <TLibrary, TMovie, TEtag> movieRepository,
        TConnectionParameters connectionParameters,
        TLibrary library,
        Func <TMovie, string> getLocalPath,
        string ffmpegPath,
        string ffprobePath,
        List <TMovie> movieEntries,
        bool deepScan,
        CancellationToken cancellationToken)
    {
        List <TEtag> existingMovies = await movieRepository.GetExistingMovies(library);

        foreach (TMovie incoming in movieEntries)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                return(new ScanCanceled());
            }

            decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
            await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);

            string localPath = getLocalPath(incoming);

            if (await ShouldScanItem(movieRepository, library, existingMovies, incoming, localPath, deepScan) == false)
            {
                continue;
            }

            Either <BaseError, MediaItemScanResult <TMovie> > maybeMovie = await movieRepository
                                                                           .GetOrAdd(library, incoming)
                                                                           .MapT(
                result =>
            {
                result.LocalPath = localPath;
                return(result);
            })
                                                                           .BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan))
                                                                           .BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath))
                                                                           .BindT(UpdateSubtitles);

            if (maybeMovie.IsLeft)
            {
                foreach (BaseError error in maybeMovie.LeftToSeq())
                {
                    _logger.LogWarning(
                        "Error processing movie {Title}: {Error}",
                        incoming.MovieMetadata.Head().Title,
                        error.Value);
                }

                continue;
            }

            foreach (MediaItemScanResult <TMovie> result in maybeMovie.RightToSeq())
            {
                await movieRepository.SetEtag(result.Item, MediaServerEtag(incoming));

                if (_localFileSystem.FileExists(result.LocalPath))
                {
                    if (await movieRepository.FlagNormal(library, result.Item))
                    {
                        result.IsUpdated = true;
                    }
                }
                else
                {
                    Option <int> flagResult = await movieRepository.FlagUnavailable(library, result.Item);

                    if (flagResult.IsSome)
                    {
                        result.IsUpdated = true;
                    }
                }

                if (result.IsAdded)
                {
                    await _searchIndex.AddItems(_searchRepository, new List <MediaItem> {
                        result.Item
                    });
                }
                else if (result.IsUpdated)
                {
                    await _searchIndex.UpdateItems(_searchRepository, new List <MediaItem> {
                        result.Item
                    });
                }
            }
        }

        // trash items that are no longer present on the media server
        var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId)
                                  .Except(movieEntries.Map(MediaServerItemId)).ToList();
        List <int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);

        await _searchIndex.RebuildItems(_searchRepository, ids);

        await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);

        return(Unit.Default);
    }
Exemple #9
0
    public async Task <Either <BaseError, Unit> > ScanFolder(
        LibraryPath libraryPath,
        string ffprobePath,
        string ffmpegPath,
        decimal progressMin,
        decimal progressMax,
        CancellationToken cancellationToken)
    {
        try
        {
            decimal progressSpread = progressMax - progressMin;

            var foldersCompleted = 0;

            var folderQueue = new Queue <string>();

            if (ShouldIncludeFolder(libraryPath.Path))
            {
                folderQueue.Enqueue(libraryPath.Path);
            }

            foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
                     .Filter(ShouldIncludeFolder)
                     .OrderBy(identity))
            {
                folderQueue.Enqueue(folder);
            }

            while (folderQueue.Count > 0)
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    return(new ScanCanceled());
                }

                decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
                await _mediator.Publish(
                    new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion *progressSpread),
                    cancellationToken);

                string songFolder = folderQueue.Dequeue();
                foldersCompleted++;

                var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList();

                var allFiles = filesForEtag
                               .Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f)))
                               .Filter(f => !Path.GetFileName(f).StartsWith("._"))
                               .ToList();

                foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder)
                         .Filter(ShouldIncludeFolder)
                         .OrderBy(identity))
                {
                    folderQueue.Enqueue(subdirectory);
                }

                string etag = FolderEtag.Calculate(songFolder, _localFileSystem);
                Option <LibraryFolder> knownFolder = libraryPath.LibraryFolders
                                                     .Filter(f => f.Path == songFolder)
                                                     .HeadOrNone();

                // skip folder if etag matches
                if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
                    etag)
                {
                    continue;
                }

                _logger.LogDebug(
                    "UPDATE: Etag has changed for folder {Folder}",
                    songFolder);

                foreach (string file in allFiles.OrderBy(identity))
                {
                    Either <BaseError, MediaItemScanResult <Song> > maybeSong = await _songRepository
                                                                                .GetOrAdd(libraryPath, file)
                                                                                .BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
                                                                                .BindT(video => UpdateMetadata(video, ffprobePath))
                                                                                .BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
                                                                                .BindT(FlagNormal);

                    foreach (BaseError error in maybeSong.LeftToSeq())
                    {
                        _logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
                    }

                    foreach (MediaItemScanResult <Song> result in maybeSong.RightToSeq())
                    {
                        if (result.IsAdded)
                        {
                            await _searchIndex.AddItems(_searchRepository, new List <MediaItem> {
                                result.Item
                            });
                        }
                        else if (result.IsUpdated)
                        {
                            await _searchIndex.UpdateItems(_searchRepository, new List <MediaItem> {
                                result.Item
                            });
                        }

                        await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag);
                    }
                }
            }

            foreach (string path in await _songRepository.FindSongPaths(libraryPath))
            {
                if (!_localFileSystem.FileExists(path))
                {
                    _logger.LogInformation("Flagging missing song at {Path}", path);
                    List <int> songIds = await FlagFileNotFound(libraryPath, path);

                    await _searchIndex.RebuildItems(_searchRepository, songIds);
                }
                else if (Path.GetFileName(path).StartsWith("._"))
                {
                    _logger.LogInformation("Removing dot underscore file at {Path}", path);
                    List <int> songIds = await _songRepository.DeleteByPath(libraryPath, path);

                    await _searchIndex.RemoveItems(songIds);
                }
            }

            await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);

            return(Unit.Default);
        }
        catch (Exception ex) when(ex is TaskCanceledException or OperationCanceledException)
        {
            return(new ScanCanceled());
        }
        finally
        {
            _searchIndex.Commit();
        }
    }
Exemple #10
0
    private async Task <Either <BaseError, Unit> > ScanEpisodes(
        PlexLibrary library,
        List <PlexPathReplacement> pathReplacements,
        PlexSeason season,
        PlexConnection connection,
        PlexServerAuthToken token,
        string ffmpegPath,
        string ffprobePath,
        bool deepScan,
        CancellationToken cancellationToken)
    {
        List <PlexItemEtag> existingEpisodes = await _plexTelevisionRepository.GetExistingPlexEpisodes(library, season);

        Either <BaseError, List <PlexEpisode> > entries = await _plexServerApiClient.GetSeasonEpisodes(
            library,
            season,
            connection,
            token);

        foreach (BaseError error in entries.LeftToSeq())
        {
            return(error);
        }

        var episodeEntries = entries.RightToSeq().Flatten().ToList();

        foreach (PlexEpisode incoming in episodeEntries)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                return(new ScanCanceled());
            }

            if (await ShouldScanItem(library, pathReplacements, existingEpisodes, incoming, deepScan) == false)
            {
                continue;
            }

            incoming.SeasonId = season.Id;

            // TODO: figure out how to rebuild playlists
            Either <BaseError, MediaItemScanResult <PlexEpisode> > maybeEpisode = await _televisionRepository
                                                                                  .GetOrAddPlexEpisode(library, incoming)
                                                                                  .BindT(existing => UpdateMetadata(existing, incoming))
                                                                                  .BindT(
                existing => UpdateStatistics(
                    pathReplacements,
                    existing,
                    incoming,
                    library,
                    connection,
                    token,
                    ffmpegPath,
                    ffprobePath,
                    deepScan))
                                                                                  .BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
                                                                                  .BindT(existing => UpdateArtwork(existing, incoming));

            foreach (BaseError error in maybeEpisode.LeftToSeq())
            {
                switch (error)
                {
                case ScanCanceled:
                    return(error);

                default:
                    _logger.LogWarning(
                        "Error processing plex episode at {Key}: {Error}",
                        incoming.Key,
                        error.Value);
                    break;
                }
            }

            foreach (MediaItemScanResult <PlexEpisode> result in maybeEpisode.RightToSeq())
            {
                await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);

                string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;

                string localPath = _plexPathReplacementService.GetReplacementPlexPath(
                    pathReplacements,
                    plexPath,
                    false);

                if (_localFileSystem.FileExists(localPath))
                {
                    await _plexTelevisionRepository.FlagNormal(library, result.Item);
                }
                else
                {
                    await _plexTelevisionRepository.FlagUnavailable(library, result.Item);
                }

                if (result.IsAdded)
                {
                    await _searchIndex.AddItems(_searchRepository, new List <MediaItem> {
                        result.Item
                    });
                }
                else
                {
                    await _searchIndex.UpdateItems(_searchRepository, new List <MediaItem> {
                        result.Item
                    });
                }
            }
        }

        var        fileNotFoundKeys = existingEpisodes.Map(m => m.Key).Except(episodeEntries.Map(m => m.Key)).ToList();
        List <int> ids = await _plexTelevisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundKeys);

        await _searchIndex.RebuildItems(_searchRepository, ids);

        _searchIndex.Commit();

        return(Unit.Default);
    }
        public async Task <Either <BaseError, Unit> > ScanFolder(LibraryPath libraryPath, string ffprobePath)
        {
            if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
            {
                return(new MediaSourceInaccessible());
            }

            var folderQueue = new Queue <string>();

            foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
            {
                folderQueue.Enqueue(folder);
            }

            while (folderQueue.Count > 0)
            {
                string movieFolder = folderQueue.Dequeue();

                var allFiles = _localFileSystem.ListFiles(movieFolder)
                               .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
                               .Filter(
                    f => !ExtraFiles.Any(
                        e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
                               .ToList();

                if (allFiles.Count == 0)
                {
                    foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity))
                    {
                        folderQueue.Enqueue(subdirectory);
                    }

                    continue;
                }

                foreach (string file in allFiles.OrderBy(identity))
                {
                    // TODO: figure out how to rebuild playlists
                    Either <BaseError, MediaItemScanResult <Movie> > maybeMovie = await _movieRepository
                                                                                  .GetOrAdd(libraryPath, file)
                                                                                  .BindT(movie => UpdateStatistics(movie, ffprobePath))
                                                                                  .BindT(UpdateMetadata)
                                                                                  .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
                                                                                  .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));

                    await maybeMovie.Match(
                        async result =>
                    {
                        if (result.IsAdded)
                        {
                            await _searchIndex.AddItems(new List <MediaItem> {
                                result.Item
                            });
                        }
                        else if (result.IsUpdated)
                        {
                            await _searchIndex.UpdateItems(new List <MediaItem> {
                                result.Item
                            });
                        }
                    },
                        error =>
                    {
                        _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value);
                        return(Task.CompletedTask);
                    });
                }
            }

            foreach (string path in await _movieRepository.FindMoviePaths(libraryPath))
            {
                if (!_localFileSystem.FileExists(path))
                {
                    _logger.LogInformation("Removing missing movie at {Path}", path);
                    List <int> ids = await _movieRepository.DeleteByPath(libraryPath, path);

                    await _searchIndex.RemoveItems(ids);
                }
            }

            return(Unit.Default);
        }