Compare commits

...
Author SHA1 Message Date
Jason Dove f8631a1f12 release 0.0.42-prealpha 2021-05-31 13:08:55 -05:00
Jason DoveandGitHub c70f153241 keep crossover episodes together (#237) 2021-05-31 11:58:56 -05:00
Jason DoveandGitHub eee10dee22 skip zero duration items when building playouts (#236) 2021-05-31 07:31:15 -05:00
Jason DoveandGitHub 9f575dbd94 fix stuck playout builds (#235)
* fix stuck playout builds

* code cleanup
2021-05-31 06:38:54 -05:00
Jason Dove 539285d81e update docs for 0.0.41-prealpha release [no docker] 2021-05-30 18:01:13 -05:00
Jason DoveandGitHub f8c986472a more grouping fixes (#234)
* more grouping fixes

* update changelog
2021-05-30 11:54:02 -05:00
Jason Dove 442d73150e link to full changelog on home page [no docker] 2021-05-30 10:47:33 -05:00
Jason DoveandGitHub d6cee14143 add changelog (#232) 2021-05-30 09:40:59 -05:00
Jason DoveandGitHub c20c0b231e fix updating local movies (#231) 2021-05-30 06:32:26 -05:00
Jason DoveandGitHub e506dd38a8 merge latest develop (#230)
* sync guids/provider ids (#227)

* sync guids from plex

* cleanup

* sync local guids

* sync jellyfin and emby guids

* add episodes to search index (#228)

* sync episode directors and writers

* display episode writers and directors

* remove missing episodes from search index

* show episodes in search results

* fix emby and jellyfin episode updates

* fix updating plex episodes

* don't delete channel logos on startup

* add episodes page; fix adding episodes to collection

* cleanup

* multi-part episode grouping fixes (#229)
2021-05-30 06:08:33 -05:00
Jason DoveandGitHub bbd8bc6c7e add button to refresh list of libraries (#226)
* add button to refresh list of libraries

* code cleanup
2021-05-28 15:28:45 -05:00
110 changed files with 12242 additions and 576 deletions
+435
View File
@@ -0,0 +1,435 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.42-prealpha] - 2021-05-31
### Added
- Support roman numerals and english integer names for multi-part episode grouping
- Add option to treat entire collection as a single show with multi-part episode grouping
- This is useful for multi-part episodes that span multiple shows (crossovers)
### Changed
- Skip zero duration items when building a playout, rather than aborting the playout build
### Fixed
- Fix edge case where a playout rebuild would get stuck and block all other playouts and local library scans
## [0.0.41-prealpha] - 2021-05-30
### Added
- Add button to refresh list of Plex, Jellyfin, Emby libraries without restarting app
- Add episodes to search index
- Add director and writer metadata to episodes
- Add unique id/provider id metadata, which will support future features
- Allow grouping multi-part episodes with titles ending in `Part X`, `Part Y`, etc.
### Changed
- Change home page link from release notes to full changelog
### Fixed
- Fix missing channel logos after restart
- Fix multi-part episode grouping with missing episodes/parts
- Fix multi-part episode grouping in collections containing multiple shows
- Fix updating modified seasons and episodes from Jellyfin and Emby
## [0.0.40-prealpha] - 2021-05-28
### Added
- Add content rating metadata to movies and shows
- Add director and writer metadata to movies
- Sync tv show thumbnail artwork in Local, Jellyfin and Emby libraries (*not* Plex)
- Prioritize tv show thumbnail artwork over tv show posters in XMLTV
- Include tv show artwork in XMLTV when grouped items with custom title are all from the same show
- Cache resized local artwork on disk
### Fixed
- Recursively retrieve Jellyfin and Emby items
- Fix incorrect search item counts
- Fix stack trace information in non-docker releases
- Fix crash opening `Add to Schedule` dialog
- Disable FFmpeg troubleshooting reports on Windows as they do not work properly
## [0.0.39-prealpha] - 2021-05-25
### Added
- Show Jellyfin and Emby artwork in XMLTV
### Fixed
- Fix path replacements for Jellyfin and Emby, including UNC paths
- Use Emby path replacements for playback
- Fix playback when `fps` is the only required filter
- Fix resources (images, fonts) required to display offline channel message
## [0.0.38-prealpha] - 2021-05-23
### Added
- Add support for Emby
- Use "single-file" deployments for releases
- Non-docker releases will have significantly fewer files
- It is recommended to empty your installation folder before copying in the latest release.
### Fixed
- Fix some cases where Jellyfin artwork would not display
- Fix saving schedule items with duration less than one hour
- Use ffmpeg 4.3 in docker images; there was a performance regression with 4.4 (only in docker)
## [0.0.37-prealpha] - 2021-05-21
### Added
- Add option to keep multi-part episodes together when shuffling (i.e. two-part season finales)
- Optimize Plex TV Scanner to quickly process shows that have not been updated since the last scan
- Optimize local Movie, Show, Music Video scanners to quickly skip unchanged folders, and to notice any mtime change
- Add server binding configuration to `appsettings.json` which lets non-docker installations bind to localhost or change the port number
### Fixed
- Properly ignore `Other` Jellyfin libraries
- Fix bug where search index would try to re-initialize unnecessarily
- Fix one cause of green line at bottom of some transcoded videos by forcing even scaling targets
## [0.0.36-prealpha] - 2021-05-16
### Added
- Add support for Jellyfin
- Add support for ffmpeg 4.4, and use ffmpeg 4.4 in all docker images
- Add configurable library refresh interval
- Add button to copy/clone ffmpeg profile
## [0.0.35-prealpha] - 2021-04-27
### Added
- Add search button for each library in `Libraries` page to quickly filter content by library
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Fixed
- Fix ingesting actors and actor artwork from newly-added Plex media items
- Only show `movie` and `show` libraries from Plex. Other library types are not supported at this time.
- Fix local movie scanner missing replaced/updated files
## [0.0.34-prealpha] - 2021-04-17
### Added
- Allow `enter` key to submit all dialogs
- Add actors to movies and shows (Plex or NFO metadata is required)
- Note that this requires a one-time full library scan to ingest actor metadata, which may take a long time with large libraries.
- Rework metadata list links in UI (languages, studios, genres, etc)
### Fixed
- Fix EPG generation with music video channels that do not use a custom title
- Fix lag when typing in search bar, `Add To Collection` dialog
- Fix collections paging
- Fix padding odd resolutions (this bug caused some items to always fail playback)
- Only update Plex episode artwork as needed
## [0.0.33-prealpha] - 2021-04-11
### Added
- Add language buttons to movies, shows, artists
- Show release notes on home page
### Fixed
- Re-import missing television metadata that was incorrectly removed with `v0.0.32`
- Fix language indexing; language searches now use full english name
- Fix synchronizing television studios, genres from Plex
- Limit channels to one playout per channel
- Though more than one playout was previously possible it was unsupported and unlikely to work as expected, if at all
- A future release may make this possible again
## [0.0.32-prealpha] - 2021-04-09
### Added
- `Add All To Collection` button to quickly add all search results to a collection
- Add Artists scanned from Music Video libraries
- Artist folders are now required, but music videos now have no naming requirements
- `artist.nfo` metadata is supported along with thumbnail and poster artwork
- Save Collections table page size in local storage
### Fixed
- Fix audio stream language indexing for movies and music videos
- Fix synchronizing list of Plex servers and connection addresses for each server
- Fix `See All` link for music video search results
## [0.0.31-prealpha] - 2021-04-06
### Added
- Add documentation link to UI
- Add `language` search field
- Minor log viewer improvements
- Use fragment navigation with letter bar (clicking a letter will page and scroll until that letter is in view)
- Send all audio streams with HLS when channel has no preferred language
- Move FFmpeg settings to new `Settings` page
- Add HDHR tuner count setting to new `Settings` page
### Fixed
- Fix poster width
- Fix bug that would occasionally prevent items from being added to the search index
- Automatically refresh the Plex Media Sources page after sign in or sign out
## 0.0.30-prealpha [YANKED]
## [0.0.29-prealpha] - 2021-04-04
- No longer require NFO metadata for music videos
- Instead, the only requirement is that music video files be named `[artist] - [track].[extension]` where the three characters (space dash space) between artist and track are required
- Add library scan progress detail
- Optimize library scans after adding library path to only scan new library path
- Fix bug replacing music videos
- Scan Plex libraries and local libraries on different threads
- Use English names for preferred languages in UI instead of ISO language code
## [0.0.28-prealpha] - 2021-04-03
- Apply audio normalization more consistently; this should further reduce program boundary errors
- Replace unused audio volume setting with audio loudness normalization option
- This can be particularly helpful with music video channels if media items have inconsistent loudness
- This setting may be less desirable on movie channels where loudness is intended to be dynamic
- Fix XMLTV containing music videos that do not use a custom title
- Fix channels table sorting, add paging to channels table
- Add sorting and paging to schedules table
- Add paging to playouts table
- Use table instead of cards for collections view
## [0.0.27-prealpha] - 2021-04-02
- Add ***basic*** music video library support
- **NFO metadata is required for music videos** - see [tags](https://kodi.wiki/view/NFO_files/Music_videos#Music_Video_Tags), [template](https://kodi.wiki/view/NFO_files/Music_videos#Template_nfo) and [sample](https://kodi.wiki/view/NFO_files/Music_videos#Sample_nfo)
- Artists can be searched using the `artist` field, like `artist:daft`
- Clear search query when clicking `Movies` or `TV Shows` from paged search results
- Add show title to playout details
- Let ffmpeg determine thread count by default (signified by `0` threads in ffmpeg profile)
- Save troubleshooting reports for ffmpeg concat process in addition to transcode process
- Simplify ffmpeg normalization options
- Add frame rate setting to ffmpeg profile
- When video normalization is enabled, all media items will have their frame rate converted to the same value
- Fix some scenarios where streaming would freeze at program boundaries
- Fix bug preventing some Plex libraries from scanning
- Fix bug preventing some local libraries from scanning folders that were recently added
## [0.0.26-prealpha] - 2021-03-30
- Add `Custom Title` option to schedule items
- When a custom title is set, the schedule item will be grouped in the EPG with the custom title
- Navigate to schedule items after creating new schedule
- Fix channel editor so preferred language is no longer required on every channel
- Fix bug with audio track selection during non-normalized playback
- Fix bug with playout builds where `Multiple` or `Duration` items wouldn't respect the settings over time
- Fix bug that prevented some television folders from scanning
## [0.0.25-prealpha] - 2021-03-29
- Add preferred language feature
- Global preference can be set in FFmpeg settings; channels can override global preference
- Preferences require [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language codes
- Audio stream selection will attempt to respect these preferences and prioritize streams with the most channels
- English (`eng`) will be used as a fallback when no preferences have been configured
- ***This feature requires a one-time reanalysis of every media item which may take a long time for large libraries and playback may fail until this scan has completed***
- Fix channel sorting in EPG
- Fix mixed-platform path replacements (Plex on Windows with ErsatzTV on Linux, or Plex on Linux with ErsatzTV on Windows)
- Fix local television library scanning; this was broken with `v0.0.23`
- Optimize local library scanning; regular scans should be significantly faster
- Add log warning when a zero-duration media item is encountered
- Fix indexing local shows without NFO metadata.
- If you have this issue the best way to fix is to:
- Shutdown ErsatzTV
- Delete the `search-index` subfolder inside the ErsatzTV config folder
- Start ErsatzTV; the full search index will be rebuilt on startup
- Fix updating search index when genres, tags, studios are updated in local libraries
- Adjust artwork routes so all IPTV traffic can be proxied with a single path prefix of `/iptv`
## [0.0.24-prealpha] - 2021-03-22
- Fix a critical bug preventing library synchronization with Plex sign ins performed with `v0.0.22` or `v0.0.23`
- **If you are unable to sync libraries from Plex, please sign out and back in to apply this fix**
- Fallback to `folder.jpg` when `poster.jpg` is not present
- Attach episodes to correct show when adding NFO metadata to existing libraries
## [0.0.23-prealpha] - 2021-03-21
- Remove all Plex items from search index after sign out
- Fix fallback metadata for local episodes (episode number was missing)
- Improve television show year detection where year is missing from nfo metadata
- Fix sorting for titles that start with `A` or `An` in addition to `The`
- Properly escape search links containing special characters (genre, tag)
- Add and index `Studio` metadata
## [0.0.22-prealpha] - 2021-03-20
- Log errors encountered during search index build; attempt to continue with partial index when errors are encountered
- Only search `title` field by default; `genre` and `tag` can be searched with `field:query` syntax
- Allow leading wildcards in searches
- Keep search query in search field to allow easy modification
- Fix default ffmpeg profile when creating new channels
- Fix multiple bugs with updating Plex servers, libraries, path replacements
- Add `release_date` to search index
## [0.0.21-prealpha] - 2021-03-20
- Optimize local library scanning to use less memory
- Duplicate some documentation near the schedule item editor
- Fix bug with updating `Normalize Video Codec` setting
- Rework search functionality
- Search landing page will show up to 50 items of each type
- `See All` links can be used to page through all search results
- Complex search queries supported (`christmas OR santa`)
- Fields that are searched by default:
- `title`
- `genre`
- `tag`
- Fields that aren't searched by default, but can be included in queries with syntax like (`plot:whatever`):
- `plot`
- `library_name`
- `type` (`movie` or `show`)
- Add letter bar to all paged search results to quickly navigate to a particular letter
## [0.0.20-prealpha] - 2021-03-17
- Fix NVIDIA hardware acceleration in `develop-nvidia` and `latest-nvidia` Docker tags
- This may never have worked correctly in Docker with older releases
- Fix occasional crash rebuilding playout from ui
- Fix crash adding a channel when no channels exist
- Fix playback for media containing attached pictures
## [0.0.19-prealpha] - 2021-03-16
- Regularly scan Plex libraries (same as local libraries)
- Add ability to create new collection from `Add to Collection` dialog
- Fix channel logos in XMLTV
- Add episode posters (show posters) to XMLTV
- Fix shuffled schedules from occasionally having repeated items when reshuffling
- This was more likely to happen with low-cardinality collections like A B C C A B B C A
- Add optional FFmpeg troubleshooting reports
- Allow synchronizing hidden Plex libraries
## [0.0.18-prealpha] - 2021-03-14
- Plex is now a supported media source
- Plex is **not** used for transcoding at this point, files are played directly from the filesystem using ErsatzTV transcoding
- Path replacements will be needed if your shared media folders are mounted differently in Plex and ErsatzTV
## [0.0.17-prealpha] - 2021-03-13
- Fix bug introduced with 0.0.16 that prevented some playouts from building
- Properly set sort title on added tv shows
- Fix loading season pages containing episodes that have incomplete metadata
- Improve XMLTV guide data
## [0.0.16-prealpha] - 2021-03-12
- Fix infinite loop caused by incorrectly configured ffprobe path
- Add more strict ffmpeg and ffprobe settings validation
- Add custom playback order option to collections that contain only movies
- This custom playback order will override the schedule's configured playback order for the collection
## [0.0.15-prealpha] - 2021-03-11
- Update UI for tv shows
- Fix tv show sorting
- Fix editing channel numbers
- Fix playout timezone bugs
- Add searchable genres and tags from local NFO metadata
- Add multi-select feature to movies, shows, search results and collection items pages
## [0.0.14-prealpha] - 2021-03-09
- New movie layout utilizing fan art (if available)
- New dark UI
- Fix offline stream (displayed when no media is scheduled for playback)
- Add M3U codec hints for Channels DVR
- Allow sub-channel numbers
- Fix bug where ffmpeg wouldn't terminate after a media item completed playback
- Fix time zone in new docker base images
- Fix vaapi pipeline with mpeg4 content by using software decoder with hardware encoder
- Enforce unique schedule name
- Enforce unique channel number
- Fix sorting of collection items in UI
## [0.0.13-prealpha] - 2021-03-07
- Remember selected Collection in `Add To Collection` dialog
- Automatically rebuild Playouts referencing any Collection that has items added or removed from the UI
- Remove Media Items from database when files are removed from disk
- Add hardware-accelerated transcoding support (`qsv`, `nvenc`/`nvidia`, `vaapi`)
- All flavors support resolution normalization (scaling and padding)
- This requires support within ffmpeg; see README for new docker image tags
## [0.0.12-prealpha] - 2021-03-02
- Fix a database migration issue introduced with version 0.0.11
- Shutdown app when database migration failures are encountered at startup
## [0.0.11-prealpha] - 2021-03-01
- Add Libraries and Library Paths under Media Sources
- Two local libraries exist: `Movies` and `Shows`
- Local Media Sources from prior versions are now found under Library Paths
- Add `Rebuild Playout` buttons to quickly regenerate playouts after modifying collections
- Add `Add to Collection` buttons to most media cards (movies, shows, seasons, episodes)
- Add Search page for searching movies and shows
## [0.0.10-prealpha] - 2021-02-21
- Rework how television media is stored in the database
- Rework how media is linked to a collection
- Add season, episode and movie detail views to UI
- Add media to collections and schedules from detail views
- Easily add and remove media from a collection
- Easily add and reorder schedule items
## [0.0.9-prealpha] - 2021-02-15
- Local media scanner has been rewritten and is much more performant
- Ignore extras in the same folder as movies (`-behindthescenes`, `-trailer`, etc)
- Support `movie.nfo` metadata in addition to matching filename nfo metadata
- Changes to video files, metadata and posters are automatically detected and used
## [0.0.8-prealpha] - 2021-02-14
- Optimize scanning so playouts are only rebuilt when necessary (duration changes, or collection membership changes)
- Automatically add new posters during scanning
- Support more poster file types (jpg, jpeg, png, gif, tbn)
- Add "Refresh All Metadata" button to media sources page; this should only be needed if NFO metadata or posters are modified
- Add progress indicator for media sources that are being actively scanned
- Prevent deleting media source during scan
- Prevent creating playout with empty schedule
## [0.0.7-prealpha] - 2021-02-13
- Rework media items layout - table has been replaced with cards/posters
- Fix bug preventing long folder names from being used as media sources
- Use 24h time pickers in schedule editor
## [0.0.6-prealpha] - 2021-02-12
- Add version information to UI
- Add basic log viewer to UI
## [0.0.5-prealpha] - 2021-02-12
- Fix bug where media scanner could stop prematurely and miss media items
- Add database migrations
## [0.0.4-prealpha] - 2021-02-11
- **Fix HDHomeRun routes** - this version is required to use as a DVR with Plex, older versions will not work
- Improve metadata parsing for tv, add fallback (filename) parsing for movies
## [0.0.3-prealpha] - 2021-02-11
- Fix incomplete XML issue introduced with v0.0.2-prealpha
- Add `.ts` files to local media scanner
- Change M3U, XMLTV, API icons to text links
## 0.0.2-prealpha - 2021-02-11 [YANKED]
- Relax some searches to be case-insensitive
- Improve categorization of tv episodes without sidecar metadata
- Properly escape XML content in XMLTV
## [0.0.1-prealpha] - 2021-02-10
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...HEAD
[0.0.42-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
[0.0.41-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
[0.0.40-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.39-prealpha...v0.0.40-prealpha
[0.0.39-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.38-prealpha...v0.0.39-prealpha
[0.0.38-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.37-prealpha...v0.0.38-prealpha
[0.0.37-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.36-prealpha...v0.0.37-prealpha
[0.0.36-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.35-prealpha...v0.0.36-prealpha
[0.0.35-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.34-prealpha...v0.0.35-prealpha
[0.0.34-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.33-prealpha...v0.0.34-prealpha
[0.0.33-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.32-prealpha...v0.0.33-prealpha
[0.0.32-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.31-prealpha...v0.0.32-prealpha
[0.0.31-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.29-prealpha...v0.0.31-prealpha
[0.0.29-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.28-prealpha...v0.0.29-prealpha
[0.0.28-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.27-prealpha...v0.0.28-prealpha
[0.0.27-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.26-prealpha...v0.0.27-prealpha
[0.0.26-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.25-prealpha...v0.0.26-prealpha
[0.0.25-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.24-prealpha...v0.0.25-prealpha
[0.0.24-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.23-prealpha...v0.0.24-prealpha
[0.0.23-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.22-prealpha...v0.0.23-prealpha
[0.0.22-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.21-prealpha...v0.0.22-prealpha
[0.0.21-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.20-prealpha...v0.0.21-prealpha
[0.0.20-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.19-prealpha...v0.0.20-prealpha
[0.0.19-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.18-prealpha...v0.0.19-prealpha
[0.0.18-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.17-prealpha...v0.0.18-prealpha
[0.0.17-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.16-prealpha...v0.0.17-prealpha
[0.0.16-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.15-prealpha...v0.0.16-prealpha
[0.0.15-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.14-prealpha...v0.0.15-prealpha
[0.0.14-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.13-prealpha...v0.0.14-prealpha
[0.0.13-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.12-prealpha...v0.0.13-prealpha
[0.0.12-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.11-prealpha...v0.0.12-prealpha
[0.0.11-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.10-prealpha...v0.0.11-prealpha
[0.0.10-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.9-prealpha...v0.0.10-prealpha
[0.0.9-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.8-prealpha...v0.0.9-prealpha
[0.0.8-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.7-prealpha...v0.0.8-prealpha
[0.0.7-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.6-prealpha...v0.0.7-prealpha
[0.0.6-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.5-prealpha...v0.0.6-prealpha
[0.0.5-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
[0.0.4-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
[0.0.3-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
[0.0.1-prealpha]: https://github.com/jasongdove/ErsatzTV/releases/tag/v0.0.1-prealpha
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
@@ -20,17 +21,20 @@ namespace ErsatzTV.Application.Emby.Commands
private readonly IEmbySecretStore _embySecretStore;
private readonly ILogger<SynchronizeEmbyLibrariesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
public SynchronizeEmbyLibrariesHandler(
IMediaSourceRepository mediaSourceRepository,
IEmbySecretStore embySecretStore,
IEmbyApiClient embyApiClient,
ILogger<SynchronizeEmbyLibrariesHandler> logger)
ILogger<SynchronizeEmbyLibrariesHandler> logger,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_embySecretStore = embySecretStore;
_embyApiClient = embyApiClient;
_logger = logger;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -75,16 +79,21 @@ namespace ErsatzTV.Application.Emby.Commands
connectionParameters.ApiKey);
await maybeLibraries.Match(
libraries =>
async libraries =>
{
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
return _mediaSourceRepository.UpdateLibraries(
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.EmbyMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
},
error =>
{
@@ -6,6 +6,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using Microsoft.Extensions.Logging;
@@ -22,17 +23,20 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly ILogger<SynchronizeJellyfinLibrariesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
public SynchronizeJellyfinLibrariesHandler(
IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinApiClient jellyfinApiClient,
ILogger<SynchronizeJellyfinLibrariesHandler> logger)
ILogger<SynchronizeJellyfinLibrariesHandler> logger,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore;
_jellyfinApiClient = jellyfinApiClient;
_logger = logger;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -77,16 +81,21 @@ namespace ErsatzTV.Application.Jellyfin.Commands
connectionParameters.ApiKey);
await maybeLibraries.Match(
libraries =>
async libraries =>
{
var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
return _mediaSourceRepository.UpdateLibraries(
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
},
error =>
{
+13 -3
View File
@@ -39,7 +39,8 @@ namespace ErsatzTV.Application.MediaCards
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
Option<EmbyMediaSource> maybeEmby,
bool isSearchResult) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
@@ -48,12 +49,21 @@ namespace ErsatzTV.Application.MediaCards
() => string.Empty),
episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.Season.SeasonNumber,
episodeMetadata.Episode.EpisodeNumber,
episodeMetadata.Title,
episodeMetadata.SortTitle,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby));
isSearchResult
? GetPoster(
episodeMetadata.Episode.Season.SeasonMetadata.Head(),
maybeJellyfin,
maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
@@ -101,7 +111,7 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
.ToList(),
collection.MediaItems.OfType<Episode>()
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby))
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
@@ -39,9 +40,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby)).ToList());
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby, false)).ToList());
return new TelevisionEpisodeCardResultsViewModel(count, results);
return new TelevisionEpisodeCardResultsViewModel(count, results, Option<SearchPageMap>.None);
}
}
}
@@ -1,6 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionEpisodeCardResultsViewModel(int Count, List<TelevisionEpisodeCardViewModel> Cards);
public record TelevisionEpisodeCardResultsViewModel(
int Count,
List<TelevisionEpisodeCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards
{
@@ -9,13 +10,17 @@ namespace ErsatzTV.Application.MediaCards
string ShowTitle,
int ShowId,
int SeasonId,
int Season,
int Episode,
string Title,
string SortTitle,
string Plot,
string Poster) : MediaCardViewModel(
string Poster,
List<string> Directors,
List<string> Writers) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
$"Episode {Episode}",
SortTitle,
Poster);
}
@@ -9,6 +9,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -41,6 +41,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.ToList();
@@ -59,8 +60,9 @@ namespace ErsatzTV.Application.MediaCollections.Commands
}
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request))
.Apply((_, _, _) => Unit.Default);
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request),
await ValidateEpisodes(request))
.Apply((_, _, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
@@ -80,5 +82,12 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
}
}
@@ -6,6 +6,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Plex;
using LanguageExt;
using Microsoft.Extensions.Logging;
@@ -19,17 +20,20 @@ namespace ErsatzTV.Application.Plex.Commands
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexSecretStore _plexSecretStore;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
public SynchronizePlexLibrariesHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IPlexServerApiClient plexServerApiClient,
ILogger<SynchronizePlexLibrariesHandler> logger)
ILogger<SynchronizePlexLibrariesHandler> logger,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_plexServerApiClient = plexServerApiClient;
_logger = logger;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -73,15 +77,20 @@ namespace ErsatzTV.Application.Plex.Commands
connectionParameters.PlexServerAuthToken);
await maybeLibraries.Match(
libraries =>
async libraries =>
{
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
return _mediaSourceRepository.UpdateLibraries(
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
},
error =>
{
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
public record CreateProgramSchedule(
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
}
@@ -34,13 +34,18 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
private Task<Validation<BaseError, ProgramSchedule>> Validate(CreateProgramSchedule request) =>
ValidateName(request)
.MapT(
name => new ProgramSchedule
name =>
{
Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether =
bool keepMultiPartEpisodesTogether =
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether
request.KeepMultiPartEpisodesTogether;
return new ProgramSchedule
{
Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows
};
});
private async Task<Validation<BaseError, string>> ValidateName(CreateProgramSchedule createProgramSchedule)
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int ProgramScheduleId,
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
}
@@ -40,13 +40,16 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified
bool needToRebuildPlayout =
programSchedule.MediaCollectionPlaybackOrder != update.MediaCollectionPlaybackOrder ||
programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether;
programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != update.TreatCollectionsAsShows;
programSchedule.Name = update.Name;
programSchedule.MediaCollectionPlaybackOrder = update.MediaCollectionPlaybackOrder;
programSchedule.KeepMultiPartEpisodesTogether =
update.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
update.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
update.TreatCollectionsAsShows;
await _programScheduleRepository.Update(programSchedule);
if (needToRebuildPlayout)
@@ -10,7 +10,8 @@ namespace ErsatzTV.Application.ProgramSchedules
programSchedule.Id,
programSchedule.Name,
programSchedule.MediaCollectionPlaybackOrder,
programSchedule.KeepMultiPartEpisodesTogether);
programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch
@@ -6,5 +6,6 @@ namespace ErsatzTV.Application.ProgramSchedules
int Id,
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether);
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows);
}
@@ -17,18 +17,16 @@ namespace ErsatzTV.Application.Search.Queries
public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken)
{
List<int> movieIds = await _searchIndex.Search($"type:movie AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> showIds = await _searchIndex.Search($"type:show AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> artistIds = await _searchIndex.Search($"type:artist AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> musicVideoIds = await _searchIndex.Search($"type:music_video AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
CancellationToken cancellationToken) =>
new(
await GetIds("movie", request.Query),
await GetIds("show", request.Query),
await GetIds("episode", request.Query),
await GetIds("artist", request.Query),
await GetIds("music_video", request.Query));
return new SearchResultAllItemsViewModel(movieIds, showIds, artistIds, musicVideoIds);
}
private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexEpisodes
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionEpisodeCardResultsViewModel>;
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes,
TelevisionEpisodeCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexEpisodesHandler(
ISearchIndex searchIndex,
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
QuerySearchIndexEpisodes request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionEpisodeCardViewModel> items = await _televisionRepository
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby, true)).ToList());
return new TelevisionEpisodeCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Search
public record SearchResultAllItemsViewModel(
List<int> MovieIds,
List<int> ShowIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
}
@@ -11,17 +11,14 @@ namespace ErsatzTV.Core.Tests.Fakes
public class FakeTelevisionRepository : ITelevisionRepository
{
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
public Task<int> GetShowCount() => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
@@ -89,10 +86,18 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
throw new NotSupportedException();
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException();
public Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) => throw new NotSupportedException();
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<int> GetShowCount() => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException();
@@ -60,6 +60,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
}
[Test]
[Timeout(1000)]
public void State_Should_Reset_When_Invalid()
{
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10 };
var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state);
chronologicalContent.State.Index.Should().Be(0);
chronologicalContent.State.Seed.Should().Be(0);
}
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => (MediaItem) new Episode
@@ -1,100 +1,307 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using FluentAssertions;
using NUnit.Framework;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.Scheduling
{
public class MultiPartEpisodeGrouperTests
{
[Test]
public void NotGrouped_Grouped_NotGrouped()
[TestCase("Episode 1", "Episode 2 (1)", "Episode 3 (2)", "Episode 4")]
[TestCase("Episode 1 - More", "Episode 2 (1) - Title", "Episode 3 (2) - After", "Episode 4 - Dash")]
[TestCase("Episode 1", "Episode 2 Part 1", "Episode 3 Part 2", "Episode 4")]
public void NotGrouped_Grouped_NotGrouped(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1"),
NamedEpisode("Episode 2 (1)"),
NamedEpisode("Episode 3 (2)"),
NamedEpisode("Episode 4")
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3),
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
result[0].First.Should().Be(mediaItems[0]);
result[1].First.Should().Be(mediaItems[1]);
result[1].Additional[0].Should().Be(mediaItems[2]);
result[2].First.Should().Be(mediaItems[3]);
ShouldHaveOneItem(result, mediaItems[0]);
ShouldHaveTwoItems(result, mediaItems[1], mediaItems[2]);
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
public void Grouped_NotGrouped()
[TestCase("Episode 1 (1)", "Episode 2 - Part 2", "Episode 3")]
[TestCase("Episode 1 Part 1", "Episode 2 (2) - More", "Episode 3 - After")]
[TestCase("Episode 1 Part 1", "Episode 2 (II)", "Episode 3")]
[TestCase("Episode 1 Part (V)", "Episode 2 (VI)", "Episode 3")]
[TestCase("Episode 1 Part Three", "Episode 2 (IV)", "Episode 3")]
public void MixedNaming_Group(string one, string two, string three)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1 (1)"),
NamedEpisode("Episode 2 (2)"),
NamedEpisode("Episode 3")
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
result[0].First.Should().Be(mediaItems[0]);
result[0].Additional[0].Should().Be(mediaItems[1]);
result[1].First.Should().Be(mediaItems[2]);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[2]);
}
[Test]
public void Grouped_NotGrouped_Grouped()
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 - After")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3")]
public void Grouped_NotGrouped(string one, string two, string three)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1 (1)"),
NamedEpisode("Episode 2 (2)"),
NamedEpisode("Episode 3"),
NamedEpisode("Episode 4 (1)"),
NamedEpisode("Episode 5 (2)")
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[2]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3", "Episode 4 (1)", "Episode 5 (2)")]
[TestCase(
"Episode 1 (1) - More",
"Episode 2 (2) - Title",
"Episode 3 - After",
"Episode 4 (1) - Dash",
"Episode 5 (2) - Again")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3", "Episode 4 Part 1", "Episode 5 Part 2")]
public void Grouped_NotGrouped_Grouped(string one, string two, string three, string four, string five)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3),
NamedEpisode(four, 1, 1, 4),
NamedEpisode(five, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
result[0].First.Should().Be(mediaItems[0]);
result[0].Additional[0].Should().Be(mediaItems[1]);
result[1].First.Should().Be(mediaItems[2]);
result[2].First.Should().Be(mediaItems[3]);
result[2].Additional[0].Should().Be(mediaItems[4]);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[2]);
ShouldHaveTwoItems(result, mediaItems[3], mediaItems[4]);
}
[Test]
public void Grouped_Grouped()
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3 (1)", "Episode 4 (2)")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 (1) - After", "Episode 4 (2) - Dash")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3 Part 1", "Episode 4 Part 2")]
public void Grouped_Grouped(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1 (1)"),
NamedEpisode("Episode 2 (2)"),
NamedEpisode("Episode 3 (1)"),
NamedEpisode("Episode 4 (2)")
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3),
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
result[0].First.Should().Be(mediaItems[0]);
result[0].Additional[0].Should().Be(mediaItems[1]);
result[1].First.Should().Be(mediaItems[2]);
result[1].Additional[0].Should().Be(mediaItems[3]);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
ShouldHaveTwoItems(result, mediaItems[2], mediaItems[3]);
}
private static Episode NamedEpisode(string title) =>
[Test]
[TestCase("Episode 1", "Episode 2 (2)", "Episode 3 (1)", "Episode 4 (2)")]
[TestCase("Episode 1 - More", "Episode 2 (2) - Title", "Episode 3 (1) - After", "Episode 4 (2) - Dash")]
[TestCase("Episode 1", "Episode 2 Part 2", "Episode 3 Part 1", "Episode 4 Part 2")]
public void Part2_Without_Part1(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3),
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
ShouldHaveOneItem(result, mediaItems[1]);
ShouldHaveTwoItems(result, mediaItems[2], mediaItems[3]);
}
[Test]
[TestCase("Episode 1", "Episode 2 (2)", "Episode 3 (3)", "Episode 4")]
[TestCase("Episode 1 - More", "Episode 2 (2) - Title", "Episode 3 (3) - After", "Episode 4 - Dash")]
[TestCase("Episode 1", "Episode 2 Part 2", "Episode 3 Part 3", "Episode 4")]
public void Part2And3_Without_Part1(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3),
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
ShouldHaveTwoItems(result, mediaItems[1], mediaItems[2]);
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 3 (3)", "Episode 4", "Episode 5")]
[TestCase("Episode 1 (1) - More", "Episode 3 (3) - Title", "Episode 4 - After", "Episode 5 - Dash")]
[TestCase("Episode 1 Part 1", "Episode 3 Part 3", "Episode 4", "Episode 5")]
public void Skip_Part(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 3),
NamedEpisode(three, 1, 1, 4),
NamedEpisode(four, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(4);
ShouldHaveOneItem(result, mediaItems[0]);
ShouldHaveOneItem(result, mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[2]);
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 3 (1)", "Episode 4 (2)", "Episode 5")]
[TestCase("Episode 1 (1) - More", "Episode 3 (1) - Title", "Episode 4 (2) - After", "Episode 5 - Dash")]
[TestCase("Episode 1 Part 1", "Episode 3 Part 1", "Episode 4 Part 2", "Episode 5")]
public void Repeat_Part(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 3),
NamedEpisode(three, 1, 1, 4),
NamedEpisode(four, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
ShouldHaveTwoItems(result, mediaItems[1], mediaItems[2]);
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
[TestCase("S1 Episode 1 (1)", "S2 Episode 3 (1)", "S1 Episode 2 (2)", "S1 Episode 5")]
[TestCase(
"S1 Episode 1 (1) - More",
"S2 Episode 3 (1) - Title",
"S1 Episode 2 (2) - After",
"S1 Episode 5 - Dash")]
[TestCase("S1 Episode 1 Part 1", "S2 Episode 3 Part 1", "S1 Episode 2 Part 2", "S1 Episode 5")]
public void Mixed_Shows_Chronologically(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1, new DateTime(2020, 1, 1)),
NamedEpisode(two, 2, 1, 3, new DateTime(2020, 1, 2)),
NamedEpisode(three, 1, 1, 2, new DateTime(2020, 1, 3)),
NamedEpisode(four, 1, 1, 5, new DateTime(2020, 1, 4))
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[2]);
ShouldHaveOneItem(result, mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
[TestCase("S1 Episode 1 (1)", "S2 Episode 3 (2)", "S1 Episode 2 (3)", "S1 Episode 5")]
[TestCase(
"S1 Episode 1 (1) - More",
"S2 Episode 3 (2) - Title",
"S1 Episode 2 (3) - After",
"S1 Episode 5 - Dash")]
[TestCase("S1 Episode 1 Part 1", "S2 Episode 3 Part 2", "S1 Episode 2 Part 3", "S1 Episode 5")]
public void Mixed_Shows_Chronologically_Crossover(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1, new DateTime(2020, 1, 1)),
NamedEpisode(two, 2, 1, 3, new DateTime(2020, 1, 2)),
NamedEpisode(three, 1, 1, 2, new DateTime(2020, 1, 3)),
NamedEpisode(four, 1, 1, 5, new DateTime(2020, 1, 4))
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, true);
result.Count.Should().Be(2);
ShouldHaveMultipleItems(result, mediaItems[0], new List<MediaItem> { mediaItems[1], mediaItems[2] });
ShouldHaveOneItem(result, mediaItems[3]);
}
private static Episode NamedEpisode(
string title,
int showId,
int season,
int episode,
DateTime? releaseDate = null) =>
new()
{
EpisodeNumber = episode,
EpisodeMetadata = new List<EpisodeMetadata>
{
new() { Title = title }
new() { Title = title, ReleaseDate = releaseDate }
},
Season = new Season
{
SeasonNumber = season,
Show = new Show { Id = showId },
ShowId = showId
}
};
private static void ShouldHaveOneItem(IEnumerable<GroupedMediaItem> result, MediaItem item) =>
result.Filter(g => g.First == item && Optional(g.Additional).Flatten().HeadOrNone() == None)
.Should().HaveCount(1);
private static void ShouldHaveTwoItems(
IEnumerable<GroupedMediaItem> result,
MediaItem first,
MediaItem additional) =>
result.Filter(g => g.First == first && Optional(g.Additional).Flatten().HeadOrNone() == Some(additional))
.Should().HaveCount(1);
private static void ShouldHaveMultipleItems(
IEnumerable<GroupedMediaItem> result,
MediaItem first,
List<MediaItem> additional) =>
result.Filter(
g => g.First == first && g.Additional != null && g.Additional.Count == additional.Count &&
additional.ForAll(g.Additional.Contains))
.Should().HaveCount(1);
}
}
@@ -39,7 +39,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test]
[Timeout(2000)]
public async Task ZeroDurationItem_Should_Abort()
public async Task OnlyZeroDurationItem_Should_Abort()
{
var mediaItems = new List<MediaItem>
{
@@ -55,6 +55,27 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Items.Should().BeNull();
}
[Test]
public async Task ZeroDurationItem_Should_BeSkipped()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.Zero, DateTime.Today),
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
};
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(1);
result.Items.Head().MediaItemId.Should().Be(2);
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
}
[Test]
public async Task InitialFlood_Should_StartAtMidnight()
{
@@ -78,6 +78,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
}
[Test]
[Timeout(1000)]
public void State_Index_Should_Continue_Past_End_Of_Items()
{
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10, Seed = KnownSeed };
var _ = new RandomizedMediaCollectionEnumerator(contents, state);
}
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => (MediaItem) new Episode
@@ -23,7 +23,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
var state = new CollectionEnumeratorState { Seed = 8 };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 1000; i++)
@@ -50,7 +50,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@@ -70,7 +70,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@@ -90,7 +90,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
for (var i = 0; i < 10; i++)
{
@@ -105,7 +105,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
for (var i = 6; i <= 10; i++)
{
@@ -116,6 +116,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
}
[Test]
[Timeout(1000)]
public void State_Should_Reset_When_Invalid()
{
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
shuffledContent.State.Index.Should().Be(0);
shuffledContent.State.Seed.Should().NotBe(MagicSeed);
}
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => (MediaItem) new Episode
@@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Domain
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class EpisodeMetadata : Metadata
{
@@ -7,5 +9,7 @@
public string Tagline { get; set; }
public int EpisodeId { get; set; }
public Episode Episode { get; set; }
public List<Director> Directors { get; set; }
public List<Writer> Writers { get; set; }
}
}
@@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Domain
public List<Tag> Tags { get; set; }
public List<Studio> Studios { get; set; }
public List<Actor> Actors { get; set; }
public List<MetadataGuid> Guids { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class MetadataGuid
{
public int Id { get; set; }
public string Guid { get; set; }
}
}
+1
View File
@@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public List<ProgramScheduleItem> Items { get; set; }
public List<Playout> Playouts { get; set; }
}
+100 -104
View File
@@ -120,8 +120,6 @@ namespace ErsatzTV.Core.Emby
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
var changed = false;
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@@ -135,7 +133,6 @@ namespace ErsatzTV.Core.Emby
"UPDATE: Etag has changed for show {Show}",
incoming.ShowMetadata.Head().Title);
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyShow> updated = await _televisionRepository.Update(incoming);
@@ -148,7 +145,6 @@ namespace ErsatzTV.Core.Emby
},
async () =>
{
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
@@ -159,50 +155,45 @@ namespace ErsatzTV.Core.Emby
}
});
if (changed)
{
List<EmbyItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
List<EmbyItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<EmbySeason>> maybeSeasons =
await _embyApiClient.GetSeasonLibraryItems(
Either<BaseError, List<EmbySeason>> maybeSeasons =
await _embyApiClient.GetSeasonLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { incoming });
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@@ -218,8 +209,6 @@ namespace ErsatzTV.Core.Emby
{
foreach (EmbySeason incoming in seasons)
{
var changed = false;
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@@ -234,7 +223,6 @@ namespace ErsatzTV.Core.Emby
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@@ -242,7 +230,6 @@ namespace ErsatzTV.Core.Emby
},
async () =>
{
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@@ -254,68 +241,68 @@ namespace ErsatzTV.Core.Emby
await _televisionRepository.AddSeason(incoming);
});
if (changed)
{
List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>
await maybeEpisodes.Match(
async episodes =>
{
var validEpisodes = new List<EmbyEpisode>();
foreach (EmbyEpisode episode in episodes)
{
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);
}
}
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping emby episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
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);
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@@ -354,7 +341,13 @@ namespace ErsatzTV.Core.Emby
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
await _televisionRepository.Update(incoming);
Option<EmbyEpisode> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
}
catch (Exception ex)
{
@@ -379,7 +372,10 @@ namespace ErsatzTV.Core.Emby
seasonName,
incoming.EpisodeNumber);
await _televisionRepository.AddEpisode(incoming);
if (await _televisionRepository.AddEpisode(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Locking
bool LockPlex();
bool UnlockPlex();
bool IsPlexLocked();
bool IsRemoteMediaSourceLocked<TMediaSource>();
bool LockRemoteMediaSource<TMediaSource>();
bool UnlockRemoteMediaSource<TMediaSource>();
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
@@ -46,7 +47,14 @@ namespace ErsatzTV.Core.Interfaces.Plex
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, MediaVersion>> GetStatistics(
Task<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>> GetMovieMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token);
@@ -16,10 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddSeason(EmbySeason season);
Task<Unit> Update(EmbySeason season);
Task<bool> AddEpisode(EmbyEpisode episode);
Task<Unit> Update(EmbyEpisode episode);
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds);
Task<Unit> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds);
Task<List<int>> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds);
Task<Unit> DeleteEmptySeasons(EmbyLibrary library);
Task<List<int>> DeleteEmptyShows(EmbyLibrary library);
}
@@ -16,10 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddSeason(JellyfinSeason season);
Task<Unit> Update(JellyfinSeason season);
Task<bool> AddEpisode(JellyfinEpisode episode);
Task<Unit> Update(JellyfinEpisode episode);
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
Task<Unit> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds);
Task<List<int>> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds);
Task<Unit> DeleteEmptySeasons(JellyfinLibrary library);
Task<List<int>> DeleteEmptyShows(JellyfinLibrary library);
}
@@ -27,17 +27,17 @@ namespace ErsatzTV.Core.Interfaces.Repositories
List<PlexConnection> toAdd,
List<PlexConnection> toDelete);
Task<Unit> UpdateLibraries(
Task<List<int>> UpdateLibraries(
int plexMediaSourceId,
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete);
Task<Unit> UpdateLibraries(
Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete);
Task<Unit> UpdateLibraries(
Task<List<int>> UpdateLibraries(
int embyMediaSourceId,
List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete);
@@ -28,5 +28,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating);
Task<Unit> MarkAsExternal(MovieMetadata metadata);
Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating);
Task<bool> RemoveGuid(MetadataGuid guid);
Task<bool> AddGuid(Domain.Metadata metadata, MetadataGuid guid);
Task<bool> RemoveDirector(Director director);
Task<bool> RemoveWriter(Writer writer);
}
}
@@ -33,9 +33,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds);
Task<bool> AddEmby(EmbyMovie movie);
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
Task<bool> RemoveDirector(Director director);
Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> RemoveWriter(Writer writer);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
}
}
@@ -9,11 +9,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface ITelevisionRepository
{
Task<bool> AllShowsExist(List<int> showIds);
Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId);
Task<int> GetShowCount();
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
Task<List<Episode>> GetShowItems(int showId);
Task<List<Season>> GetAllSeasons();
Task<Option<Season>> GetSeason(int seasonId);
@@ -46,7 +46,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddActor(EpisodeMetadata metadata, Actor actor);
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
}
}
@@ -120,8 +120,6 @@ namespace ErsatzTV.Core.Jellyfin
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
var changed = false;
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@@ -135,7 +133,6 @@ namespace ErsatzTV.Core.Jellyfin
"UPDATE: Etag has changed for show {Show}",
incoming.ShowMetadata.Head().Title);
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinShow> updated = await _televisionRepository.Update(incoming);
@@ -148,7 +145,6 @@ namespace ErsatzTV.Core.Jellyfin
},
async () =>
{
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
@@ -159,50 +155,45 @@ namespace ErsatzTV.Core.Jellyfin
}
});
if (changed)
{
List<JellyfinItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
List<JellyfinItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
await _jellyfinApiClient.GetSeasonLibraryItems(
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
await _jellyfinApiClient.GetSeasonLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { incoming });
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@@ -218,8 +209,6 @@ namespace ErsatzTV.Core.Jellyfin
{
foreach (JellyfinSeason incoming in seasons)
{
var changed = false;
Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@@ -234,7 +223,6 @@ namespace ErsatzTV.Core.Jellyfin
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@@ -242,7 +230,6 @@ namespace ErsatzTV.Core.Jellyfin
},
async () =>
{
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@@ -254,68 +241,69 @@ namespace ErsatzTV.Core.Jellyfin
await _televisionRepository.AddSeason(incoming);
});
if (changed)
{
List<JellyfinItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
List<JellyfinItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<JellyfinEpisode>> maybeEpisodes =
await _jellyfinApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
Either<BaseError, List<JellyfinEpisode>> maybeEpisodes =
await _jellyfinApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>
await maybeEpisodes.Match(
async episodes =>
{
var validEpisodes = new List<JellyfinEpisode>();
foreach (JellyfinEpisode episode in episodes)
{
var validEpisodes = new List<JellyfinEpisode>();
foreach (JellyfinEpisode episode in episodes)
{
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping jellyfin episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping jellyfin episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
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);
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@@ -354,7 +342,13 @@ namespace ErsatzTV.Core.Jellyfin
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
await _televisionRepository.Update(incoming);
Option<JellyfinEpisode> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
}
catch (Exception ex)
{
@@ -379,7 +373,10 @@ namespace ErsatzTV.Core.Jellyfin
seasonName,
incoming.EpisodeNumber);
await _televisionRepository.AddEpisode(incoming);
if (await _televisionRepository.AddEpisode(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
+120 -9
View File
@@ -212,6 +212,66 @@ namespace ErsatzTV.Core.Metadata
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
{
updated = true;
}
}
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
{
updated = true;
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
@@ -261,17 +321,17 @@ namespace ErsatzTV.Core.Metadata
_movieRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(g => metadata.Directors.All(g2 => g2.Name != g.Name)).ToList())
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _movieRepository.RemoveDirector(director))
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
foreach (Director director in metadata.Directors
.Filter(g => existing.Directors.All(g2 => g2.Name != g.Name)).ToList())
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _movieRepository.AddDirector(existing, director))
@@ -281,17 +341,17 @@ namespace ErsatzTV.Core.Metadata
}
foreach (Writer writer in existing.Writers
.Filter(g => metadata.Writers.All(g2 => g2.Name != g.Name)).ToList())
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _movieRepository.RemoveWriter(writer))
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
foreach (Writer writer in metadata.Writers
.Filter(g => existing.Writers.All(g2 => g2.Name != g.Name)).ToList())
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _movieRepository.AddWriter(existing, writer))
@@ -300,6 +360,26 @@ namespace ErsatzTV.Core.Metadata
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
@@ -345,6 +425,26 @@ namespace ErsatzTV.Core.Metadata
_televisionRepository.AddStudio,
_televisionRepository.AddActor);
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
@@ -522,7 +622,10 @@ namespace ErsatzTV.Core.Metadata
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList()
};
},
None);
@@ -582,7 +685,12 @@ namespace ErsatzTV.Core.Metadata
Title = nfo.Title,
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList(),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList()
};
return Tuple(metadata, nfo.Episode);
},
@@ -624,7 +732,10 @@ namespace ErsatzTV.Core.Metadata
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList()
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList()
};
},
None);
+3
View File
@@ -45,5 +45,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}
}
@@ -29,5 +29,14 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("credits")]
public List<string> Writers { get; set; }
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}
}
+3
View File
@@ -38,5 +38,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}
}
+16
View File
@@ -0,0 +1,16 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo
{
public class UniqueIdNfo
{
[XmlAttribute("default")]
public bool Default { get; set; }
[XmlAttribute("type")]
public string Type { get; set; }
[XmlText]
public string Guid { get; set; }
}
}
@@ -209,10 +209,20 @@ namespace ErsatzTV.Core.Metadata
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
maybeEpisode.IfLeft(
error => _logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value));
await maybeEpisode.Match(
async episode =>
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { episode });
},
error =>
{
_logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
// TODO: remove missing episodes?
return Unit.Default;
}
+37 -7
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -63,7 +64,7 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(existing => UpdateStatistics(existing, incoming, library, connection, token))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
@@ -114,6 +115,7 @@ namespace ErsatzTV.Core.Plex
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
@@ -123,12 +125,18 @@ namespace ErsatzTV.Core.Plex
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async mediaVersion =>
async tuple =>
{
(MovieMetadata _, MediaVersion mediaVersion) = tuple;
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Statistics",
@@ -259,7 +267,7 @@ namespace ErsatzTV.Core.Plex
.ToList())
{
existingMetadata.Directors.Remove(director);
if (await _movieRepository.RemoveDirector(director))
if (await _metadataRepository.RemoveDirector(director))
{
result.IsUpdated = true;
}
@@ -281,7 +289,7 @@ namespace ErsatzTV.Core.Plex
.ToList())
{
existingMetadata.Writers.Remove(writer);
if (await _movieRepository.RemoveWriter(writer))
if (await _metadataRepository.RemoveWriter(writer))
{
result.IsUpdated = true;
}
@@ -298,6 +306,28 @@ namespace ErsatzTV.Core.Plex
}
}
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
}
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = fullMetadata.SortTitle;
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -70,17 +71,7 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
if (result.IsAdded || incoming.ShowMetadata.Head().DateUpdated >
result.Item.ShowMetadata.Head().DateUpdated)
{
await ScanSeasons(library, result.Item, connection, token);
}
else
{
_logger.LogDebug(
"Skipping Plex show that has not been updated: {Show}",
incoming.ShowMetadata.Head().Title);
}
await ScanSeasons(library, result.Item, connection, token);
if (result.IsAdded)
{
@@ -227,6 +218,28 @@ namespace ErsatzTV.Core.Plex
}
}
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
@@ -278,7 +291,7 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateArtwork(existing, incoming));
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(plexMediaSourceLibrary, season, connection, token),
@@ -308,13 +321,31 @@ namespace ErsatzTV.Core.Plex
});
}
private async Task<Either<BaseError, PlexSeason>> UpdateArtwork(PlexSeason existing, PlexSeason incoming)
private async Task<Either<BaseError, PlexSeason>> UpdateMetadataAndArtwork(
PlexSeason existing,
PlexSeason incoming)
{
SeasonMetadata existingMetadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
@@ -344,18 +375,34 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexEpisode> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(
existing => UpdateMetadataAndStatistics(
existing,
incoming,
plexMediaSourceLibrary,
connection,
token))
.BindT(existing => UpdateArtwork(existing, incoming));
maybeEpisode.IfLeft(
error => _logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value));
await maybeEpisode.Match(
async episode =>
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { episode });
},
error =>
{
_logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
var episodeKeys = episodeEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
List<int> ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return Unit.Default;
},
@@ -370,9 +417,10 @@ namespace ErsatzTV.Core.Plex
});
}
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
private async Task<Either<BaseError, PlexEpisode>> UpdateMetadataAndStatistics(
PlexEpisode existing,
PlexEpisode incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
@@ -381,12 +429,36 @@ namespace ErsatzTV.Core.Plex
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async mediaVersion =>
async tuple =>
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
@@ -399,7 +471,9 @@ namespace ErsatzTV.Core.Plex
return Right<BaseError, PlexEpisode>(existing);
}
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(PlexEpisode existing, PlexEpisode incoming)
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(
PlexEpisode existing,
PlexEpisode incoming)
{
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();
@@ -18,6 +18,13 @@ namespace ErsatzTV.Core.Scheduling
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalMediaComparer()).ToList();
State = new CollectionEnumeratorState { Seed = state.Seed };
if (state.Index >= _sortedMediaItems.Count)
{
state.Index = 0;
state.Seed = 0;
}
while (State.Index < state.Index)
{
MoveNext();
@@ -3,27 +3,58 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
public static class MultiPartEpisodeGrouper
{
public static List<GroupedMediaItem> GroupMediaItems(IList<MediaItem> mediaItems)
public static List<GroupedMediaItem> GroupMediaItems(IList<MediaItem> mediaItems, bool treatCollectionsAsShows)
{
var sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalMediaComparer()).ToList();
var episodes = mediaItems.OfType<Episode>().ToList();
// var showIds = episodes.Map(e => e.Season.ShowId).Distinct().ToList();
var groups = new List<GroupedMediaItem>();
GroupedMediaItem group = null;
var lastNumber = 0;
foreach (MediaItem item in sortedMediaItems)
var showIds = new List<Option<int>>();
if (treatCollectionsAsShows)
{
if (item is Episode e)
showIds.Add(Option<int>.None);
}
else
{
showIds.AddRange(episodes.Map(e => Some(e.Season.ShowId)).Distinct());
}
foreach (Option<int> showId in showIds)
{
var lastNumber = 0;
void AddUngrouped(MediaItem item)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success)
if (group != null && lastNumber != 0)
{
var number = int.Parse(match.Groups[1].Value);
groups.Add(group);
group = null;
lastNumber = 0;
}
groups.Add(new GroupedMediaItem(item, null));
}
IEnumerable<Episode> sortedEpisodes = showId.Match(
id => episodes.Filter(e => e.Season.ShowId == id),
() => episodes).OrderBy(identity, new ChronologicalMediaComparer());
foreach (Episode episode in sortedEpisodes)
{
Option<int> maybeNumber = FindPartNumber(episode);
if (maybeNumber.IsSome)
{
int number = maybeNumber.ValueUnsafe();
if (number <= lastNumber && group != null)
{
groups.Add(group);
@@ -31,61 +62,96 @@ namespace ErsatzTV.Core.Scheduling
lastNumber = 0;
}
if (number == lastNumber + 1)
if (number > lastNumber)
{
if (lastNumber == 0)
{
// start a new group
group = new GroupedMediaItem(item, null);
group = new GroupedMediaItem(episode, null);
lastNumber = number;
}
else if (group != null)
else if (number == lastNumber + 1)
{
// add to current group
List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
additional.Add(item);
group = group with { Additional = additional };
if (group != null)
{
// add to current group
List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
additional.Add(episode);
group = group with { Additional = additional };
}
else
{
// this should never happen
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber} with no existing group");
}
lastNumber = number;
}
else
{
// this should never happen
throw new InvalidOperationException("Bad shuffle state");
AddUngrouped(episode);
}
lastNumber = number;
}
else
{
// this should never happen
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber}");
AddUngrouped(episode);
}
}
else
{
if (group != null && lastNumber != 0)
{
groups.Add(group);
group = null;
lastNumber = 0;
}
groups.Add(new GroupedMediaItem(item, null));
AddUngrouped(episode);
}
}
else
if (group != null && lastNumber != 0)
{
groups.Add(new GroupedMediaItem(item, null));
groups.Add(group);
group = null;
}
}
if (group != null && lastNumber != 0)
foreach (MediaItem notEpisode in mediaItems.Filter(i => i is not Episode))
{
groups.Add(group);
groups.Add(new GroupedMediaItem(notEpisode, null));
}
return groups;
}
private static Option<int> FindPartNumber(Episode e)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success && int.TryParse(match.Groups[1].Value, out int value1))
{
return value1;
}
const string PATTERN_2 = @"^.*Part (\d+)$";
Match match2 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_2);
if (match2.Success && int.TryParse(match2.Groups[1].Value, out int value2))
{
return value2;
}
const string PATTERN_3 = @"^.*\(([MDCLXVI]+)\)( - .*)?$";
Match match3 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_3);
if (match3.Success && TryParseRoman(match3.Groups[1].Value, out int value3))
{
return value3;
}
const string PATTERN_4 = @"^.*Part (\w+)$";
Match match4 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_4);
if (match4.Success && TryParseEnglish(match4.Groups[1].Value, out int value4))
{
return value4;
}
return None;
}
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount)
{
var result = new MediaItem[mediaItemCount];
@@ -101,5 +167,85 @@ namespace ErsatzTV.Core.Scheduling
return result;
}
private static bool TryParseRoman(string input, out int output)
{
switch (input?.ToLowerInvariant())
{
case "i":
output = 1;
return true;
case "ii":
output = 2;
return true;
case "iii":
output = 3;
return true;
case "iv":
output = 4;
return true;
case "v":
output = 5;
return true;
case "vi":
output = 6;
return true;
case "vii":
output = 7;
return true;
case "viii" or "iix":
output = 8;
return true;
case "ix":
output = 9;
return true;
case "x":
output = 10;
return true;
default:
output = 0;
return false;
}
}
private static bool TryParseEnglish(string input, out int output)
{
switch (input?.ToLowerInvariant())
{
case "one":
output = 1;
return true;
case "two":
output = 2;
return true;
case "three":
output = 3;
return true;
case "four":
output = 4;
return true;
case "five":
output = 5;
return true;
case "six":
output = 6;
return true;
case "seven":
output = 7;
return true;
case "eight":
output = 8;
return true;
case "nine":
output = 9;
return true;
case "ten":
output = 10;
return true;
default:
output = 0;
return false;
}
}
}
}
+39 -3
View File
@@ -80,17 +80,52 @@ namespace ErsatzTV.Core.Scheduling
playout.Channel.Number,
playout.Channel.Name);
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
{
var zeroItems = new List<MediaItem>();
foreach (MediaItem item in items)
{
bool isZero = item switch
{
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone().IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
_ => true
};
if (isZero)
{
_logger.LogWarning(
"Skipping media item with zero duration {MediaItem} - {MediaItemTitle}",
item.Id,
DisplayTitle(item));
zeroItems.Add(item);
}
}
items.RemoveAll(i => zeroItems.Contains(i));
}
// this guard needs to be below the place where we modify the collections (by removing zero-duration items)
Option<CollectionKey> emptyCollection =
collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
if (emptyCollection.IsSome)
{
_logger.LogError(
"Unable to rebuild playout; collection {@CollectionKey} has no items!",
"Unable to rebuild playout; collection {@CollectionKey} has no valid items!",
emptyCollection.ValueUnsafe());
return playout;
}
// leaving this guard in for a while to ensure the zero item removal is working properly
Option<CollectionKey> zeroDurationCollection = collectionMediaItems.Find(
c => c.Value.Any(
mi => mi switch
@@ -106,7 +141,7 @@ namespace ErsatzTV.Core.Scheduling
if (zeroDurationCollection.IsSome)
{
_logger.LogError(
"Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
"BUG: Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
zeroDurationCollection.ValueUnsafe());
return playout;
@@ -471,7 +506,8 @@ namespace ErsatzTV.Core.Scheduling
return new ShuffledMediaCollectionEnumerator(
mediaItems,
state,
playout.ProgramSchedule.KeepMultiPartEpisodesTogether);
playout.ProgramSchedule.KeepMultiPartEpisodesTogether,
playout.ProgramSchedule.TreatCollectionsAsShows);
default:
// TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
@@ -18,14 +18,21 @@ namespace ErsatzTV.Core.Scheduling
public ShuffledMediaCollectionEnumerator(
IList<MediaItem> mediaItems,
CollectionEnumeratorState state,
bool keepMultiPartEpisodesTogether)
bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows)
{
_mediaItemCount = mediaItems.Count;
_mediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems)
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
if (state.Index >= _mediaItems.Count)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();
}
_random = new Random(state.Seed);
_shuffled = Shuffle(_mediaItems, _random);
@@ -17,6 +17,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(em => em.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Directors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Writers)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MetadataGuidConfiguration : IEntityTypeConfiguration<MetadataGuid>
{
public void Configure(EntityTypeBuilder<MetadataGuid> builder) => builder.ToTable("MetadataGuid");
}
}
@@ -37,6 +37,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(mm => mm.Writers)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -13,6 +13,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(em => em.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -29,6 +29,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(sm => sm.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
AND A.ChannelId IS NULL
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
.Map(result => result.ToList());
@@ -88,6 +88,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Actors)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -177,6 +179,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Actors.Add(actor);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@@ -246,6 +263,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == season.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -322,6 +341,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@@ -345,20 +379,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Unit> Update(EmbyEpisode episode)
public async Task<Option<EmbyEpisode>> Update(EmbyEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<EmbyEpisode> maybeExisting = await dbContext.EmbyEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -402,6 +453,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
thumbnail.DateUpdated = incomingThumbnail.DateUpdated;
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@@ -422,7 +518,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync();
return Unit.Default;
return maybeExisting;
}
public async Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds)
@@ -454,14 +550,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit();
public Task<Unit> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds) =>
_dbConnection.ExecuteAsync(
public async Task<List<int>> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode ee ON ee.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND ee.ItemId IN @EpisodeIds",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode je ON je.Id = m.Id
INNER JOIN EmbyEpisode ee ON ee.Id = m.Id
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds)",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).ToUnit();
WHERE LP.LibraryId = @LibraryId AND ee.ItemId IN @EpisodeIds)",
new { LibraryId = library.Id, EpisodeIds = episodeIds });
return ids;
}
public async Task<Unit> DeleteEmptySeasons(EmbyLibrary library)
{
@@ -88,6 +88,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Actors)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -177,6 +179,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Actors.Add(actor);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@@ -263,6 +280,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == season.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -322,6 +341,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@@ -345,20 +379,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Unit> Update(JellyfinEpisode episode)
public async Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinEpisode> maybeExisting = await dbContext.JellyfinEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -402,6 +453,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
thumbnail.DateUpdated = incomingThumbnail.DateUpdated;
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@@ -422,7 +518,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync();
return Unit.Default;
return maybeExisting;
}
public async Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds)
@@ -454,14 +550,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit();
public Task<Unit> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds) =>
_dbConnection.ExecuteAsync(
public async Task<List<int>> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode je ON je.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode je ON je.Id = m.Id
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds)",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).ToUnit();
new { LibraryId = library.Id, EpisodeIds = episodeIds });
return ids;
}
public async Task<Unit> DeleteEmptySeasons(JellyfinLibrary library)
{
@@ -155,9 +155,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Directors)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Writers)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
@@ -206,7 +206,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
public async Task<Unit> UpdateLibraries(
public async Task<List<int>> UpdateLibraries(
int plexMediaSourceId,
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete)
@@ -228,12 +228,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
dbContext.Entry(delete).State = EntityState.Deleted;
}
List<int> ids = await DisablePlexLibrarySync(toDelete.Map(l => l.Id).ToList());
await dbContext.SaveChangesAsync();
return Unit.Default;
return ids;
}
public async Task<Unit> UpdateLibraries(
public async Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete)
@@ -255,12 +257,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
dbContext.Entry(delete).State = EntityState.Deleted;
}
List<int> ids = await DisableJellyfinLibrarySync(toDelete.Map(l => l.Id).ToList());
await dbContext.SaveChangesAsync();
return Unit.Default;
return ids;
}
public async Task<Unit> UpdateLibraries(
public async Task<List<int>> UpdateLibraries(
int embyMediaSourceId,
List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete)
@@ -282,9 +286,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
dbContext.Entry(delete).State = EntityState.Deleted;
}
List<int> ids = await DisableEmbyLibrarySync(toDelete.Map(l => l.Id).ToList());
await dbContext.SaveChangesAsync();
return Unit.Default;
return ids;
}
public async Task<Unit> UpdatePathReplacements(
@@ -348,10 +354,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
List<int> movieIds = await context.PlexMovies.Map(pm => pm.Id).ToListAsync();
List<int> showIds = await context.PlexShows.Map(ps => ps.Id).ToListAsync();
List<int> episodeIds = await context.PlexEpisodes.Map(pe => pe.Id).ToListAsync();
await context.SaveChangesAsync();
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
@@ -398,6 +405,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@@ -433,7 +448,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
@@ -555,6 +570,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@@ -590,7 +613,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
@@ -702,9 +725,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(ps => ps.Id)
.ToListAsync();
List<int> episodeIds = await context.JellyfinEpisodes
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync();
await context.SaveChangesAsync();
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public async Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem)
@@ -898,9 +926,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(ps => ps.Id)
.ToListAsync();
List<int> episodeIds = await context.EmbyEpisodes
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync();
await context.SaveChangesAsync();
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds) =>
@@ -935,6 +968,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@@ -970,7 +1011,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
}
}
@@ -75,6 +75,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
foreach (MetadataGuid guid in Optional(metadata.Guids).Flatten())
{
dbContext.Entry(guid).State = EntityState.Added;
}
if (metadata is MovieMetadata movieMetadata)
{
foreach (Director director in Optional(movieMetadata.Directors).Flatten())
@@ -88,6 +93,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
if (metadata is EpisodeMetadata episodeMetadata)
{
foreach (Director director in Optional(episodeMetadata.Directors).Flatten())
{
dbContext.Entry(director).State = EntityState.Added;
}
foreach (Writer writer in Optional(episodeMetadata.Writers).Flatten())
{
dbContext.Entry(writer).State = EntityState.Added;
}
}
return await dbContext.SaveChangesAsync() > 0;
}
@@ -261,6 +279,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"UPDATE MovieMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
new { metadata.Id, ContentRating = contentRating }).ToUnit();
public Task<bool> RemoveGuid(MetadataGuid guid) =>
_dbConnection.ExecuteAsync("DELETE FROM MetadataGuid WHERE Id = @GuidId", new { GuidId = guid.Id })
.Map(result => result > 0);
public Task<bool> AddGuid(Metadata metadata, MetadataGuid guid) =>
metadata switch
{
MovieMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, MovieMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
ShowMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, ShowMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
SeasonMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, SeasonMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
EpisodeMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, EpisodeMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
ArtistMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, ArtistMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
_ => throw new NotSupportedException()
};
public Task<bool> RemoveDirector(Director director) =>
_dbConnection.ExecuteAsync("DELETE FROM Director WHERE Id = @DirectorId", new { DirectorId = director.Id })
.Map(result => result > 0);
public Task<bool> RemoveWriter(Writer writer) =>
_dbConnection.ExecuteAsync("DELETE FROM Writer WHERE Id = @WriterId", new { WriterId = writer.Id })
.Map(result => result > 0);
public Task<bool> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
.Map(result => result > 0);
@@ -73,6 +73,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Actors)
@@ -119,6 +121,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Directors)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
@@ -328,6 +332,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Directors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -447,6 +453,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@@ -565,6 +586,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@@ -684,6 +707,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@@ -737,19 +775,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return maybeExisting;
}
public Task<bool> RemoveDirector(Director director) =>
_dbConnection.ExecuteAsync("DELETE FROM Director WHERE Id = @DirectorId", new { DirectorId = director.Id })
.Map(result => result > 0);
public Task<bool> AddDirector(MovieMetadata metadata, Director director) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Director (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { director.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> RemoveWriter(Writer writer) =>
_dbConnection.ExecuteAsync("DELETE FROM Writer WHERE Id = @WriterId", new { WriterId = writer.Id })
.Map(result => result > 0);
public Task<bool> AddWriter(MovieMetadata metadata, Writer writer) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Writer (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
@@ -47,6 +47,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Genres)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Tags)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Studios)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Actors)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Directors)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Writers)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(em => em.Streams)
.Include(mi => (mi as Episode).Season)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata)
@@ -31,6 +31,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowIds = showIds })
.Map(c => c == showIds.Count);
public Task<bool> AllEpisodesExist(List<int> episodeIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds",
new { EpisodeIds = episodeIds })
.Map(c => c == episodeIds.Count);
public async Task<List<Show>> GetAllShows()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@@ -63,31 +69,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public async Task<int> GetShowCount()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ShowMetadata
.AsNoTracking()
.GroupBy(sm => new { sm.Title, sm.Year })
.CountAsync();
}
public async Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ShowMetadata.FromSqlRaw(
@"SELECT * FROM ShowMetadata WHERE Id IN
(SELECT MIN(Id) FROM ShowMetadata GROUP BY Title, Year, MetadataKind HAVING MetadataKind = MAX(MetadataKind))
ORDER BY SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.SortTitle)
.ToListAsync();
}
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@@ -99,6 +80,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync();
}
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => ids.Contains(em.EpisodeId))
.Include(em => em.Artwork)
.Include(em => em.Directors)
.Include(em => em.Writers)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.OrderBy(em => em.SortTitle)
.ToListAsync();
}
public async Task<List<Season>> GetAllSeasons()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@@ -187,6 +189,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.AsNoTracking()
.Filter(em => em.Episode.SeasonId == seasonId)
.Include(em => em.Artwork)
.Include(em => em.Directors)
.Include(em => em.Writers)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
@@ -225,6 +229,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(s => s.Id)
@@ -248,6 +254,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Guids ??= new List<MetadataGuid>();
var show = new Show
{
LibraryPathId = libraryPathId,
@@ -274,6 +281,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<Season> maybeExisting = await dbContext.Seasons
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Guids)
.OrderBy(s => s.ShowId)
.ThenBy(s => s.SeasonNumber)
.SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber);
@@ -293,12 +302,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Genres)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Tags)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Studios)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.Season)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -398,6 +422,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(a => a.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key)
@@ -415,7 +441,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.SeasonMetadata)
.ThenInclude(sm => sm.Guids)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -431,6 +459,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
@@ -438,6 +476,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.ThenInclude(a => a.Artwork)
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(e => e.Season)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -456,15 +499,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE P.Key = @ShowKey AND ps.Key not in @Keys)",
new { ShowKey = showKey, Keys = seasonKeys }).ToUnit();
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
_dbConnection.ExecuteAsync(
public async Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN Episode e ON m.Id = e.Id
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN PlexSeason P on P.Id = e.SeasonId
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys",
new { SeasonKey = seasonKey, Keys = episodeKeys }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN Episode e ON m.Id = e.Id
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN PlexSeason P on P.Id = e.SeasonId
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys)",
new { SeasonKey = seasonKey, Keys = episodeKeys }).ToUnit();
new { SeasonKey = seasonKey, Keys = episodeKeys });
return ids;
}
public async Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber)
{
@@ -475,6 +530,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Director (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
new { director.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Writer (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<Episode>> GetShowItems(int showId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@@ -613,7 +678,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
new()
{
DateAdded = DateTime.UtcNow
DateAdded = DateTime.UtcNow,
Guids = new List<MetadataGuid>()
}
}
};
@@ -651,7 +717,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.MinValue,
MetadataKind = MetadataKind.Fallback,
Actors = new List<Actor>()
Actors = new List<Actor>(),
Guids = new List<MetadataGuid>(),
Writers = new List<Writer>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>()
}
},
MediaVersions = new List<MediaVersion>
@@ -668,6 +740,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
};
await dbContext.Episodes.AddAsync(episode);
await dbContext.SaveChangesAsync();
await dbContext.Entry(episode).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return episode;
}
catch (Exception ex)
@@ -730,10 +805,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
item.LibraryPathId = library.Paths.Head().Id;
EpisodeMetadata metadata = item.EpisodeMetadata.Head();
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Directors ??= new List<Director>();
metadata.Writers ??= new List<Writer>();
await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync();
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(item).Reference(e => e.Season).LoadAsync();
return item;
}
catch (Exception ex)
+35 -4
View File
@@ -244,7 +244,8 @@ namespace ErsatzTV.Infrastructure.Emby
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@@ -365,7 +366,8 @@ namespace ErsatzTV.Infrastructure.Emby
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@@ -429,7 +431,8 @@ namespace ErsatzTV.Infrastructure.Emby
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
Year = item.ProductionYear,
DateAdded = dateAdded,
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
@@ -540,7 +543,10 @@ namespace ErsatzTV.Infrastructure.Emby
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
};
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
@@ -561,5 +567,30 @@ namespace ErsatzTV.Infrastructure.Emby
return metadata;
}
private List<MetadataGuid> GuidsFromProviderIds(EmbyProviderIdsResponse providerIds)
{
var result = new List<MetadataGuid>();
if (providerIds != null)
{
if (!string.IsNullOrWhiteSpace(providerIds.Imdb))
{
result.Add(new MetadataGuid { Guid = $"imdb://{providerIds.Imdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tmdb))
{
result.Add(new MetadataGuid { Guid = $"tmdb://{providerIds.Tmdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tvdb))
{
result.Add(new MetadataGuid { Guid = $"tvdb://{providerIds.Tvdb}" });
}
}
return result;
}
}
}
+9 -4
View File
@@ -28,7 +28,7 @@ namespace ErsatzTV.Infrastructure.Emby
string parentId,
[Query]
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating",
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Movie",
[Query]
@@ -42,7 +42,7 @@ namespace ErsatzTV.Infrastructure.Emby
string parentId,
[Query]
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating",
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Series",
[Query]
@@ -55,10 +55,12 @@ namespace ErsatzTV.Infrastructure.Emby
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Taglines",
string fields = "Path,DateCreated,Etag,Taglines,ProviderIds",
[Query]
string includeItemTypes = "Season",
[Query]
string excludeLocationTypes = "Virtual",
[Query]
bool recursive = true);
[Get("/Items")]
@@ -68,10 +70,13 @@ namespace ErsatzTV.Infrastructure.Emby
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType",
string fields =
"Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType,ProviderIds,People",
[Query]
string includeItemTypes = "Episode",
[Query]
string excludeLocationTypes = "Virtual",
[Query]
bool recursive = true);
}
}
@@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Emby.Models
public List<string> Genres { get; set; }
public List<string> Tags { get; set; }
public int ProductionYear { get; set; }
public EmbyProviderIdsResponse ProviderIds { get; set; }
public string PremiereDate { get; set; }
public List<EmbyMediaStreamResponse> MediaStreams { get; set; }
public List<EmbyMediaSourceResponse> MediaSources { get; set; }
@@ -0,0 +1,9 @@
namespace ErsatzTV.Infrastructure.Emby.Models
{
public class EmbyProviderIdsResponse
{
public string Imdb { get; set; }
public string Tmdb { get; set; }
public string Tvdb { get; set; }
}
}
@@ -34,7 +34,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating",
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Movie",
[Query]
@@ -49,7 +50,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating",
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Series",
[Query]
@@ -64,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Taglines",
string fields = "Path,DateCreated,Etag,Taglines,ProviderIds",
[Query]
string includeItemTypes = "Season",
[Query]
@@ -79,7 +81,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Overview",
string fields = "Path,DateCreated,Etag,Overview,ProviderIds,People",
[Query]
string includeItemTypes = "Episode",
[Query]
@@ -290,7 +290,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@@ -416,7 +417,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@@ -485,7 +487,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
Year = item.ProductionYear,
DateAdded = dateAdded,
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
@@ -596,7 +599,10 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
};
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
@@ -617,5 +623,30 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return metadata;
}
private List<MetadataGuid> GuidsFromProviderIds(JellyfinProviderIdsResponse providerIds)
{
var result = new List<MetadataGuid>();
if (providerIds != null)
{
if (!string.IsNullOrWhiteSpace(providerIds.Imdb))
{
result.Add(new MetadataGuid { Guid = $"imdb://{providerIds.Imdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tmdb))
{
result.Add(new MetadataGuid { Guid = $"tmdb://{providerIds.Tmdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tvdb))
{
result.Add(new MetadataGuid { Guid = $"tvdb://{providerIds.Tvdb}" });
}
}
return result;
}
}
}
@@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin.Models
public List<string> Genres { get; set; }
public List<string> Tags { get; set; }
public int ProductionYear { get; set; }
public JellyfinProviderIdsResponse ProviderIds { get; set; }
public string PremiereDate { get; set; }
public List<JellyfinMediaStreamResponse> MediaStreams { get; set; }
public string LocationType { get; set; }
@@ -0,0 +1,9 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models
{
public class JellyfinProviderIdsResponse
{
public string Imdb { get; set; }
public string Tmdb { get; set; }
public string Tvdb { get; set; }
}
}
@@ -71,6 +71,9 @@ namespace ErsatzTV.Infrastructure.Locking
public bool IsPlexLocked() => _plex;
public bool IsRemoteMediaSourceLocked<TMediaSource>() =>
_lockedRemoteMediaSourceTypes.ContainsKey(typeof(TMediaSource));
public bool LockRemoteMediaSource<TMediaSource>()
{
Type mediaSourceType = typeof(TMediaSource);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MetadataGuid : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// local and plex
migrationBuilder.Sql("UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE SeasonMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE EpisodeMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL WHERE LibraryPathId IN
(SELECT LibraryPathId FROM LibraryPath LP
INNER JOIN Library L on LP.LibraryId = L.Id
WHERE L.MediaKind = 1)");
// emby
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
// jellyfin
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
migrationBuilder.CreateTable(
name: "MetadataGuid",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Guid = table.Column<string>(type: "TEXT", nullable: true),
ArtistMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
EpisodeMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
MovieMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
MusicVideoMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
SeasonMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
ShowMetadataId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataGuid", x => x.Id);
table.ForeignKey(
name: "FK_MetadataGuid_ArtistMetadata_ArtistMetadataId",
column: x => x.ArtistMetadataId,
principalTable: "ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_MetadataGuid_EpisodeMetadata_EpisodeMetadataId",
column: x => x.EpisodeMetadataId,
principalTable: "EpisodeMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataGuid_MovieMetadata_MovieMetadataId",
column: x => x.MovieMetadataId,
principalTable: "MovieMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataGuid_MusicVideoMetadata_MusicVideoMetadataId",
column: x => x.MusicVideoMetadataId,
principalTable: "MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_MetadataGuid_SeasonMetadata_SeasonMetadataId",
column: x => x.SeasonMetadataId,
principalTable: "SeasonMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataGuid_ShowMetadata_ShowMetadataId",
column: x => x.ShowMetadataId,
principalTable: "ShowMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_ArtistMetadataId",
table: "MetadataGuid",
column: "ArtistMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_EpisodeMetadataId",
table: "MetadataGuid",
column: "EpisodeMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_MovieMetadataId",
table: "MetadataGuid",
column: "MovieMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_MusicVideoMetadataId",
table: "MetadataGuid",
column: "MusicVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_SeasonMetadataId",
table: "MetadataGuid",
column: "SeasonMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_ShowMetadataId",
table: "MetadataGuid",
column: "ShowMetadataId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MetadataGuid");
}
}
}
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_EpisodeMetadataDirectorsWriters : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeMetadataId",
table: "Writer",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "EpisodeMetadataId",
table: "Director",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Writer_EpisodeMetadataId",
table: "Writer",
column: "EpisodeMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Director_EpisodeMetadataId",
table: "Director",
column: "EpisodeMetadataId");
migrationBuilder.AddForeignKey(
name: "FK_Director_EpisodeMetadata_EpisodeMetadataId",
table: "Director",
column: "EpisodeMetadataId",
principalTable: "EpisodeMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Writer_EpisodeMetadata_EpisodeMetadataId",
table: "Writer",
column: "EpisodeMetadataId",
principalTable: "EpisodeMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Director_EpisodeMetadata_EpisodeMetadataId",
table: "Director");
migrationBuilder.DropForeignKey(
name: "FK_Writer_EpisodeMetadata_EpisodeMetadataId",
table: "Writer");
migrationBuilder.DropIndex(
name: "IX_Writer_EpisodeMetadataId",
table: "Writer");
migrationBuilder.DropIndex(
name: "IX_Director_EpisodeMetadataId",
table: "Director");
migrationBuilder.DropColumn(
name: "EpisodeMetadataId",
table: "Writer");
migrationBuilder.DropColumn(
name: "EpisodeMetadataId",
table: "Director");
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ProgramScheduleTreatCollectionsAsShows : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "TreatCollectionsAsShows",
table: "ProgramSchedule",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TreatCollectionsAsShows",
table: "ProgramSchedule");
}
}
}
@@ -274,6 +274,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@@ -282,6 +285,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.ToTable("Director");
@@ -740,6 +745,50 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaVersion");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Guid")
.HasColumnType("TEXT");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.ToTable("MetadataGuid");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b =>
{
b.Property<int>("Id")
@@ -1007,6 +1056,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("TreatCollectionsAsShows")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
@@ -1281,6 +1333,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@@ -1289,6 +1344,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.ToTable("Writer");
@@ -1770,6 +1827,11 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Director", b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Directors")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Directors")
.HasForeignKey("MovieMetadataId")
@@ -1957,6 +2019,37 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Guids")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Guids")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Guids")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Guids")
.HasForeignKey("MusicVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids")
.HasForeignKey("SeasonMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Guids")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
@@ -2255,6 +2348,11 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Writer", b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Writers")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Writers")
.HasForeignKey("MovieMetadataId")
@@ -2572,6 +2670,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Moods");
b.Navigation("Studios");
@@ -2599,11 +2699,17 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Artwork");
b.Navigation("Directors");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
@@ -2645,6 +2751,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
@@ -2660,6 +2768,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
@@ -2687,6 +2797,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
@@ -2700,6 +2812,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
+24 -5
View File
@@ -4,15 +4,16 @@ using Refit;
namespace ErsatzTV.Infrastructure.Plex
{
[Headers("Accept: application/json")]
public interface IPlexServerApi
{
[Get("/library/sections")]
[Headers("Accept: application/json")]
public Task<PlexMediaContainerResponse<PlexMediaContainerDirectoryContent<PlexLibraryResponse>>> GetLibraries(
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/sections/{key}/all")]
[Headers("Accept: application/json")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetLibrarySectionContents(
string key,
@@ -20,15 +21,33 @@ namespace ErsatzTV.Infrastructure.Plex
string token);
[Get("/library/metadata/{key}")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetMetadata(
[Headers("Accept: text/xml")]
public Task<PlexXmlVideoMetadataResponseContainer>
GetVideoMetadata(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}")]
[Headers("Accept: text/xml")]
public Task<PlexXmlDirectoryMetadataResponseContainer>
GetDirectoryMetadata(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}/children")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetChildren(
[Headers("Accept: text/xml")]
public Task<PlexXmlSeasonsMetadataResponseContainer>
GetShowChildren(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}/children")]
[Headers("Accept: text/xml")]
public Task<PlexXmlEpisodesMetadataResponseContainer>
GetSeasonChildren(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
@@ -1,7 +1,10 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexDirectorResponse
{
[XmlAttribute("tag")]
public string Tag { get; set; }
}
}
@@ -1,9 +1,16 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexGenreResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("filter")]
public string Filter { get; set; }
[XmlAttribute("tag")]
public string Tag { get; set; }
}
}
@@ -0,0 +1,10 @@
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexGuidResponse
{
[XmlAttribute("id")]
public string Id { get; set; }
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
@@ -16,4 +17,32 @@ namespace ErsatzTV.Infrastructure.Plex.Models
{
public List<T> Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlVideoMetadataResponseContainer
{
[XmlElement("Video")]
public PlexXmlMetadataResponse Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlDirectoryMetadataResponseContainer
{
[XmlElement("Directory")]
public PlexXmlMetadataResponse Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlSeasonsMetadataResponseContainer
{
[XmlElement("Directory")]
public List<PlexXmlMetadataResponse> Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlEpisodesMetadataResponseContainer
{
[XmlElement("Video")]
public List<PlexXmlMetadataResponse> Metadata { get; set; }
}
}
@@ -1,21 +1,50 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexMediaResponse
public class PlexMediaResponse<T>
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("duration")]
public int Duration { get; set; }
[XmlAttribute("bitrate")]
public int Bitrate { get; set; }
[XmlAttribute("width")]
public int Width { get; set; }
[XmlAttribute("height")]
public int Height { get; set; }
[XmlAttribute("aspectRatio")]
public double AspectRatio { get; set; }
[XmlAttribute("audioChannels")]
public int AudioChannels { get; set; }
[XmlAttribute("audioCodec")]
public string AudioCodec { get; set; }
[XmlAttribute("videoCodec")]
public string VideoCodec { get; set; }
[XmlAttribute("videoResulution")]
public string VideoResolution { get; set; }
[XmlAttribute("videoProfile")]
public string VideoProfile { get; set; }
[XmlAttribute("container")]
public string Container { get; set; }
[XmlAttribute("videoFrameRate")]
public string VideoFrameRate { get; set; }
public List<PlexPartResponse> Part { get; set; }
[XmlElement("Part")]
public List<T> Part { get; set; }
}
}
@@ -1,26 +1,74 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexMetadataResponse
{
[XmlAttribute("key")]
public string Key { get; set; }
[XmlAttribute("title")]
public string Title { get; set; }
[XmlAttribute("contentRating")]
public string ContentRating { get; set; }
[XmlAttribute("summary")]
public string Summary { get; set; }
[XmlAttribute("year")]
public int Year { get; set; }
[XmlAttribute("tagline")]
public string Tagline { get; set; }
[XmlAttribute("thumb")]
public string Thumb { get; set; }
[XmlAttribute("art")]
public string Art { get; set; }
[XmlAttribute("originallyAvailableAt")]
public string OriginallyAvailableAt { get; set; }
[XmlAttribute("addedAt")]
public long AddedAt { get; set; }
[XmlAttribute("updatedAt")]
public long UpdatedAt { get; set; }
[XmlAttribute("index")]
public int Index { get; set; }
[XmlAttribute("studio")]
public string Studio { get; set; }
public List<PlexMediaResponse> Media { get; set; }
[XmlAttribute("rating")]
public double Rating { get; set; }
[XmlAttribute("audienceRating")]
public double AudienceRating { get; set; }
[XmlAttribute("audienceRatingImage")]
public string AudienceRatingImage { get; set; }
[XmlAttribute("ratingImage")]
public string RatingImage { get; set; }
[XmlIgnore]
public virtual List<PlexMediaResponse<PlexPartResponse>> Media { get; set; }
[XmlElement("Genre")]
public List<PlexGenreResponse> Genre { get; set; }
[XmlElement("Role")]
public List<PlexRoleResponse> Role { get; set; }
[XmlElement("Director")]
public List<PlexDirectorResponse> Director { get; set; }
[XmlElement("Writer")]
public List<PlexWriterResponse> Writer { get; set; }
}
}
@@ -1,6 +1,4 @@
using System.Collections.Generic;
namespace ErsatzTV.Infrastructure.Plex.Models
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexPartResponse
{
@@ -8,6 +6,5 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public List<PlexStreamResponse> Stream { get; set; }
}
}
@@ -1,9 +1,16 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexRoleResponse
{
[XmlAttribute("tag")]
public string Tag { get; set; }
[XmlAttribute("role")]
public string Role { get; set; }
[XmlAttribute("thumb")]
public string Thumb { get; set; }
}
}
@@ -1,18 +1,43 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexStreamResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("index")]
public int Index { get; set; }
[XmlAttribute("default")]
public bool Default { get; set; }
[XmlAttribute("forced")]
public bool Forced { get; set; }
[XmlAttribute("languageCode")]
public string LanguageCode { get; set; }
[XmlAttribute("streamType")]
public int StreamType { get; set; }
[XmlAttribute("codec")]
public string Codec { get; set; }
[XmlAttribute("profile")]
public string Profile { get; set; }
[XmlAttribute("channels")]
public int Channels { get; set; }
[XmlAttribute("anamorphic")]
public bool Anamorphic { get; set; }
[XmlAttribute("pixelAspectRatio")]
public string PixelAspectRatio { get; set; }
[XmlAttribute("scanType")]
public string ScanType { get; set; }
}
}
@@ -1,7 +1,10 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexWriterResponse
{
[XmlAttribute("tag")]
public string Tag { get; set; }
}
}
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexXmlMetadataResponse : PlexMetadataResponse
{
[XmlAttribute("guid")]
public string PlexGuid { get; set; }
[XmlElement("Media")]
public new List<PlexMediaResponse<PlexXmlPartResponse>> Media { get; set; }
[XmlElement("Guid")]
public List<PlexGuidResponse> Guid { get; set; }
}
}
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexXmlPartResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("key")]
public string Key { get; set; }
[XmlAttribute("duration")]
public int Duration { get; set; }
[XmlAttribute("file")]
public string File { get; set; }
[XmlElement("Stream")]
public List<PlexStreamResponse> Stream { get; set; }
}
}
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -87,9 +88,9 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.MediaContainer.Metadata)
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetShowChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.Metadata)
.Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
@@ -106,9 +107,9 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetSeasonChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
.Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
@@ -125,12 +126,13 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(key, token.AuthToken)
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone())
.MapT(response => ProjectToMovieMetadata(response, library.MediaSourceId))
.MapT(response => ProjectToMovieMetadata(response.Metadata, library.MediaSourceId))
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
}
catch (Exception ex)
@@ -147,10 +149,10 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(key, token.AuthToken)
.Map(r => r.MediaContainer.Metadata.HeadOrNone())
.MapT(response => ProjectToShowMetadata(response, library.MediaSourceId))
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetDirectoryMetadata(key, token.AuthToken)
.Map(Optional)
.MapT(response => ProjectToShowMetadata(response.Metadata, library.MediaSourceId))
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
}
catch (Exception ex)
@@ -159,20 +161,30 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, MediaVersion>> GetStatistics(
public async Task<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>> GetMovieMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(key, token.AuthToken)
IPlexServerApi service = XmlServiceFor(connection.Uri);
Option<PlexXmlVideoMetadataResponseContainer> maybeResponse = await service
.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
.HeadOrNone())
.BindT(ProjectToMediaVersion)
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone());
return maybeResponse.Match(
response =>
{
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(response.Metadata);
return maybeVersion.Match<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>>(
version => Tuple(ProjectToMovieMetadata(response.Metadata, library.MediaSourceId), version),
() => BaseError.New("Unable to locate metadata"));
},
() => BaseError.New("Unable to locate metadata"));
}
catch (Exception ex)
{
@@ -180,6 +192,57 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = XmlServiceFor(connection.Uri);
Option<PlexXmlVideoMetadataResponseContainer> maybeResponse = await service
.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone());
return maybeResponse.Match(
response =>
{
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(response.Metadata);
return maybeVersion.Match<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>>(
version => Tuple(
ProjectToEpisodeMetadata(response.Metadata, library.MediaSourceId),
version),
() => BaseError.New("Unable to locate metadata"));
},
() => BaseError.New("Unable to locate metadata"));
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private static IPlexServerApi XmlServiceFor(string uri)
{
var overrides = new XmlAttributeOverrides();
var attrs = new XmlAttributes { XmlIgnore = true };
overrides.Add(typeof(PlexMetadataResponse), "Media", attrs);
return RestService.For<IPlexServerApi>(
uri,
new RefitSettings
{
ContentSerializer = new XmlContentSerializer(
new XmlContentSerializerSettings
{
XmlAttributeOverrides = overrides
})
});
}
private static Option<PlexLibrary> Project(PlexLibraryResponse response) =>
response.Type switch
{
@@ -205,7 +268,7 @@ namespace ErsatzTV.Infrastructure.Plex
private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse media = response.Media.Head();
PlexMediaResponse<PlexPartResponse> media = response.Media.Head();
PlexPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@@ -268,6 +331,23 @@ namespace ErsatzTV.Infrastructure.Plex
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList()
};
if (response is PlexXmlMetadataResponse xml)
{
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
}
else
{
metadata.Guids = new List<MetadataGuid>();
}
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
@@ -311,7 +391,7 @@ namespace ErsatzTV.Infrastructure.Plex
return metadata;
}
private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
private Option<MediaVersion> ProjectToMediaVersion(PlexXmlMetadataResponse response)
{
List<PlexStreamResponse> streams = response.Media.Head().Part.Head().Stream;
DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@@ -418,6 +498,23 @@ namespace ErsatzTV.Infrastructure.Plex
.ToList()
};
if (response is PlexXmlMetadataResponse xml)
{
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
}
else
{
metadata.Guids = new List<MetadataGuid>();
}
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
@@ -461,7 +558,7 @@ namespace ErsatzTV.Infrastructure.Plex
return metadata;
}
private PlexSeason ProjectToSeason(PlexMetadataResponse response, int mediaSourceId)
private PlexSeason ProjectToSeason(PlexXmlMetadataResponse response, int mediaSourceId)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@@ -476,6 +573,16 @@ namespace ErsatzTV.Infrastructure.Plex
DateUpdated = lastWriteTime
};
metadata.Guids = Optional(response.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(response.PlexGuid))
{
string normalized = NormalizeGuid(response.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
@@ -516,47 +623,14 @@ namespace ErsatzTV.Infrastructure.Plex
return season;
}
private PlexEpisode ProjectToEpisode(PlexMetadataResponse response, int mediaSourceId)
private PlexEpisode ProjectToEpisode(PlexXmlMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse media = response.Media.Head();
PlexPartResponse part = media.Part.Head();
PlexMediaResponse<PlexXmlPartResponse> media = response.Media.Head();
PlexXmlPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.External,
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Plot = response.Summary,
Year = response.Year,
Tagline = response.Tagline,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList()
};
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.Thumbnail,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
EpisodeMetadata metadata = ProjectToEpisodeMetadata(response, mediaSourceId);
var version = new MediaVersion
{
Name = "Main",
@@ -589,6 +663,67 @@ namespace ErsatzTV.Infrastructure.Plex
return episode;
}
private EpisodeMetadata ProjectToEpisodeMetadata(PlexMetadataResponse response, int mediaSourceId)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.External,
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Plot = response.Summary,
Year = response.Year,
Tagline = response.Tagline,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList(),
Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(),
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList()
};
if (response is PlexXmlMetadataResponse xml)
{
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
}
else
{
metadata.Guids = new List<MetadataGuid>();
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.Thumbnail,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
return metadata;
}
private Actor ProjectToModel(PlexRoleResponse role, DateTime dateAdded, DateTime lastWriteTime)
{
var actor = new Actor { Name = role.Tag, Role = role.Role };
@@ -605,5 +740,32 @@ namespace ErsatzTV.Infrastructure.Plex
return actor;
}
private string NormalizeGuid(string guid)
{
if (guid.StartsWith("plex://show") ||
guid.StartsWith("plex://season") ||
guid.StartsWith("plex://episode") ||
guid.StartsWith("plex://movie"))
{
return guid;
}
if (guid.StartsWith("com.plexapp.agents.thetvdb"))
{
string strip1 = guid.Replace("com.plexapp.agents.thetvdb://", string.Empty);
string strip2 = strip1.Split("?").Head();
return $"tvdb://{strip2}";
}
if (guid.StartsWith("com.plexapp.agents.themoviedb"))
{
string strip1 = guid.Replace("com.plexapp.agents.themoviedb://", string.Empty);
string strip2 = strip1.Split("?").Head();
return $"tmdb://{strip2}";
}
throw new NotSupportedException(guid);
}
}
}
+92 -2
View File
@@ -56,6 +56,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string ShowType = "show";
private const string ArtistType = "artist";
private const string MusicVideoType = "music_video";
private const string EpisodeType = "episode";
private readonly List<CultureInfo> _cultureInfos;
private readonly ILogger<SearchIndex> _logger;
@@ -71,7 +72,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false;
}
public int Version => 12;
public int Version => 13;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -114,6 +115,9 @@ namespace ErsatzTV.Infrastructure.Search
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
case Episode episode:
UpdateEpisode(episode);
break;
}
}
}
@@ -143,6 +147,9 @@ namespace ErsatzTV.Infrastructure.Search
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
case Episode episode:
UpdateEpisode(episode);
break;
}
}
@@ -577,6 +584,83 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateEpisode(Episode episode)
{
Option<EpisodeMetadata> maybeMetadata = episode.EpisodeMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
EpisodeMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, episode.Id.ToString(), Field.Store.YES),
new StringField(TypeField, EpisodeType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, episode.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
foreach (Actor actor in metadata.Actors)
{
doc.Add(new TextField(ActorField, actor.Name, Field.Store.NO));
}
foreach (Director director in metadata.Directors)
{
doc.Add(new TextField(DirectorField, director.Name, Field.Store.NO));
}
foreach (Writer writer in metadata.Writers)
{
doc.Add(new TextField(WriterField, writer.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, episode.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.Episode = null;
_logger.LogWarning(ex, "Error indexing episode with metadata {@Metadata}", metadata);
}
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField)));
private Query ParseQuery(string searchQuery, QueryParser parser)
@@ -595,7 +679,13 @@ namespace ErsatzTV.Infrastructure.Search
}
private static string GetTitleAndYear(Metadata metadata) =>
$"{metadata.Title}_{metadata.Year}".ToLowerInvariant();
metadata switch
{
EpisodeMetadata em =>
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.Episode.EpisodeNumber}"
.ToLowerInvariant(),
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
};
private static string GetJumpLetter(Metadata metadata)
{
+11 -2
View File
@@ -1,8 +1,9 @@
@page "/media/emby"
@using ErsatzTV.Core.Interfaces.Emby
@using ErsatzTV.Application.Emby.Queries
@using ErsatzTV.Application.Emby.Commands
@using ErsatzTV.Application.Emby.Queries
@inject IEmbySecretStore _embySecretStore
@inject ChannelWriter<IEmbyBackgroundServiceRequest> _channel
<RemoteMediaSources
TViewModel="ErsatzTV.Application.Emby.EmbyMediaSourceViewModel"
@@ -11,4 +12,12 @@
Name="Emby"
GetAllMediaSourcesCommand="@(new GetAllEmbyMediaSources())"
DisconnectCommand="@(new DisconnectEmby())"
SecretStore="@_embySecretStore"/>
RefreshLibrariesCommand="@(mediaSourceId => RefreshLibraries(mediaSourceId))"
SecretStore="@_embySecretStore"/>
@code {
private async Task RefreshLibraries(int mediaSourceId) =>
await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSourceId));
}
+166
View File
@@ -0,0 +1,166 @@
@page "/media/tv/episodes"
@page "/media/tv/episodes/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<EpisodeList>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</div>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<FragmentLetterAnchor TCard="TelevisionEpisodeCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link="@($"/media/tv/seasons/{context.SeasonId}#episode-{context.EpisodeId}")"
Subtitle="@($"{context.ShowTitle} - S{context.Season} E{context.Episode}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/tv/episodes"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private TelevisionEpisodeCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
return RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:episode" : $"type:episode AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexEpisodes(searchQuery, PageNumber, PageSize));
}
private void PrevPage()
{
var uri = $"/media/tv/episodes/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
_navigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"/media/tv/episodes/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
_navigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
<MarkdownView Content="@_releaseNotes"/>
</MudCardContent>
</MudCard>
<MudText Class="mt-6">Additional release notes are available on <MudLink Href="https://github.com/jasongdove/ErsatzTV/releases">GitHub</MudLink></MudText>
<MudText Class="mt-6">Full changelog is available on <MudLink Href="https://github.com/jasongdove/ErsatzTV/blob/main/CHANGELOG.md">GitHub</MudLink></MudText>
</MudContainer>
@code {

Some files were not shown because too many files have changed in this diff Show More