Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8631a1f12 | ||
|
|
c70f153241 | ||
|
|
eee10dee22 | ||
|
|
9f575dbd94 | ||
|
|
539285d81e | ||
|
|
f8c986472a | ||
|
|
442d73150e | ||
|
|
d6cee14143 | ||
|
|
c20c0b231e | ||
|
|
e506dd38a8 | ||
|
|
bbd8bc6c7e |
+435
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2862
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2886
File diff suppressed because it is too large
Load Diff
+75
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2889
File diff suppressed because it is too large
Load Diff
+24
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user