Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4172074ac4 | ||
|
|
e9889cefd6 | ||
|
|
fc59c9c284 | ||
|
|
0750a0712f | ||
|
|
0365d4c8f8 | ||
|
|
5b36252dd0 | ||
|
|
7d852bc960 | ||
|
|
cdf10b0535 | ||
|
|
f0b429efb5 | ||
|
|
da5148affd | ||
|
|
cec5a09839 | ||
|
|
e20f9be702 | ||
|
|
3bc3faa7c4 | ||
|
|
db24ba84f7 | ||
|
|
8346a02747 | ||
|
|
c3b33c184f | ||
|
|
6bec9c5f07 | ||
|
|
0ef03d66f3 | ||
|
|
10c422a3eb | ||
|
|
6c867d0d51 | ||
|
|
ed0796ad58 | ||
|
|
49109ac121 | ||
|
|
3e3bbcf38e | ||
|
|
ce9ef72799 | ||
|
|
f8631a1f12 | ||
|
|
c70f153241 | ||
|
|
eee10dee22 | ||
|
|
9f575dbd94 | ||
|
|
539285d81e | ||
|
|
f8c986472a | ||
|
|
442d73150e | ||
|
|
d6cee14143 | ||
|
|
c20c0b231e | ||
|
|
e506dd38a8 | ||
|
|
bbd8bc6c7e | ||
|
|
e841c9c53b | ||
|
|
4c78f41c5a | ||
|
|
95cceb95b9 | ||
|
|
58d6f81d2e | ||
|
|
fe5cedfcdc | ||
|
|
0bbed69e85 | ||
|
|
68123a2f9c | ||
|
|
6504ca10a8 | ||
|
|
84770ed250 | ||
|
|
466d33f808 | ||
|
|
8e81d5f197 | ||
|
|
da43e6f7cf | ||
|
|
c9905d0542 | ||
|
|
c9e20e28df | ||
|
|
f9427cac99 | ||
|
|
141a34933d | ||
|
|
0962a1429a | ||
|
|
f8b45ed9db |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2020.3.2",
|
||||
"version": "2021.1.3",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
# 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.45-prealpha] - 2021-06-12
|
||||
### Added
|
||||
- Add experimental `HLS Hybrid` channel mode
|
||||
- Media items are transcoded using the channel's ffmpeg profile and served using HLS
|
||||
- Add optional channel watermark
|
||||
|
||||
### Changed
|
||||
- Remove framerate normalization; it caused more problems than it solved
|
||||
- Include non-US (and unknown) content ratings in XMLTV
|
||||
|
||||
### Fixed
|
||||
- Fix serving channels.m3u with missing content ratings
|
||||
- Fix percent progress indicator for Jellyfin and Emby show library scans
|
||||
|
||||
## [0.0.44-prealpha] - 2021-06-09
|
||||
### Added
|
||||
- Add artists directly to schedules
|
||||
- Include MPAA and VCHIP content ratings in XMLTV guide data
|
||||
- Quickly skip missing files during Plex library scan
|
||||
|
||||
### Fixed
|
||||
- Ignore unsupported plex guids (this prevented some libraries from scanning correctly)
|
||||
- Ignore unsupported STRM files from Jellyfin
|
||||
|
||||
## [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`
|
||||
- Improve `HLS Direct` mode compatibility with Channels DVR Server
|
||||
|
||||
### 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.45-prealpha...HEAD
|
||||
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
|
||||
[0.0.44-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
|
||||
[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
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Artists.Queries
|
||||
{
|
||||
public record GetAllArtists : IRequest<List<NamedMediaItemViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists.Queries
|
||||
{
|
||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
|
||||
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
|
||||
|
||||
public Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_artistRepository.GetAllArtists().Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,13 @@ namespace ErsatzTV.Application.Channels
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode);
|
||||
StreamingMode StreamingMode,
|
||||
ChannelWatermarkMode WatermarkMode,
|
||||
ChannelWatermarkLocation WatermarkLocation,
|
||||
ChannelWatermarkSize WatermarkSize,
|
||||
int WatermarkWidth,
|
||||
int WatermarkHorizontalMargin,
|
||||
int WatermarkVerticalMargin,
|
||||
int WatermarkFrequencyMinutes,
|
||||
int WatermarkDurationSeconds);
|
||||
}
|
||||
|
||||
@@ -12,5 +12,13 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
StreamingMode StreamingMode,
|
||||
ChannelWatermarkMode WatermarkMode,
|
||||
ChannelWatermarkLocation WatermarkLocation,
|
||||
ChannelWatermarkSize WatermarkSize,
|
||||
int WatermarkWidth,
|
||||
int WatermarkHorizontalMargin,
|
||||
int WatermarkVerticalMargin,
|
||||
int WatermarkFrequencyMinutes,
|
||||
int WatermarkDurationSeconds) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
});
|
||||
}
|
||||
|
||||
return new Channel(Guid.NewGuid())
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Name = name,
|
||||
Number = number,
|
||||
@@ -66,6 +66,23 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
Artwork = artwork,
|
||||
PreferredLanguageCode = preferredLanguageCode
|
||||
};
|
||||
|
||||
if (request.WatermarkMode != ChannelWatermarkMode.None)
|
||||
{
|
||||
channel.Watermark = new ChannelWatermark
|
||||
{
|
||||
Mode = request.WatermarkMode,
|
||||
Location = request.WatermarkLocation,
|
||||
Size = request.WatermarkSize,
|
||||
WidthPercent = request.WatermarkWidth,
|
||||
HorizontalMarginPercent = request.WatermarkHorizontalMargin,
|
||||
VerticalMarginPercent = request.WatermarkVerticalMargin,
|
||||
FrequencyMinutes = request.WatermarkFrequencyMinutes,
|
||||
DurationSeconds = request.WatermarkDurationSeconds
|
||||
};
|
||||
}
|
||||
|
||||
return channel;
|
||||
});
|
||||
|
||||
private Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
|
||||
|
||||
@@ -13,5 +13,13 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
StreamingMode StreamingMode,
|
||||
ChannelWatermarkMode WatermarkMode,
|
||||
ChannelWatermarkLocation WatermarkLocation,
|
||||
ChannelWatermarkSize WatermarkSize,
|
||||
int WatermarkWidth,
|
||||
int WatermarkHorizontalMargin,
|
||||
int WatermarkVerticalMargin,
|
||||
int WatermarkFrequencyMinutes,
|
||||
int WatermarkDurationSeconds) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,39 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
});
|
||||
}
|
||||
|
||||
if (update.WatermarkMode == ChannelWatermarkMode.None)
|
||||
{
|
||||
await _channelRepository.RemoveWatermark(c);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c.Watermark != null)
|
||||
{
|
||||
c.Watermark.Mode = update.WatermarkMode;
|
||||
c.Watermark.Location = update.WatermarkLocation;
|
||||
c.Watermark.Size = update.WatermarkSize;
|
||||
c.Watermark.WidthPercent = update.WatermarkWidth;
|
||||
c.Watermark.HorizontalMarginPercent = update.WatermarkHorizontalMargin;
|
||||
c.Watermark.VerticalMarginPercent = update.WatermarkVerticalMargin;
|
||||
c.Watermark.FrequencyMinutes = update.WatermarkFrequencyMinutes;
|
||||
c.Watermark.DurationSeconds = update.WatermarkDurationSeconds;
|
||||
}
|
||||
else
|
||||
{
|
||||
c.Watermark = new ChannelWatermark
|
||||
{
|
||||
Mode = update.WatermarkMode,
|
||||
Location = update.WatermarkLocation,
|
||||
Size = update.WatermarkSize,
|
||||
WidthPercent = update.WatermarkWidth,
|
||||
HorizontalMarginPercent = update.WatermarkHorizontalMargin,
|
||||
VerticalMarginPercent = update.WatermarkVerticalMargin,
|
||||
FrequencyMinutes = update.WatermarkFrequencyMinutes,
|
||||
DurationSeconds = update.WatermarkDurationSeconds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
await _channelRepository.Update(c);
|
||||
return ProjectToViewModel(c);
|
||||
|
||||
@@ -14,7 +14,15 @@ namespace ErsatzTV.Application.Channels
|
||||
channel.FFmpegProfileId,
|
||||
GetLogo(channel),
|
||||
channel.PreferredLanguageCode,
|
||||
channel.StreamingMode);
|
||||
channel.StreamingMode,
|
||||
channel.Watermark?.Mode ?? ChannelWatermarkMode.None,
|
||||
channel.Watermark?.Location ?? ChannelWatermarkLocation.BottomRight,
|
||||
channel.Watermark?.Size ?? ChannelWatermarkSize.Scaled,
|
||||
channel.Watermark?.WidthPercent ?? 15,
|
||||
channel.Watermark?.HorizontalMarginPercent ?? 5,
|
||||
channel.Watermark?.VerticalMarginPercent ?? 5,
|
||||
channel.Watermark?.FrequencyMinutes ?? 15,
|
||||
channel.Watermark?.DurationSeconds ?? 15);
|
||||
|
||||
private static string GetLogo(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnableEmbyLibrarySync(toEnable);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Emby
|
||||
{
|
||||
public record EmbyConnectionParametersViewModel(string Address);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries
|
||||
{
|
||||
public record GetEmbyConnectionParameters : IRequest<Either<BaseError, EmbyConnectionParametersViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries
|
||||
{
|
||||
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
|
||||
Either<BaseError, EmbyConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetEmbyConnectionParametersHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
|
||||
GetEmbyConnectionParameters request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(request, out EmbyConnectionParametersViewModel parameters))
|
||||
{
|
||||
return parameters;
|
||||
}
|
||||
|
||||
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
|
||||
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
||||
|
||||
return maybeParameters.Match(
|
||||
p =>
|
||||
{
|
||||
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
|
||||
return maybeParameters;
|
||||
},
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
EmbyMediaSourceMustExist()
|
||||
.BindT(MediaSourceMustHaveActiveConnection);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
"Emby media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
EmbyMediaSource embyMediaSource)
|
||||
{
|
||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.FirstOrDefault();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
|
||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
EmbyMediaSource EmbyMediaSource,
|
||||
EmbyConnection ActiveConnection);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -21,6 +21,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
NormalizeLoudness = request.NormalizeLoudness,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
FrameRate = request.FrameRate
|
||||
NormalizeAudio = request.NormalizeAudio
|
||||
});
|
||||
|
||||
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
|
||||
@@ -22,6 +22,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.FrameRate = update.FrameRate;
|
||||
await _ffmpegProfileRepository.Update(p);
|
||||
return ProjectToViewModel(p);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
@@ -14,13 +16,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem)
|
||||
ILocalFileSystem localFileSystem,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
@@ -31,8 +36,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
|
||||
(await FFmpegMustExist(request), await FFprobeMustExist(request))
|
||||
.Apply((_, _) => Unit.Default);
|
||||
(await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request))
|
||||
.Apply((_, _, _) => Unit.Default);
|
||||
|
||||
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
|
||||
@@ -40,6 +45,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
|
||||
|
||||
private Validation<BaseError, Unit> ReportsAreNotSupportedOnWindows(UpdateFFmpegSettings request)
|
||||
{
|
||||
if (request.Settings.SaveReports && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return BaseError.New("FFmpeg reports are not supported on Windows");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
|
||||
@@ -20,6 +20,5 @@ namespace ErsatzTV.Application.FFmpegProfiles
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
string FrameRate);
|
||||
bool NormalizeAudio);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
|
||||
profile.NormalizeLoudness,
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeAudio,
|
||||
profile.FrameRate);
|
||||
profile.NormalizeAudio);
|
||||
|
||||
private static ResolutionViewModel Project(Resolution resolution) =>
|
||||
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Images
|
||||
{
|
||||
public record CachedImagePathViewModel(string FileName, string MimeType);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Application.Images
|
||||
{
|
||||
// ReSharper disable once SuggestBaseTypeForParameter
|
||||
public record ImageViewModel(byte[] Contents, string MimeType);
|
||||
}
|
||||
+3
-2
@@ -5,6 +5,7 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Images.Queries
|
||||
{
|
||||
public record GetImageContents
|
||||
(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<Either<BaseError, ImageViewModel>>;
|
||||
public record GetCachedImagePath
|
||||
(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<
|
||||
Either<BaseError, CachedImagePathViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Winista.Mime;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Images.Queries
|
||||
{
|
||||
public class
|
||||
GetCachedImagePathHandler : IRequestHandler<GetCachedImagePath, Either<BaseError, CachedImagePathViewModel>>
|
||||
{
|
||||
private static readonly MimeTypes MimeTypes = new();
|
||||
private readonly IImageCache _imageCache;
|
||||
|
||||
public GetCachedImagePathHandler(IImageCache imageCache) => _imageCache = imageCache;
|
||||
|
||||
public async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
|
||||
GetCachedImagePath request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
MimeType mimeType;
|
||||
|
||||
string cachePath = _imageCache.GetPathForImage(
|
||||
request.FileName,
|
||||
request.ArtworkKind,
|
||||
Optional(request.MaxHeight));
|
||||
if (!File.Exists(cachePath))
|
||||
{
|
||||
if (request.MaxHeight.HasValue)
|
||||
{
|
||||
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
|
||||
byte[] contents = await File.ReadAllBytesAsync(originalPath, cancellationToken);
|
||||
Either<BaseError, byte[]> resizeResult =
|
||||
await _imageCache.ResizeImage(contents, request.MaxHeight.Value);
|
||||
resizeResult.IfRight(result => contents = result);
|
||||
|
||||
string baseFolder = Path.GetDirectoryName(cachePath);
|
||||
if (baseFolder != null && !Directory.Exists(baseFolder))
|
||||
{
|
||||
Directory.CreateDirectory(baseFolder);
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(cachePath, contents, cancellationToken);
|
||||
|
||||
mimeType = new MimeType("image/jpeg");
|
||||
}
|
||||
else
|
||||
{
|
||||
return BaseError.New($"Artwork does not exist on disk at {cachePath}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mimeType = MimeTypes.GetMimeTypeFromFile(cachePath);
|
||||
}
|
||||
|
||||
return new CachedImagePathViewModel(cachePath, mimeType.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Winista.Mime;
|
||||
|
||||
namespace ErsatzTV.Application.Images.Queries
|
||||
{
|
||||
public class GetImageContentsHandler : IRequestHandler<GetImageContents, Either<BaseError, ImageViewModel>>
|
||||
{
|
||||
private static readonly MimeTypes MimeTypes = new();
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetImageContentsHandler(IImageCache imageCache, IMemoryCache memoryCache)
|
||||
{
|
||||
_imageCache = imageCache;
|
||||
_memoryCache = memoryCache;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ImageViewModel>> Handle(
|
||||
GetImageContents request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _memoryCache.GetOrCreateAsync(
|
||||
request.FileName,
|
||||
async entry =>
|
||||
{
|
||||
entry.SlidingExpiration = TimeSpan.FromHours(1);
|
||||
|
||||
string subfolder = request.FileName.Substring(0, 2);
|
||||
string baseFolder = request.ArtworkKind switch
|
||||
{
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
|
||||
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
|
||||
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
|
||||
_ => FileSystemLayout.LegacyImageCacheFolder
|
||||
};
|
||||
|
||||
string fileName = Path.Combine(baseFolder, request.FileName);
|
||||
byte[] contents = await File.ReadAllBytesAsync(fileName, cancellationToken);
|
||||
|
||||
if (request.MaxHeight.HasValue)
|
||||
{
|
||||
Either<BaseError, byte[]> resizeResult = await _imageCache
|
||||
.ResizeImage(contents, request.MaxHeight.Value);
|
||||
resizeResult.IfRight(result => contents = result);
|
||||
}
|
||||
|
||||
MimeType mimeType = MimeTypes.GetMimeType(contents);
|
||||
return new ImageViewModel(contents, mimeType.Name);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnableJellyfinLibrarySync(toEnable);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinConnectionParametersViewModel(string Address);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinConnectionParameters : IRequest<Either<BaseError, JellyfinConnectionParametersViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfinConnectionParameters,
|
||||
Either<BaseError, JellyfinConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetJellyfinConnectionParametersHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, JellyfinConnectionParametersViewModel>> Handle(
|
||||
GetJellyfinConnectionParameters request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(request, out JellyfinConnectionParametersViewModel parameters))
|
||||
{
|
||||
return parameters;
|
||||
}
|
||||
|
||||
Either<BaseError, JellyfinConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
.MapT(cp => new JellyfinConnectionParametersViewModel(cp.ActiveConnection.Address))
|
||||
.Map(v => v.ToEither<JellyfinConnectionParametersViewModel>());
|
||||
|
||||
return maybeParameters.Match(
|
||||
p =>
|
||||
{
|
||||
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
|
||||
return maybeParameters;
|
||||
},
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
JellyfinMediaSourceMustExist()
|
||||
.BindT(MediaSourceMustHaveActiveConnection);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllJellyfin().Map(list => list.HeadOrNone())
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
"Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.FirstOrDefault();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance.Commands
|
||||
{
|
||||
public record DeleteOrphanedArtwork : MediatR.IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance.Commands
|
||||
{
|
||||
public class DeleteOrphanedArtworkHandler : MediatR.IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IArtworkRepository _artworkRepository;
|
||||
|
||||
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
|
||||
_artworkRepository = artworkRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>>
|
||||
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
|
||||
_artworkRepository.GetOrphanedArtwork()
|
||||
.Bind(_artworkRepository.Delete)
|
||||
.Map(_ => Right<BaseError, Unit>(Unit.Default));
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
@@ -122,7 +129,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
|
||||
{
|
||||
artwork = EmbyUrl.ForArtwork(maybeEmby, artwork)
|
||||
.SetQueryParam("fillHeight", 440);
|
||||
.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
|
||||
@@ -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,
|
||||
@@ -152,7 +177,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
else if (maybeEmby.IsSome && poster.StartsWith("emby://"))
|
||||
{
|
||||
poster = EmbyUrl.ForArtwork(maybeEmby, poster)
|
||||
.SetQueryParam("fillHeight", 440);
|
||||
.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
return poster;
|
||||
@@ -174,7 +199,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
else if (maybeEmby.IsSome && thumb.StartsWith("emby://"))
|
||||
{
|
||||
thumb = EmbyUrl.ForArtwork(maybeEmby, thumb)
|
||||
.SetQueryParam("fillHeight", 220);
|
||||
.SetQueryParam("maxHeight", 220);
|
||||
}
|
||||
|
||||
return thumb;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ namespace ErsatzTV.Application.MediaItems
|
||||
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
|
||||
new(mediaItem.Id, mediaItem.LibraryPathId);
|
||||
|
||||
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
|
||||
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
|
||||
|
||||
public static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
|
||||
new(season.Id, $"{ShowTitle(season)} ({SeasonDescription(season)})");
|
||||
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
|
||||
new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???"));
|
||||
|
||||
private static string ShowTitle(Season season) =>
|
||||
season.Show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNone("???");
|
||||
|
||||
|
||||
@@ -26,10 +26,14 @@ namespace ErsatzTV.Application.Movies
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Tags.Map(t => t.Name).ToList(),
|
||||
metadata.Studios.Map(s => s.Name).ToList(),
|
||||
(metadata.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(),
|
||||
LanguagesForMovie(movie),
|
||||
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
|
||||
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
|
||||
.ToList())
|
||||
.ToList(),
|
||||
metadata.Directors.Map(d => d.Name).ToList(),
|
||||
metadata.Writers.Map(w => w.Name).ToList())
|
||||
{
|
||||
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
|
||||
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)
|
||||
@@ -76,7 +80,7 @@ namespace ErsatzTV.Application.Movies
|
||||
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
|
||||
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
||||
{
|
||||
url.SetQueryParam("fillHeight", 440);
|
||||
url.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
artwork = url;
|
||||
|
||||
@@ -11,8 +11,11 @@ namespace ErsatzTV.Application.Movies
|
||||
List<string> Genres,
|
||||
List<string> Tags,
|
||||
List<string> Studios,
|
||||
List<string> ContentRatings,
|
||||
List<CultureInfo> Languages,
|
||||
List<ActorCardViewModel> Actors)
|
||||
List<ActorCardViewModel> Actors,
|
||||
List<string> Directors,
|
||||
List<string> Writers)
|
||||
{
|
||||
public string Poster { get; set; }
|
||||
public string FanArt { get; set; }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _plexSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockPlex();
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -79,6 +79,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
return BaseError.New("[MediaItem] is required for collection type 'TelevisionSeason'");
|
||||
}
|
||||
|
||||
break;
|
||||
case ProgramScheduleItemCollectionType.Artist:
|
||||
if (item.MediaItemId is null)
|
||||
{
|
||||
return BaseError.New("[MediaItem] is required for collection type 'Artist'");
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
return BaseError.New("[CollectionType] is invalid");
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +30,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
Season season => MediaItems.Mapper.ProjectToViewModel(season),
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
duration.PlayoutDuration,
|
||||
@@ -48,6 +50,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
Season season => MediaItems.Mapper.ProjectToViewModel(season),
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
flood.CustomTitle),
|
||||
@@ -65,6 +68,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
Season season => MediaItems.Mapper.ProjectToViewModel(season),
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
multiple.Count,
|
||||
@@ -83,6 +87,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
Season season => MediaItems.Mapper.ProjectToViewModel(season),
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
one.CustomTitle),
|
||||
|
||||
@@ -19,10 +19,12 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
public string Name => CollectionType switch
|
||||
{
|
||||
ProgramScheduleItemCollectionType.Collection => Collection?.Name,
|
||||
ProgramScheduleItemCollectionType
|
||||
.TelevisionShow => MediaItem?.Name, // $"{TelevisionShow?.Title} ({TelevisionShow?.Year})",
|
||||
ProgramScheduleItemCollectionType
|
||||
.TelevisionSeason => MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
|
||||
ProgramScheduleItemCollectionType.TelevisionShow =>
|
||||
MediaItem?.Name, // $"{TelevisionShow?.Title} ({TelevisionShow?.Year})",
|
||||
ProgramScheduleItemCollectionType.TelevisionSeason =>
|
||||
MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
|
||||
ProgramScheduleItemCollectionType.Artist =>
|
||||
MediaItem?.Name,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
@@ -12,15 +14,18 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public GetConcatProcessByChannelNumberHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
FFmpegProcessService ffmpegProcessService)
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
@@ -28,7 +33,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
Channel channel,
|
||||
string ffmpegPath)
|
||||
{
|
||||
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await _configElementRepository
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
return _ffmpegProcessService.ConcatChannel(
|
||||
|
||||
@@ -5,5 +5,5 @@ using MediatR;
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetHlsPlaylistByChannelNumber
|
||||
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, string>>;
|
||||
(string Scheme, string Host, string ChannelNumber, string Mode) : IRequest<Either<BaseError, string>>;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -35,17 +39,24 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
GetHlsPlaylistByChannelNumber request,
|
||||
Channel channel)
|
||||
{
|
||||
string mode = string.IsNullOrWhiteSpace(request.Mode)
|
||||
? string.Empty
|
||||
: $"&mode={request.Mode}";
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
|
||||
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:10
|
||||
#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}
|
||||
";
|
||||
},
|
||||
() =>
|
||||
@@ -59,5 +70,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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -1,14 +1,17 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
@@ -18,11 +21,13 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||
IChannelRepository channelRepository,
|
||||
@@ -31,7 +36,9 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService)
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
@@ -40,6 +47,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_localFileSystem = localFileSystem;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
@@ -64,7 +73,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
|
||||
};
|
||||
|
||||
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await _configElementRepository
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
return Right<BaseError, Process>(
|
||||
@@ -143,7 +153,6 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
string path = await GetPlayoutItemPath(playoutItem);
|
||||
|
||||
// TODO: this won't work with url streaming from plex
|
||||
if (_localFileSystem.FileExists(path))
|
||||
{
|
||||
return new PlayoutItemWithPath(playoutItem, path);
|
||||
@@ -178,6 +187,12 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinEpisode.LibraryPathId,
|
||||
path),
|
||||
EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyMovie.LibraryPathId,
|
||||
path),
|
||||
EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyEpisode.LibraryPathId,
|
||||
path),
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ namespace ErsatzTV.Application.Television
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
|
||||
.IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
m => (m.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList()).IfNone(new List<string>()),
|
||||
LanguagesForShow(languages),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
@@ -53,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,
|
||||
@@ -104,7 +112,7 @@ namespace ErsatzTV.Application.Television
|
||||
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
|
||||
if (artworkKind == ArtworkKind.Poster)
|
||||
{
|
||||
url.SetQueryParam("fillHeight", 440);
|
||||
url.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
artwork = url;
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace ErsatzTV.Application.Television
|
||||
List<string> Genres,
|
||||
List<string> Tags,
|
||||
List<string> Studios,
|
||||
List<string> ContentRatings,
|
||||
List<CultureInfo> Languages,
|
||||
List<ActorCardViewModel> Actors);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
@@ -688,7 +688,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -39,7 +41,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 +57,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()
|
||||
{
|
||||
@@ -328,7 +351,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
@@ -408,7 +432,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(7);
|
||||
@@ -494,7 +519,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
@@ -584,7 +610,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
@@ -678,7 +705,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(5);
|
||||
@@ -771,7 +799,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(5);
|
||||
@@ -830,7 +859,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
|
||||
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, _logger);
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
var items = new List<ProgramScheduleItem> { Flood(mediaCollection) };
|
||||
|
||||
|
||||
@@ -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,8 @@ namespace ErsatzTV.Core.Domain
|
||||
public string Name { get; set; }
|
||||
public int FFmpegProfileId { get; set; }
|
||||
public FFmpegProfile FFmpegProfile { get; set; }
|
||||
public int? WatermarkId { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
public StreamingMode StreamingMode { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
public List<Artwork> Artwork { get; set; }
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ChannelWatermark
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public Channel Channel { get; set; }
|
||||
public ChannelWatermarkLocation Location { get; set; }
|
||||
public ChannelWatermarkSize Size { get; set; }
|
||||
public ChannelWatermarkMode Mode { get; set; }
|
||||
public int WidthPercent { get; set; }
|
||||
public int HorizontalMarginPercent { get; set; }
|
||||
public int VerticalMarginPercent { get; set; }
|
||||
public int FrequencyMinutes { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
public enum ChannelWatermarkLocation
|
||||
{
|
||||
BottomRight = 0,
|
||||
BottomLeft = 1,
|
||||
TopRight = 2,
|
||||
TopLeft = 3
|
||||
}
|
||||
|
||||
public enum ChannelWatermarkSize
|
||||
{
|
||||
Scaled = 0,
|
||||
ActualSize = 1
|
||||
}
|
||||
|
||||
public enum ChannelWatermarkMode
|
||||
{
|
||||
None = 0,
|
||||
Permanent = 1,
|
||||
Intermittent = 2
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
public bool NormalizeVideo { get; set; }
|
||||
public int VideoBitrate { get; set; }
|
||||
public int VideoBufferSize { get; set; }
|
||||
public string FrameRate { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public int AudioBitrate { get; set; }
|
||||
public int AudioBufferSize { get; set; }
|
||||
@@ -40,7 +39,6 @@
|
||||
AudioChannels = 2,
|
||||
AudioSampleRate = 48,
|
||||
NormalizeVideo = true,
|
||||
FrameRate = "24",
|
||||
NormalizeAudio = true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Director
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { 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; }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
{
|
||||
Fallback = 0,
|
||||
Sidecar = 1,
|
||||
External
|
||||
External = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class MovieMetadata : Metadata
|
||||
{
|
||||
public string ContentRating { get; set; }
|
||||
public string Outline { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public string Tagline { get; set; }
|
||||
public int MovieId { get; set; }
|
||||
public Movie Movie { get; set; }
|
||||
public List<Director> Directors { get; set; }
|
||||
public List<Writer> Writers { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public class ShowMetadata : Metadata
|
||||
{
|
||||
public string ContentRating { get; set; }
|
||||
public string Outline { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public string Tagline { get; set; }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Writer
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { 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; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
Collection = 0,
|
||||
TelevisionShow = 1,
|
||||
TelevisionSeason = 2
|
||||
TelevisionSeason = 2,
|
||||
Artist = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
public enum StreamingMode
|
||||
{
|
||||
TransportStream = 1,
|
||||
HttpLiveStreaming = 2
|
||||
HttpLiveStreamingDirect = 2,
|
||||
HttpLiveStreamingHybrid = 3
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user