Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bec9c5f07 | ||
|
|
0ef03d66f3 | ||
|
|
10c422a3eb | ||
|
|
6c867d0d51 | ||
|
|
ed0796ad58 | ||
|
|
49109ac121 | ||
|
|
3e3bbcf38e | ||
|
|
ce9ef72799 | ||
|
|
f8631a1f12 | ||
|
|
c70f153241 | ||
|
|
eee10dee22 | ||
|
|
9f575dbd94 | ||
|
|
539285d81e | ||
|
|
f8c986472a | ||
|
|
442d73150e | ||
|
|
d6cee14143 | ||
|
|
c20c0b231e | ||
|
|
e506dd38a8 | ||
|
|
bbd8bc6c7e |
+452
@@ -0,0 +1,452 @@
|
||||
# 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.43-prealpha] - 2021-06-05
|
||||
### Added
|
||||
- Support `(Part #)` name suffixes for multi-part episode grouping
|
||||
- Support multi-episode files in local and Plex libraries
|
||||
- Save Channels table page size
|
||||
- Add optional query string parameter to M3U channel playlist to allow some customization per client
|
||||
- `?mode=ts` will force `MPEG-TS` mode for all channels
|
||||
- `?mode=hls-direct` will force `HLS Direct` mode for all channels
|
||||
- `?mode=mixed` or no parameter will maintain existing behavior
|
||||
|
||||
### Changed
|
||||
- Rename channel mode `TransportStream` to `MPEG-TS` and `HttpLiveStreaming` to `HLS Direct`
|
||||
|
||||
### Fixed
|
||||
- Fix search result crashes due to missing season metadata
|
||||
|
||||
## [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.43-prealpha...HEAD
|
||||
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...v0.0.43-prealpha
|
||||
[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
|
||||
@@ -3,5 +3,5 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public record GetChannelPlaylist(string Scheme, string Host) : IRequest<ChannelPlaylist>;
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using LanguageExt;
|
||||
@@ -16,6 +18,31 @@ namespace ErsatzTV.Application.Channels.Queries
|
||||
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
var result = new List<Channel>();
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
switch (mode.ToLowerInvariant())
|
||||
{
|
||||
case "hls-direct":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "ts":
|
||||
channel.StreamingMode = StreamingMode.TransportStream;
|
||||
result.Add(channel);
|
||||
break;
|
||||
default:
|
||||
result.Add(channel);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 @@ namespace ErsatzTV.Application.MediaCards
|
||||
() => string.Empty),
|
||||
episodeMetadata.Episode.Season.ShowId,
|
||||
episodeMetadata.Episode.SeasonId,
|
||||
episodeMetadata.Episode.EpisodeNumber,
|
||||
episodeMetadata.Episode.Season.SeasonNumber,
|
||||
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, () => 0),
|
||||
episodeMetadata.Title,
|
||||
episodeMetadata.SortTitle,
|
||||
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Plot ?? string.Empty,
|
||||
() => string.Empty),
|
||||
GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby));
|
||||
isSearchResult
|
||||
? GetEpisodePoster(episodeMetadata, 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 +108,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()))
|
||||
@@ -136,6 +143,24 @@ namespace ErsatzTV.Application.MediaCards
|
||||
private static string GetSeasonName(int number) =>
|
||||
number == 0 ? "Specials" : $"Season {number}";
|
||||
|
||||
private static string GetEpisodePoster(
|
||||
EpisodeMetadata episodeMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby)
|
||||
{
|
||||
Option<SeasonMetadata> maybeSeasonMetadata = episodeMetadata.Episode.Season.SeasonMetadata.HeadOrNone();
|
||||
return maybeSeasonMetadata.Match(
|
||||
seasonMetadata => GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
|
||||
() =>
|
||||
{
|
||||
Option<ShowMetadata> maybeShowMetadata =
|
||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone();
|
||||
return maybeShowMetadata.Match(
|
||||
showMetadata => GetPoster(showMetadata, maybeJellyfin, maybeEmby),
|
||||
() => string.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetPoster(
|
||||
Metadata metadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts
|
||||
@@ -31,9 +32,16 @@ namespace ErsatzTV.Application.Playouts
|
||||
case Episode e:
|
||||
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
|
||||
return e.EpisodeMetadata.HeadOrNone()
|
||||
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
|
||||
.IfNone("[unknown episode]");
|
||||
var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList();
|
||||
var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList();
|
||||
if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
|
||||
{
|
||||
return "[unknown episode]";
|
||||
}
|
||||
|
||||
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
|
||||
var titlesString = $"{string.Join('/', episodeTitles)}";
|
||||
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
|
||||
case Movie m:
|
||||
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
|
||||
case MusicVideo mv:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
|
||||
private async Task<Validation<BaseError, Channel>> ChannelMustExist(T request) =>
|
||||
(await _channelRepository.GetByNumber(request.ChannelNumber))
|
||||
.Map(
|
||||
channel =>
|
||||
{
|
||||
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
|
||||
{
|
||||
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
|
||||
"ts" => StreamingMode.TransportStream,
|
||||
_ => channel.StreamingMode
|
||||
};
|
||||
|
||||
return channel;
|
||||
})
|
||||
.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.");
|
||||
|
||||
private Task<Validation<BaseError, string>> FFmpegPathMustExist() =>
|
||||
|
||||
@@ -5,5 +5,5 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record FFmpegProcessRequest(string ChannelNumber) : IRequest<Either<BaseError, Process>>;
|
||||
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
{
|
||||
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(channelNumber)
|
||||
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
|
||||
channelNumber,
|
||||
"ts")
|
||||
{
|
||||
Scheme = scheme;
|
||||
Host = host;
|
||||
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Serilog;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
@@ -14,14 +15,17 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
GetHlsPlaylistByChannelNumberHandler : IRequestHandler<GetHlsPlaylistByChannelNumber, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
|
||||
public GetHlsPlaylistByChannelNumberHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IPlayoutRepository playoutRepository)
|
||||
IPlayoutRepository playoutRepository,
|
||||
IMemoryCache memoryCache)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_playoutRepository = playoutRepository;
|
||||
_memoryCache = memoryCache;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
@@ -40,12 +44,15 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
return maybePlayoutItem.Match<Either<BaseError, string>>(
|
||||
playoutItem =>
|
||||
{
|
||||
double timeRemaining = Math.Abs((playoutItem.Finish - now).TotalSeconds);
|
||||
long index = GetIndexForChannel(channel, playoutItem);
|
||||
double timeRemaining = Math.Abs((playoutItem.FinishOffset - now).TotalSeconds);
|
||||
return $@"#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:18000
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-MEDIA-SEQUENCE:{index}
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:{timeRemaining:F2},
|
||||
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}
|
||||
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}?index={index}&mode=hls-direct
|
||||
";
|
||||
},
|
||||
() =>
|
||||
@@ -59,5 +66,36 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
private async Task<Validation<BaseError, Channel>> ChannelMustExist(GetHlsPlaylistByChannelNumber request) =>
|
||||
(await _channelRepository.GetByNumber(request.ChannelNumber))
|
||||
.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.");
|
||||
|
||||
private long GetIndexForChannel(Channel channel, PlayoutItem playoutItem)
|
||||
{
|
||||
long ticks = playoutItem.Start.Ticks;
|
||||
var key = new ChannelIndexKey(channel.Id);
|
||||
|
||||
long index;
|
||||
if (_memoryCache.TryGetValue(key, out ChannelIndexRecord channelRecord))
|
||||
{
|
||||
if (channelRecord.StartTicks == ticks)
|
||||
{
|
||||
index = channelRecord.Index;
|
||||
}
|
||||
else
|
||||
{
|
||||
index = channelRecord.Index + 1;
|
||||
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
index = 1;
|
||||
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private record ChannelIndexKey(int ChannelId);
|
||||
|
||||
private record ChannelIndexRecord(long StartTicks, long Index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetPlayoutItemProcessByChannelNumber(string channelNumber) : base(channelNumber)
|
||||
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,14 +57,18 @@ namespace ErsatzTV.Application.Television
|
||||
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby))
|
||||
.IfNone(string.Empty));
|
||||
|
||||
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
|
||||
new(
|
||||
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode)
|
||||
{
|
||||
Option<EpisodeMetadata> maybeMetadata = episode.EpisodeMetadata.HeadOrNone();
|
||||
|
||||
return new TelevisionEpisodeViewModel(
|
||||
episode.Season.ShowId,
|
||||
episode.SeasonId,
|
||||
episode.EpisodeNumber,
|
||||
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
|
||||
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
|
||||
maybeMetadata.Map(em => em.EpisodeNumber).IfNone(0),
|
||||
maybeMetadata.Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
|
||||
maybeMetadata.Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
maybeMetadata.Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
|
||||
}
|
||||
|
||||
private static string GetPoster(
|
||||
Metadata metadata,
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
@@ -76,7 +76,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegProfile ffmpegProfile = TestProfile();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
@@ -112,7 +112,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegProfile ffmpegProfile = TestProfile();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
@@ -151,7 +151,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegProfile ffmpegProfile = TestProfile();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
@@ -317,7 +317,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
@@ -426,7 +426,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
@@ -718,7 +718,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
|
||||
@@ -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,15 +86,14 @@ 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<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Show show) => throw new NotSupportedException();
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Season season) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using FluentAssertions;
|
||||
@@ -16,35 +15,33 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
private FallbackMetadataProvider _fallbackMetadataProvider;
|
||||
|
||||
[Test]
|
||||
[TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - s1e2.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - S1E2.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
|
||||
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
|
||||
[TestCase("Awesome Show - s01e02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01E02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s1e2.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S1E2.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", 1, 2)]
|
||||
[TestCase(
|
||||
"Awesome Show (2021) - S01E02 - Description; More Description (1080p QUALITY codec GROUP).mkv",
|
||||
"Awesome Show (2021)",
|
||||
1,
|
||||
2)]
|
||||
[TestCase(
|
||||
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
|
||||
"Awesome.Show",
|
||||
1,
|
||||
2)]
|
||||
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode)
|
||||
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, int season, int episode)
|
||||
{
|
||||
(EpisodeMetadata metadata, int episodeNumber) = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
new Episode
|
||||
{
|
||||
LibraryPath = new LibraryPath(),
|
||||
@@ -60,10 +57,41 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
}
|
||||
});
|
||||
|
||||
metadata.Title.Should().Be(title);
|
||||
metadata.Count.Should().Be(1);
|
||||
// TODO: how can we test season number? do we need to?
|
||||
// metadata.Season.Should().Be(season);
|
||||
episodeNumber.Should().Be(episode);
|
||||
metadata.Head().EpisodeNumber.Should().Be(episode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("Awesome Show - s01e02-s01e03.mkv", 1, 2, 3)]
|
||||
[TestCase("Awesome Show - s01e02-whatever-s01e03-whatever2.mkv", 1, 2, 3)]
|
||||
[TestCase("Awesome Show - s01e02e03.mkv", 1, 2, 3)]
|
||||
[TestCase("Awesome Show - s01e02-03.mkv", 1, 2, 3)]
|
||||
public void GetFallbackMetadata_Should_Handle_Two_Episode_Formats(
|
||||
string path,
|
||||
int season,
|
||||
int episode1,
|
||||
int episode2)
|
||||
{
|
||||
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
new Episode
|
||||
{
|
||||
LibraryPath = new LibraryPath(),
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
metadata.Count.Should().Be(2);
|
||||
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata.Nfo
|
||||
{
|
||||
[TestFixture]
|
||||
public class EpisodeNfoReaderTests
|
||||
{
|
||||
[Test]
|
||||
public async Task One()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Two()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<showtitle>show</showtitle>
|
||||
<title>episode-one</title>
|
||||
<episode>1</episode>
|
||||
<season>1</season>
|
||||
</episodedetails>
|
||||
<episodedetails>
|
||||
<showtitle>show</showtitle>
|
||||
<title>episode-two</title>
|
||||
<episode>2</episode>
|
||||
<season>1</season>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(2);
|
||||
result.All(nfo => nfo.ShowTitle == "show").Should().BeTrue();
|
||||
result.All(nfo => nfo.Season == 1).Should().BeTrue();
|
||||
result.Count(nfo => nfo.Title == "episode-one" && nfo.Episode == 1).Should().Be(1);
|
||||
result.Count(nfo => nfo.Title == "episode-two" && nfo.Episode == 2).Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UniqueIds()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<uniqueid default=""true"" type=""tvdb"">12345</uniqueid>
|
||||
<uniqueid default=""false"" type=""imdb"">tt54321</uniqueid>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(1);
|
||||
result[0].UniqueIds.Count.Should().Be(2);
|
||||
result[0].UniqueIds.Count(id => id.Default && id.Type == "tvdb" && id.Guid == "12345").Should().Be(1);
|
||||
result[0].UniqueIds.Count(id => !id.Default && id.Type == "imdb" && id.Guid == "tt54321").Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task No_ContentRating()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<mpaa/>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(1);
|
||||
result[0].ContentRating.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ContentRating()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<mpaa>US:Something</mpaa>
|
||||
</episodedetails>
|
||||
<episodedetails>
|
||||
<mpaa>US:Something / US:SomethingElse</mpaa>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(2);
|
||||
result.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1);
|
||||
result.Count(nfo => nfo.ContentRating == "US:Something / US:SomethingElse").Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task No_Plot()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<plot/>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(1);
|
||||
result[0].Plot.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Plot()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<plot>Some Plot</plot>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(1);
|
||||
result[0].Plot.Should().Be("Some Plot");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Actors()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<actor>
|
||||
<name>Name 1</name>
|
||||
<role>Role 1</role>
|
||||
<thumb>Thumb 1</thumb>
|
||||
</actor>
|
||||
<actor>
|
||||
<name>Name 2</name>
|
||||
<role>Role 2</role>
|
||||
<thumb>Thumb 2</thumb>
|
||||
</actor>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(1);
|
||||
result[0].Actors.Count.Should().Be(2);
|
||||
result[0].Actors.Count(a => a.Name == "Name 1" && a.Role == "Role 1" && a.Thumb == "Thumb 1")
|
||||
.Should().Be(1);
|
||||
result[0].Actors.Count(a => a.Name == "Name 2" && a.Role == "Role 2" && a.Thumb == "Thumb 2")
|
||||
.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Writers()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<credits>Writer 1</credits>
|
||||
</episodedetails>
|
||||
<episodedetails>
|
||||
<credits>Writer 2</credits>
|
||||
<credits>Writer 3</credits>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(2);
|
||||
result.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1);
|
||||
result.Count(nfo => nfo.Writers.Count == 2 && nfo.Writers[0] == "Writer 2" && nfo.Writers[1] == "Writer 3")
|
||||
.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Directors()
|
||||
{
|
||||
var reader = new EpisodeNfoReader();
|
||||
var stream = new MemoryStream(
|
||||
Encoding.UTF8.GetBytes(
|
||||
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||
<!--created on whatever - comment-->
|
||||
<episodedetails>
|
||||
<director>Director 1</director>
|
||||
</episodedetails>
|
||||
<episodedetails>
|
||||
<director>Director 2</director>
|
||||
<director>Director 3</director>
|
||||
</episodedetails>"));
|
||||
|
||||
List<TvShowEpisodeNfo> result = await reader.Read(stream);
|
||||
|
||||
result.Count.Should().Be(2);
|
||||
result.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1);
|
||||
result.Count(
|
||||
nfo => nfo.Directors.Count == 2 && nfo.Directors[0] == "Director 2" &&
|
||||
nfo.Directors[1] == "Director 3")
|
||||
.Should().Be(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,337 @@
|
||||
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")]
|
||||
[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 One", "Episode 2 (II)", "Episode 3")]
|
||||
[TestCase("Episode 1 (1)", "Episode 2 (Part 2)", "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 (5)", "Episode 2 - (6)", "Episode 3")]
|
||||
[TestCase("Episode 1 Part 5", "Episode 2 Part 6", "Episode 3 - After")]
|
||||
[TestCase("Episode 1 Part (V)", "Episode 2 (VI)", "Episode 3")]
|
||||
[TestCase("Episode 1 (Part 5)", "Episode 2 (Part 6)", "Episode 3")]
|
||||
public void Only_Later_Parts(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")]
|
||||
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 - After")]
|
||||
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3")]
|
||||
[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(one, 1, 1, 1),
|
||||
NamedEpisode(two, 1, 1, 2),
|
||||
NamedEpisode(three, 1, 1, 3)
|
||||
};
|
||||
|
||||
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")]
|
||||
[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")]
|
||||
[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")]
|
||||
[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")]
|
||||
[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")]
|
||||
[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")]
|
||||
[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")]
|
||||
[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")]
|
||||
[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()
|
||||
{
|
||||
EpisodeMetadata = new List<EpisodeMetadata>
|
||||
{
|
||||
new() { Title = title }
|
||||
new() { Title = title, ReleaseDate = releaseDate, EpisodeNumber = episode }
|
||||
},
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace ErsatzTV.Core.Domain
|
||||
[DebuggerDisplay("{EpisodeMetadata[0].Title}")]
|
||||
public class Episode : MediaItem
|
||||
{
|
||||
public int EpisodeNumber { get; set; }
|
||||
public int SeasonId { get; set; }
|
||||
public Season Season { get; set; }
|
||||
public List<EpisodeMetadata> EpisodeMetadata { get; set; }
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class EpisodeMetadata : Metadata
|
||||
{
|
||||
public int EpisodeNumber { get; set; }
|
||||
public string Outline { get; set; }
|
||||
public string Plot { get; set; }
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
public enum StreamingMode
|
||||
{
|
||||
TransportStream = 1,
|
||||
HttpLiveStreaming = 2
|
||||
HttpLiveStreamingDirect = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,13 +335,19 @@ namespace ErsatzTV.Core.Emby
|
||||
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeNumber);
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
updateStatistics = true;
|
||||
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)
|
||||
{
|
||||
@@ -377,9 +370,12 @@ namespace ErsatzTV.Core.Emby
|
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeNumber);
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
await _televisionRepository.AddEpisode(incoming);
|
||||
if (await _televisionRepository.AddEpisode(incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -64,7 +64,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
switch (streamingMode)
|
||||
{
|
||||
case StreamingMode.HttpLiveStreaming:
|
||||
case StreamingMode.HttpLiveStreamingDirect:
|
||||
result.AudioCodec = "copy";
|
||||
result.VideoCodec = "copy";
|
||||
result.Deinterlace = false;
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
|
||||
{
|
||||
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming &&
|
||||
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
|
||||
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace ErsatzTV.Core.Hdhr
|
||||
|
||||
public string URL => _channel.StreamingMode switch
|
||||
{
|
||||
StreamingMode.HttpLiveStreaming => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
|
||||
StreamingMode.HttpLiveStreamingDirect => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
|
||||
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
ShowMetadata GetFallbackMetadataForShow(string showFolder);
|
||||
ArtistMetadata GetFallbackMetadataForArtist(string artistFolder);
|
||||
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
|
||||
List<EpisodeMetadata> GetFallbackMetadata(Episode episode);
|
||||
MovieMetadata GetFallbackMetadata(Movie movie);
|
||||
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
|
||||
string GetSortTitle(string title);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo
|
||||
{
|
||||
public interface IEpisodeNfoReader
|
||||
{
|
||||
Task<List<TvShowEpisodeNfo>> Read(Stream input);
|
||||
}
|
||||
}
|
||||
@@ -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<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
|
||||
Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
|
||||
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
|
||||
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,8 @@ namespace ErsatzTV.Core.Iptv
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
int e = episode.EpisodeNumber;
|
||||
// TODO: multi-episode?
|
||||
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
|
||||
if (s > 0 && e > 0)
|
||||
{
|
||||
xml.WriteStartElement("episode-num");
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
|
||||
string format = channel.StreamingMode switch
|
||||
{
|
||||
StreamingMode.HttpLiveStreaming => "m3u8",
|
||||
StreamingMode.HttpLiveStreamingDirect => "m3u8",
|
||||
_ => "ts"
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,13 +336,19 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeNumber);
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
updateStatistics = true;
|
||||
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)
|
||||
{
|
||||
@@ -377,9 +371,12 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeNumber);
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
await _televisionRepository.AddEpisode(incoming);
|
||||
if (await _televisionRepository.AddEpisode(incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -26,13 +26,28 @@ namespace ErsatzTV.Core.Metadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
|
||||
}
|
||||
|
||||
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
|
||||
public List<EpisodeMetadata> GetFallbackMetadata(Episode episode)
|
||||
{
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new EpisodeMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow };
|
||||
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
|
||||
var baseMetadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
EpisodeNumber = 0,
|
||||
Actors = new List<Actor>(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Directors = new List<Director>(),
|
||||
Genres = new List<Genre>(),
|
||||
Guids = new List<MetadataGuid>(),
|
||||
Studios = new List<Studio>(),
|
||||
Tags = new List<Tag>(),
|
||||
Writers = new List<Writer>()
|
||||
};
|
||||
return fileName != null
|
||||
? GetEpisodeMetadata(fileName, baseMetadata)
|
||||
: new List<EpisodeMetadata> { baseMetadata };
|
||||
}
|
||||
|
||||
public MovieMetadata GetFallbackMetadata(Movie movie)
|
||||
@@ -87,18 +102,47 @@ namespace ErsatzTV.Core.Metadata
|
||||
return title;
|
||||
}
|
||||
|
||||
private Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
|
||||
private static List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
|
||||
{
|
||||
var result = new List<EpisodeMetadata>();
|
||||
|
||||
try
|
||||
{
|
||||
const string PATTERN = @"^(.*?)[.\s-]+[sS](\d+)[eE](\d+).*\.\w+$";
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
const string PATTERN = @"[sS]\d+[eE]([e\-\d{1,2}]+)";
|
||||
MatchCollection matches = Regex.Matches(fileName, PATTERN);
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.Actors = new List<Actor>();
|
||||
return Tuple(metadata, int.Parse(match.Groups[3].Value));
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
string[] split = match.Groups[1].Value.Replace('e', '-').Split('-');
|
||||
foreach (string ep in split)
|
||||
{
|
||||
if (!int.TryParse(ep, out int episodeNumber))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
EpisodeNumber = episodeNumber,
|
||||
DateAdded = baseMetadata.DateAdded,
|
||||
DateUpdated = baseMetadata.DateAdded,
|
||||
Actors = new List<Actor>(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Directors = new List<Director>(),
|
||||
Genres = new List<Genre>(),
|
||||
Guids = new List<MetadataGuid>(),
|
||||
Studios = new List<Studio>(),
|
||||
Tags = new List<Tag>(),
|
||||
Writers = new List<Writer>()
|
||||
};
|
||||
|
||||
result.Add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -106,7 +150,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
// ignored
|
||||
}
|
||||
|
||||
return Tuple(metadata, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using System.Xml.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using LanguageExt;
|
||||
@@ -17,11 +18,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
|
||||
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
|
||||
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
|
||||
private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo));
|
||||
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IEpisodeNfoReader _episodeNfoReader;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalMetadataProvider> _logger;
|
||||
@@ -39,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IEpisodeNfoReader episodeNfoReader,
|
||||
ILogger<LocalMetadataProvider> logger)
|
||||
{
|
||||
_metadataRepository = metadataRepository;
|
||||
@@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
_episodeNfoReader = episodeNfoReader;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -110,10 +113,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
() => Task.FromResult(false)));
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
|
||||
LoadEpisodeMetadata(episode, nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(episode, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
|
||||
LoadArtistMetadata(nfoFileName).Bind(
|
||||
@@ -174,58 +174,138 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
|
||||
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
|
||||
{
|
||||
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
|
||||
if (episode.EpisodeNumber != episodeNumber)
|
||||
var updated = false;
|
||||
|
||||
episode.EpisodeMetadata ??= new List<EpisodeMetadata>();
|
||||
|
||||
var toUpdate = episode.EpisodeMetadata
|
||||
.Where(em => episodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
|
||||
.ToList();
|
||||
var toRemove = episode.EpisodeMetadata.Except(toUpdate).ToList();
|
||||
var toAdd = episodeMetadata
|
||||
.Where(em => episode.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
|
||||
.ToList();
|
||||
|
||||
foreach (EpisodeMetadata metadata in toRemove)
|
||||
{
|
||||
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
|
||||
await _televisionRepository.RemoveMetadata(episode, metadata);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
foreach (EpisodeMetadata metadata in toAdd)
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.EpisodeId = episode.Id;
|
||||
metadata.Episode = episode;
|
||||
episode.EpisodeMetadata.Add(metadata);
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
updated = await _metadataRepository.Add(metadata) || updated;
|
||||
}
|
||||
|
||||
foreach (EpisodeMetadata metadata in toUpdate)
|
||||
{
|
||||
Option<EpisodeMetadata> maybeExisting =
|
||||
episode.EpisodeMetadata.Find(em => em.EpisodeNumber == metadata.EpisodeNumber);
|
||||
updated = await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
existing.ReleaseDate = metadata.ReleaseDate;
|
||||
existing.Year = metadata.Year;
|
||||
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
bool updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
(_, _) => Task.FromResult(false),
|
||||
(_, _) => Task.FromResult(false),
|
||||
(_, _) => Task.FromResult(false),
|
||||
_televisionRepository.AddActor);
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
existing.ReleaseDate = metadata.ReleaseDate;
|
||||
existing.Year = metadata.Year;
|
||||
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.EpisodeId = episode.Id;
|
||||
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
|
||||
updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
(_, _) => Task.FromResult(false),
|
||||
(_, _) => Task.FromResult(false),
|
||||
(_, _) => Task.FromResult(false),
|
||||
_televisionRepository.AddActor) || updated;
|
||||
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return 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;
|
||||
},
|
||||
() => Task.FromResult(updated)) || updated;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
|
||||
@@ -261,17 +341,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 +361,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 +380,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 +445,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 +642,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);
|
||||
@@ -562,31 +685,45 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
|
||||
private async Task<List<EpisodeMetadata>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
|
||||
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>(
|
||||
nfo =>
|
||||
{
|
||||
DateTime dateAdded = DateTime.UtcNow;
|
||||
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
|
||||
List<TvShowEpisodeNfo> nfos = await _episodeNfoReader.Read(fileStream);
|
||||
var result = new List<EpisodeMetadata>();
|
||||
foreach (TvShowEpisodeNfo nfo in nfos)
|
||||
{
|
||||
DateTime dateAdded = DateTime.UtcNow;
|
||||
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
|
||||
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = dateUpdated,
|
||||
Title = nfo.Title,
|
||||
ReleaseDate = GetAired(0, nfo.Aired),
|
||||
Plot = nfo.Plot,
|
||||
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
|
||||
};
|
||||
return Tuple(metadata, nfo.Episode);
|
||||
},
|
||||
None);
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = dateUpdated,
|
||||
Title = nfo.Title,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(nfo.Title),
|
||||
EpisodeNumber = nfo.Episode,
|
||||
Year = GetYear(0, nfo.Aired),
|
||||
ReleaseDate = GetAired(0, nfo.Aired),
|
||||
Plot = nfo.Plot,
|
||||
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(),
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
result.Add(metadata);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -624,7 +761,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);
|
||||
@@ -772,7 +912,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
return updated;
|
||||
}
|
||||
|
||||
private List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
|
||||
private static List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
|
||||
{
|
||||
var result = new List<Actor>();
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo
|
||||
{
|
||||
public class EpisodeNfoReader : IEpisodeNfoReader
|
||||
{
|
||||
public async Task<List<TvShowEpisodeNfo>> Read(Stream input)
|
||||
{
|
||||
var result = new List<TvShowEpisodeNfo>();
|
||||
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
TvShowEpisodeNfo nfo = null;
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
switch (reader.NodeType)
|
||||
{
|
||||
case XmlNodeType.Element:
|
||||
switch (reader.Name.ToLowerInvariant())
|
||||
{
|
||||
case "episodedetails":
|
||||
nfo = new TvShowEpisodeNfo
|
||||
{
|
||||
UniqueIds = new List<UniqueIdNfo>(),
|
||||
Actors = new List<ActorNfo>(),
|
||||
Writers = new List<string>(),
|
||||
Directors = new List<string>()
|
||||
};
|
||||
break;
|
||||
case "title":
|
||||
await ReadTitle(reader, nfo);
|
||||
break;
|
||||
case "showtitle":
|
||||
await ReadShowTitle(reader, nfo);
|
||||
break;
|
||||
case "episode":
|
||||
await ReadEpisode(reader, nfo);
|
||||
break;
|
||||
case "season":
|
||||
await ReadSeason(reader, nfo);
|
||||
break;
|
||||
case "uniqueid":
|
||||
await ReadUniqueId(reader, nfo);
|
||||
break;
|
||||
case "mpaa":
|
||||
await ReadContentRating(reader, nfo);
|
||||
break;
|
||||
case "aired":
|
||||
// TODO: parse the date here
|
||||
await ReadAired(reader, nfo);
|
||||
break;
|
||||
case "plot":
|
||||
await ReadPlot(reader, nfo);
|
||||
break;
|
||||
case "actor":
|
||||
ReadActor(reader, nfo);
|
||||
break;
|
||||
case "credits":
|
||||
await ReadWriter(reader, nfo);
|
||||
break;
|
||||
case "director":
|
||||
await ReadDirector(reader, nfo);
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case XmlNodeType.EndElement:
|
||||
switch (reader.Name.ToLowerInvariant())
|
||||
{
|
||||
case "episodedetails":
|
||||
if (nfo != null)
|
||||
{
|
||||
result.Add(nfo);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task ReadTitle(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
nfo.Title = await reader.ReadElementContentAsStringAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadShowTitle(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
nfo.ShowTitle = await reader.ReadElementContentAsStringAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadEpisode(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int episode);
|
||||
nfo.Episode = episode;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadSeason(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int season);
|
||||
nfo.Season = season;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadUniqueId(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
var uniqueId = new UniqueIdNfo();
|
||||
reader.MoveToAttribute("default");
|
||||
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
|
||||
reader.MoveToAttribute("type");
|
||||
uniqueId.Type = reader.Value;
|
||||
reader.MoveToElement();
|
||||
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
|
||||
|
||||
nfo.UniqueIds.Add(uniqueId);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadContentRating(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
nfo.ContentRating = await reader.ReadElementContentAsStringAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadAired(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
nfo.Aired = await reader.ReadElementContentAsStringAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadPlot(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
nfo.Plot = await reader.ReadElementContentAsStringAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadActor(XmlReader reader, TvShowEpisodeNfo nfo)
|
||||
{
|
||||
if (nfo != null)
|
||||
{
|
||||
var actor = new ActorNfo();
|
||||
var element = (XElement) XNode.ReadFrom(reader);
|
||||
|
||||
XElement name = element.Element("name");
|
||||
if (name != null)
|
||||
{
|
||||
actor.Name = name.Value;
|
||||
}
|
||||
|
||||
XElement role = element.Element("role");
|
||||
if (role != null)
|
||||
{
|
||||
actor.Role = role.Value;
|
||||
}
|
||||
|
||||
XElement thumb = element.Element("thumb");
|
||||
if (thumb != null)
|
||||
{
|
||||
actor.Thumb = thumb.Value;
|
||||
}
|
||||
|
||||
nfo.Actors.Add(actor);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadWriter(XmlReader reader, TvShowEpisodeNfo nfo) =>
|
||||
nfo?.Writers.Add(await reader.ReadElementContentAsStringAsync());
|
||||
|
||||
private static async Task ReadDirector(XmlReader reader, TvShowEpisodeNfo nfo) =>
|
||||
nfo?.Directors.Add(await reader.ReadElementContentAsStringAsync());
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<TelevisionFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
@@ -49,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
_localFileSystem = localFileSystem;
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
@@ -169,6 +171,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
Either<BaseError, Season> maybeSeason = await _televisionRepository
|
||||
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
|
||||
.BindT(EnsureMetadataExists)
|
||||
.BindT(season => UpdatePoster(season, seasonFolder));
|
||||
|
||||
await maybeSeason.Match(
|
||||
@@ -209,10 +212,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;
|
||||
}
|
||||
|
||||
@@ -260,8 +273,28 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Episode>> UpdateMetadata(
|
||||
Episode episode)
|
||||
private async Task<Either<BaseError, Season>> EnsureMetadataExists(Season season)
|
||||
{
|
||||
season.SeasonMetadata ??= new List<SeasonMetadata>();
|
||||
|
||||
if (!season.SeasonMetadata.Any())
|
||||
{
|
||||
var metadata = new SeasonMetadata
|
||||
{
|
||||
SeasonId = season.Id,
|
||||
Season = season,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
Guids = new List<MetadataGuid>()
|
||||
};
|
||||
|
||||
season.SeasonMetadata.Add(metadata);
|
||||
await _metadataRepository.Add(metadata);
|
||||
}
|
||||
|
||||
return season;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Episode>> UpdateMetadata(Episode episode)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -350,8 +383,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
await LocateThumbnail(episode).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
|
||||
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
|
||||
{
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
|
||||
}
|
||||
});
|
||||
|
||||
return episode;
|
||||
|
||||
@@ -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;
|
||||
@@ -7,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -70,17 +72,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 +219,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 +292,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 +322,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 +376,35 @@ 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 => UpdateMetadata(existing, incoming))
|
||||
.BindT(
|
||||
existing => UpdateStatistics(
|
||||
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 +419,39 @@ namespace ErsatzTV.Core.Plex
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexEpisode>> UpdateMetadata(PlexEpisode existing, PlexEpisode incoming)
|
||||
{
|
||||
var toUpdate = existing.EpisodeMetadata
|
||||
.Where(em => incoming.EpisodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
|
||||
.ToList();
|
||||
var toRemove = existing.EpisodeMetadata.Except(toUpdate).ToList();
|
||||
var toAdd = incoming.EpisodeMetadata
|
||||
.Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
|
||||
.ToList();
|
||||
|
||||
foreach (EpisodeMetadata metadata in toRemove)
|
||||
{
|
||||
await _televisionRepository.RemoveMetadata(existing, metadata);
|
||||
}
|
||||
|
||||
foreach (EpisodeMetadata metadata in toAdd)
|
||||
{
|
||||
metadata.EpisodeId = existing.Id;
|
||||
metadata.Episode = existing;
|
||||
existing.EpisodeMetadata.Add(metadata);
|
||||
|
||||
await _metadataRepository.Add(metadata);
|
||||
}
|
||||
|
||||
// TODO: update existing metadata
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
|
||||
PlexEpisode existing,
|
||||
PlexEpisode incoming,
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
@@ -381,12 +460,39 @@ 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;
|
||||
|
||||
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
|
||||
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
|
||||
foreach (EpisodeMetadata existingMetadata in maybeExisting)
|
||||
{
|
||||
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;
|
||||
@@ -401,13 +507,19 @@ namespace ErsatzTV.Core.Plex
|
||||
|
||||
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(PlexEpisode existing, PlexEpisode incoming)
|
||||
{
|
||||
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
|
||||
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();
|
||||
|
||||
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
||||
foreach (EpisodeMetadata incomingMetadata in incoming.EpisodeMetadata)
|
||||
{
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
Option<EpisodeMetadata> maybeExistingMetadata = existing.EpisodeMetadata
|
||||
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
|
||||
if (maybeExistingMetadata.IsSome)
|
||||
{
|
||||
EpisodeMetadata existingMetadata = maybeExistingMetadata.ValueUnsafe();
|
||||
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
||||
{
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
@@ -65,13 +66,13 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
int episode1 = x switch
|
||||
{
|
||||
Episode e => e.EpisodeNumber,
|
||||
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
int episode2 = y switch
|
||||
{
|
||||
Episode e => e.EpisodeNumber,
|
||||
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -486,7 +522,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
|
||||
return e.EpisodeMetadata.HeadOrNone()
|
||||
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
|
||||
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{em.EpisodeNumber:00} - {em.Title}")
|
||||
.IfNone("[unknown episode]");
|
||||
case Movie m:
|
||||
return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]");
|
||||
|
||||
@@ -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();
|
||||
@@ -372,9 +423,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
episode.Id = existing.Id;
|
||||
|
||||
existing.Etag = episode.Etag;
|
||||
existing.EpisodeNumber = episode.EpisodeNumber;
|
||||
|
||||
// metadata
|
||||
// TODO: multiple metadata?
|
||||
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
|
||||
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
@@ -384,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
metadata.EpisodeNumber = incomingMetadata.EpisodeNumber;
|
||||
|
||||
// thumbnail
|
||||
Artwork incomingThumbnail =
|
||||
@@ -402,6 +454,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 +519,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 +551,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();
|
||||
@@ -372,9 +423,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
episode.Id = existing.Id;
|
||||
|
||||
existing.Etag = episode.Etag;
|
||||
existing.EpisodeNumber = episode.EpisodeNumber;
|
||||
|
||||
// metadata
|
||||
// TODO: multiple metadata?
|
||||
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
|
||||
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
@@ -384,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
metadata.EpisodeNumber = metadata.EpisodeNumber;
|
||||
|
||||
// thumbnail
|
||||
Artwork incomingThumbnail =
|
||||
@@ -402,6 +454,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 +519,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 +551,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,23 @@ 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).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.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,28 @@ 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)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.OrderBy(em => em.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Season>> GetAllSeasons()
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
@@ -187,13 +190,15 @@ 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)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Actors)
|
||||
.ThenInclude(a => a.Artwork)
|
||||
.OrderBy(em => em.Episode.EpisodeNumber)
|
||||
.OrderBy(em => em.EpisodeNumber)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
@@ -225,6 +230,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 +255,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 +282,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 +303,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 +423,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 +442,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 +460,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 +477,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,25 +500,47 @@ 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 });
|
||||
|
||||
public async Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber)
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata)
|
||||
{
|
||||
episode.EpisodeNumber = episodeNumber;
|
||||
episode.EpisodeMetadata.Remove(metadata);
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id",
|
||||
new { EpisodeNumber = episodeNumber, episode.Id });
|
||||
@"DELETE FROM EpisodeMetadata WHERE Id = @MetadataId",
|
||||
new { MetadataId = metadata.Id });
|
||||
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 +679,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
new()
|
||||
{
|
||||
DateAdded = DateTime.UtcNow
|
||||
DateAdded = DateTime.UtcNow,
|
||||
Guids = new List<MetadataGuid>()
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -651,7 +718,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 +741,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 +806,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
foreach (EpisodeMetadata metadata in item.EpisodeMetadata)
|
||||
{
|
||||
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))
|
||||
@@ -509,11 +512,6 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
|
||||
};
|
||||
|
||||
if (item.IndexNumber.HasValue)
|
||||
{
|
||||
episode.EpisodeNumber = item.IndexNumber.Value;
|
||||
}
|
||||
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -540,9 +538,17 @@ 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 (item.IndexNumber.HasValue)
|
||||
{
|
||||
metadata.EpisodeNumber = item.IndexNumber.Value;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = 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))
|
||||
@@ -565,11 +568,6 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
|
||||
};
|
||||
|
||||
if (item.IndexNumber.HasValue)
|
||||
{
|
||||
episode.EpisodeNumber = item.IndexNumber.Value;
|
||||
}
|
||||
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -596,9 +594,17 @@ 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 (item.IndexNumber.HasValue)
|
||||
{
|
||||
metadata.EpisodeNumber = item.IndexNumber.Value;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = 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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user