Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 050aaaa288 fix updating music videos (#174) 2021-04-17 10:24:16 -05:00
Jason DoveandGitHub 7c07c5f522 fix odd resolution padding; fix updating plex episode artwork (#173)
* fix padding odd resolutions

* fix updating plex episode artwork only as needed
2021-04-17 10:08:20 -05:00
Jason DoveandGitHub d8d21996b4 add actors to movies and shows (#172)
* add actor metadata

* show actors in ui

* get full movie/show metadata from plex

* store actor thumbnail url

* rework movie detail page

* metadata fixes

* rework show detail page

* rework artist page

* code cleanup
2021-04-17 09:36:57 -05:00
Jason DoveandGitHub e368d4a075 fix collections paging (#171) 2021-04-16 15:59:31 -05:00
Jason DoveandGitHub 466059e2aa fix add to collection typing lag (#170) 2021-04-15 20:35:07 -05:00
Jason DoveandGitHub e951ecb650 fix lag when typing in search bar (#169) 2021-04-15 19:54:19 -05:00
Jason DoveandGitHub 1d1f53da01 enter to submit all dialogs (#168) 2021-04-14 14:48:08 -05:00
Jason DoveandGitHub a854294cb6 allow enter key to submit add to collection dialog (#167) 2021-04-14 14:37:37 -05:00
Jason DoveandGitHub f89f3d2225 fix music videos in epg (#166) 2021-04-13 06:20:37 -05:00
Jason DoveandGitHub a2700e087c show release notes on home page (#165) 2021-04-11 12:34:41 -05:00
Jason DoveandGitHub 34fbfce0a5 limit to one playout per channel (#164) 2021-04-11 05:55:26 -05:00
Jason Dove 993293c104 add search docs [no docker] 2021-04-11 05:34:20 -05:00
Jason DoveandGitHub ececa62446 fix synchronizing plex show metadata (#163) 2021-04-10 20:57:09 -05:00
Jason DoveandGitHub 237729e79d add movie, show, artist language buttons. search by english language name (#162) 2021-04-10 20:40:54 -05:00
Jason DoveandGitHub 9c0ada2df5 fix television metadata (#161) 2021-04-10 18:40:18 -05:00
Jason Dove dee264597b fix search index warning 2021-04-10 13:07:58 -05:00
Jason Dove a8db294043 add local libraries doc [no docker] 2021-04-09 18:33:28 -05:00
Jason Dove a2a63e0120 add creative commons attribution [no docker] 2021-04-09 15:11:22 -05:00
Jason Dove c7881aec14 remove screenshots [no docker] 2021-04-09 14:39:10 -05:00
Jason DoveandGitHub 558bdcb6b0 client setup doc updates (#160)
* update jellyfin client setup

* update channels-dvr setup
2021-04-09 13:11:49 -05:00
Jason DoveandGitHub 24f2b4b727 force music video library scan (#159) 2021-04-09 07:42:13 -05:00
Jason DoveandGitHub 667887f387 fix television show and season playouts (#158) 2021-04-09 07:24:12 -05:00
Jason Dove 98eb72fcfe skip docker with [no docker] 2021-04-09 06:42:16 -05:00
Jason Dove c2f92fd054 add github release links to docs [no ci] 2021-04-09 06:39:22 -05:00
Jason DoveandGitHub f04ddd3a40 save collection page size (#157) 2021-04-09 06:30:46 -05:00
Jason DoveandGitHub aa0942384d fix removing deleted music videos (#156) 2021-04-09 05:34:28 -05:00
Jason DoveandGitHub cd100be3a2 relax music video naming requirements (#155) 2021-04-09 05:22:02 -05:00
Jason DoveandGitHub 2b26a5411c add artists as owners of music videos (#154)
* clean up genre, tag, studio orphans

* enforce foreign keys at connection level

* wip

* fix fragment scroll offset

* fix see all link for music videos

* add fake artist metadata

* not null artist id

* add artist scanning

* remove improperly named music videos

* code cleanup

* add artists to search results and collections

* clean up music video metadata / artist

* add artist view

* show music videos on artist page

* add music video artwork placeholder
2021-04-09 05:10:58 -05:00
Jason DoveandGitHub baf81f31cd fix plex server and connection sync (#153) 2021-04-08 18:48:12 -05:00
Jason DoveandGitHub bfa290790b trim parsed music video titles (#152) 2021-04-07 16:36:20 -05:00
Jason DoveandGitHub b975922a77 only index video stream languages for movies and music videos (#151) 2021-04-07 16:12:39 -05:00
Jason Dove 1a39978a77 try to fix windows builds 2021-04-07 05:34:02 -05:00
Jason DoveandGitHub 436c9119fa add all search results to collection (#150) 2021-04-06 20:43:44 -05:00
Jason Dove 33642a13ce allow manual ci trigger 2021-04-06 09:21:54 -05:00
Jason Dove 09b349d1cb only index audio language [no ci] 2021-04-06 09:11:39 -05:00
Jason DoveandGitHub 2be729c10e search show by language (#149) 2021-04-06 07:58:45 -05:00
Jason DoveandGitHub 0aac702853 search movies and music videos by language (#148) 2021-04-06 07:42:21 -05:00
Jason DoveandGitHub 3f406ac556 log viewer improvements (#147) 2021-04-06 07:25:05 -05:00
Jason DoveandGitHub 454e2edf7c add documentation link to ui (#146) 2021-04-06 06:39:48 -05:00
b3f4fa8c23 add documentation (#145)
* start to reorganize documentation

* revert readme tag changes

* revert readme tag changes pt2

* doc updates; doc theme updates

* doc updates

* publish docs from documentation branch

* use favicon for docs

* create channel

* collections, jellyfin client, schedule items and playout docs

* channels dvr

* tivimate

* scale tivimate screenshots

* add channels dvr server setup

* add copyright and social

* Added UnRAID Docker install, formatting fixes (#100)

Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>

* minor doc updates

* readme tweak

* add basic plex documentation

Co-authored-by: suckerface <9060047+suckerface@users.noreply.github.com>
Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>
2021-04-06 06:20:49 -05:00
Jason DoveandGitHub a6496db58d settings rework (#144)
* add hdhr tuner count setting

* code cleanup
2021-04-05 19:48:17 -05:00
Jason Dove 3eed79b5e1 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:19:22 -05:00
Jason DoveandGitHub 79bfba6428 better search index thread fix (#143)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* better search index thread fix
2021-04-05 16:18:17 -05:00
Jason Dove 9f6d4114a6 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:06:28 -05:00
Jason DoveandGitHub 9809c60924 send all audio streams on hls channels with no preferred language (#142)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* send all audio streams on hls channels with no preferred language
2021-04-05 16:06:13 -05:00
Jason Dove 16072fed1c Revert "fix search index threading (#141)"
This reverts commit 3fb6da0754.
2021-04-05 07:44:42 -05:00
Jason DoveandGitHub 3fb6da0754 fix search index threading (#141)
* fix search index threading

* code cleanup
2021-04-05 05:41:29 -05:00
Jason DoveandGitHub 24cdf6295f clean up fragment letter anchor code (#140) 2021-04-04 20:56:47 -05:00
Jason DoveandGitHub c1b41e2865 use fragment navigation with letter bar (#139) 2021-04-04 20:30:40 -05:00
Jason DoveandGitHub d249e95f12 fix poster width (#138) 2021-04-04 20:13:29 -05:00
Jason DoveandGitHub efae005447 use full preferred language names in ui (#137) 2021-04-04 18:30:42 -05:00
Jason DoveandGitHub cead787c55 force SAR 1:1 if missing (#136) 2021-04-04 18:00:39 -05:00
Jason DoveandGitHub 77a69af1a8 sort channels and schedules in playout editor (#135) 2021-04-04 16:24:46 -05:00
Jason DoveandGitHub 8fea24a3a5 add fallback metadata for music videos (#134) 2021-04-04 15:57:25 -05:00
Jason DoveandGitHub 6b44873474 add library scan progress detail (#133)
* add library scan progress detail

* scan plex libraries on plex thread
2021-04-04 10:44:10 -05:00
Jason DoveandGitHub c5ee5903b2 use table for collections ui (#132) 2021-04-03 16:21:06 -05:00
Jason DoveandGitHub 526eada48b channels, schedules, playouts paging/sorting (#131)
* add paging to playouts

* add sorting, paging to schedules

* fix channels sorting; add channels paging
2021-04-03 16:07:05 -05:00
Jason DoveandGitHub 7a0d65a433 fix epg with music videos (#130) 2021-04-03 15:38:12 -05:00
Jason DoveandGitHub 74c95249c3 add loudness normalization (#129)
* fix music video search result artwork

* add normalize loudness setting

* fix audio normalization

* fix music video thumbnails in collection items view

* fix ef core warnings querying playout item

* implement audio loudness normalization filter
2021-04-03 13:36:11 -05:00
Jason DoveandGitHub d4a2197dfa async fixes (#128)
* refactor local metadata provider

* resolve async warnings

* more async fixes
2021-04-03 11:01:20 -05:00
Jason DoveandGitHub 633586ddba add music videos library (#125)
* add music videos library

* add music video tables

* first pass at music video library scan

* support music videos in playouts

* display music videos in search results and collections

* fix music video thumbnails

* remove some obsolete fields
2021-04-02 18:28:45 -05:00
Jason DoveandGitHub da3e05b231 normalize video track timescale (#123) 2021-03-31 23:36:20 +00:00
Jason DoveandGitHub 9e6de7e2eb use proper type for plex timestamps (#124) 2021-03-31 21:30:30 +00:00
Jason DoveandGitHub 4097288fed normalize framerate (#122)
* normalize framerate

* simplify audio normalization settings
2021-03-31 09:34:52 +00:00
Jason DoveandGitHub 90f775aab4 ffmpeg tweaks (#121)
* save reports from ffmpeg concat process

* let ffmpeg determine thread count by default

* disable stdin for ffmpeg processes
2021-03-31 01:08:57 +00:00
Jason DoveandGitHub fc33c5cd05 add show title to playout details (#120) 2021-03-30 21:23:02 +00:00
Jason DoveandGitHub 37eee73ab7 clear search query when clicking nav links (#119) 2021-03-30 21:10:56 +00:00
Jason DoveandGitHub e7ebb32a1d navigate to schedule items after creating new schedule (#118) 2021-03-30 11:15:31 +00:00
Jason DoveandGitHub 9ea4459988 cache artwork async (#117) 2021-03-30 11:09:47 +00:00
Jason DoveandGitHub 745b03af73 add custom title option to schedule items (#116) 2021-03-29 21:46:03 +00:00
Jason DoveandGitHub a62c4ecfcf fix playout builds using duration or multiple (#115) 2021-03-29 20:01:46 +00:00
Jason DoveandGitHub c48f0a7d51 don't require preferred language on channels (#114) 2021-03-29 14:43:09 +00:00
Jason DoveandGitHub f2c105174b fix stream selection for non-normalized playback (#113) 2021-03-29 14:42:20 +00:00
Jason DoveandGitHub 076a88230e optimize local library scanning (#112) 2021-03-29 10:34:33 +00:00
Jason DoveandGitHub f06a04ed0e fix search index updates for local libraries (#111) 2021-03-29 10:28:38 +00:00
Jason DoveandGitHub 07d690a31f fix local tv library scanning (#110) 2021-03-29 10:20:18 +00:00
Jason Dove 001453714a fix playback on channel with no preferred language 2021-03-28 18:21:26 -05:00
Jason DoveandGitHub d303bc0158 add preferred language (#109)
* add explicit warning for zero/invalid duration media items

* set dateadded on plex media versions

* add media stream table

* save local media streams to db

* save plex media streams to db

* add preferred language settings (no validation)

* use preferred language if possible

* code cleanup

* proper language code validation

* force scan of all libraries to pull in media streams
2021-03-28 21:54:48 +00:00
Jason Dove 51b671dec7 load concat playlist from localhost 2021-03-28 06:48:10 -05:00
Jason DoveandGitHub a5e1cc7c3d allow trailing slash in plex path replacement (#108)
* add test for unc path replacement

* allow trailing slash in plex path replacement
2021-03-28 11:32:32 +00:00
Jason DoveandGitHub 9ba6686c44 iptv route consistency [no ci] (#107)
* use localhost in concat playlist

* expose all playlist artwork under /iptv
2021-03-28 11:32:13 +00:00
Jason DoveandGitHub 104d4a0cbd fix mixed platform directory mapping (#106)
* sync plex platform and platform version

* fix mixed-platform path replacements
2021-03-28 01:40:40 +00:00
Jason DoveandGitHub 22c4fe2a27 fix indexing shows without nfo metadata (#105) 2021-03-27 23:32:10 +00:00
Jason DoveandGitHub 7e0bdfdb40 fix epg channel sorting (#101) 2021-03-26 10:36:06 +00:00
Jason Dove 6bdaca0222 remove unused code [no ci] 2021-03-26 05:33:46 -05:00
Jason Dove 67aa3a5a46 Revert "update docker repos and tagging for ci"
This reverts commit 470fba275b.
2021-03-23 07:42:31 -05:00
Jason Dove a0332e242c Revert "update docker repos and tagging for release [no ci]"
This reverts commit cd74859d28.
2021-03-23 07:42:20 -05:00
Jason Dove cd74859d28 update docker repos and tagging for release [no ci] 2021-03-23 06:39:40 -05:00
Jason Dove 470fba275b update docker repos and tagging for ci 2021-03-23 06:20:58 -05:00
Jason DoveandGitHub e42b000b7f fix plex sign in (#99) 2021-03-23 02:09:05 +00:00
Jason DoveandGitHub 489f8d92ff properly store plex timestamps on update (#98) 2021-03-22 02:20:31 +00:00
Jason DoveandGitHub 527d3c6e4b attach existing episodes to correct show and season when adding nfo metadata (#97) 2021-03-22 01:57:32 +00:00
Jason DoveandGitHub c33c037188 use folder.ext when poster.ext is not found for movies or shows (#96) 2021-03-21 21:50:31 +00:00
Jason DoveandGitHub 4c70d61d48 metadata improvements (#95)
* fix episode fallback metadata processing, fix show fallback metadata year parsing

* fix sort title for "a" and "an"

* add and index studio metadata

* minimize circular logging with search index errors

* update plex movie sort titles as needed

* properly escape search links

* force refreshing all movie/show metadata
2021-03-21 18:43:08 +00:00
Jason DoveandGitHub 00fdc272e9 remove plex items from index after sign out (#94) 2021-03-21 15:23:53 +00:00
Jason DoveandGitHub f04c18c810 index release date for searching (#93) 2021-03-21 01:49:10 +00:00
Jason DoveandGitHub eca58dbe7f plex fixes (#92)
* fix updating plex path replacements

* fix adding/removing plex libraries

* fix adding/removing plex servers

* fix initial plex library sync after sign in

* code cleanup
2021-03-21 01:35:18 +00:00
Jason DoveandGitHub cf9479d2a9 log search indexing errors and continue indexing (#91) 2021-03-20 21:33:37 +00:00
Jason DoveandGitHub b6331331b0 use default ffmpeg profile with new channels (#90) 2021-03-20 20:56:15 +00:00
Jason DoveandGitHub ed365cfa43 keep search query in search field (#89)
* upgrade dependencies

* keep search query in search field
2021-03-20 20:45:02 +00:00
Jason Dove b3a1e71570 only search title by default, allow leading wildcards 2021-03-20 15:32:30 -05:00
Jason Dove 454343d14f prevent ui crash during index rebuild [no ci] 2021-03-20 11:23:36 -05:00
Jason DoveandGitHub c0a6677861 optimize memory use during search index rebuild (#88) 2021-03-20 16:08:28 +00:00
Jason DoveandGitHub 2efcbca2da search overhaul (#87)
* add letter bar with no links

* use lucene for search, add paged search results

* add search index version

* index library_name; rebuild index when folder is missing

* maintain index as local movies change

* fix tests

* maintain index as local shows change

* maintain index as plex movies change

* maintain index as plex shows change

* code cleanup

* add duplicate filter to search

* add links to letter bar

* code cleanup
2021-03-20 15:49:50 +00:00
Jason Dove f96efa9b2f fix normalize video codec setting 2021-03-19 16:00:23 -05:00
Jason DoveandGitHub f46041305c add docs to schedule items page (#86) 2021-03-19 02:10:56 +00:00
Jason DoveandGitHub 493a496b91 delete orphan plex media sources (#85)
* delete orphan plex media sources

* fix plex db warning on startup
2021-03-19 01:14:18 +00:00
Jason DoveandGitHub 739d074bc6 optimize local scanning (#84)
* optimize local scanning

* fix artwork updates

* fix adding genres and tags

* fix movie fallback metadata
2021-03-19 00:45:38 +00:00
Jason DoveandGitHub c5c28cb92d fix playback for media containing attached pictures (#83) 2021-03-18 01:49:30 +00:00
Jason DoveandGitHub 636bf0715b bug fixes (#82)
* fix crash rebuilding playlists from ui

* fix error creating first channel
2021-03-18 01:27:08 +00:00
Jason Dove 0ca15ee7a8 fix docker release [no ci] 2021-03-17 16:46:35 -05:00
Jason Dove 6565240eeb try ci with isolated builders 2021-03-17 16:31:16 -05:00
Jason Dove d64188927c try ci without docker cache 2021-03-17 16:11:44 -05:00
Jason Dove 0ecec3cb07 include hidden plex libraries 2021-03-16 20:40:50 -05:00
Jason DoveandGitHub a8e861abc0 add optional ffmpeg reports (#81)
* log full exceptions in plex tv api client

* add optional ffmpeg reports
2021-03-17 01:22:09 +00:00
Jason DoveandGitHub 76446e0d69 prevent repeated playout items when reshuffling (#80) 2021-03-15 11:28:07 +00:00
Jason Dove c6d90ad750 allow plex re-authentication 2021-03-14 21:05:14 -05:00
Jason Dove e5a9ef6196 add episode posters to xmltv 2021-03-14 18:49:30 -05:00
Jason Dove 8439d6fd54 fix channel logos in xmltv 2021-03-14 18:44:19 -05:00
Jason DoveandGitHub 1773691c39 create collection from add to collection dialog (#79) 2021-03-14 20:50:23 +00:00
Jason Dove 940cdd10a3 update all references 2021-03-14 15:29:14 -05:00
Jason Dove 6beb9f7e33 regularly scan plex media sources 2021-03-14 15:21:23 -05:00
Jason DoveandGitHub 898a21dcd9 clean up tables (#78)
* add plex library sorting options

* add playout sorting options
2021-03-14 20:13:28 +00:00
Jason DoveandGitHub a01888792a delet items removed from plex (#77)
* delete items removed from plex

* fix tests
2021-03-14 17:54:32 +00:00
Jason DoveandGitHub 8b1f8dd36b support plex media with missing release date (#76) 2021-03-14 17:32:49 +00:00
Jason DoveandGitHub e9b26d6bdb fix plex async genre sync (#75) 2021-03-14 16:27:06 +00:00
Jason DoveandGitHub 79b2e9dbfe fix plex movie scanning performance (#74) 2021-03-14 16:25:05 +00:00
Jason DoveandGitHub 9ba0cbd84f enable plex for television (#73)
* add plex show, season sync

* sync plex episodes

* sync plex episode statistics

* update plex artwork as needed

* code cleanup

* add note about tests
2021-03-14 16:03:04 +00:00
Jason Dove d5b48d2601 fix plex movies with no genres 2021-03-13 15:28:45 -06:00
Jason DoveandGitHub aa938baec8 enable plex for movies (#72)
* re-enable plex, temp force secure connections

* add plex fanart

* synchronize genre from plex

* fix plex library sync

* improve stream error handling

* synchronize plex artwork

* use switch instead of button

* prioritize local connections for insecure plex sources

* sign out of plex

* better plex sign in/out

* code cleanup

* fix plex movie aspect ratio and scan type
2021-03-13 21:04:54 +00:00
Jason DoveandGitHub a13f964200 add movie poster to xmltv (#71) 2021-03-13 11:55:15 +00:00
Jason DoveandGitHub 0da9701f9c include movie date in xmltv (#70) 2021-03-13 03:20:39 +00:00
Jason Dove b3f4c22f49 update docker cache [no ci] 2021-03-12 21:13:29 -06:00
Jason DoveandGitHub 50fafbfb98 remove duplicate subtitle tag from xmltv (#69) 2021-03-13 03:03:59 +00:00
Jason DoveandGitHub 914d128610 set title, subtitle, category in xmltv (#68) 2021-03-13 02:47:34 +00:00
Jason DoveandGitHub 1a2f36f561 fix loading seasons with empty episode plot (#67) 2021-03-13 02:15:35 +00:00
Jason DoveandGitHub 96887fbd79 properly set sort title on new tv shows (#66) 2021-03-13 00:51:08 +00:00
Jason DoveandGitHub c07e2afff4 fix playouts that use shows or seasons (#65) 2021-03-13 00:50:17 +00:00
Jason DoveandGitHub 4953617f79 custom collection playback order (#64)
* add custom index to collection items

* add custom collection order to ui

* cleanup
2021-03-12 19:24:28 +00:00
Jason DoveandGitHub 1587ac7d62 ffmpeg and ffprobe validation fixes (#63)
* abort building playout if any collection contains a zero-duration item

* surface errors calling ffprobe

* improve ffmpeg/ffprobe path validation
2021-03-12 02:20:18 +00:00
Jason DoveandGitHub c240169fc9 add multiselect and movie tags (#62)
* add basic selection behavior to search results

* add search scrolling, selection actions

* include shows in multiselect

* multiselect movies, shows, collection items

* add movie and show tags

* code cleanup

* update show screenshot
2021-03-11 18:49:00 +00:00
Jason DoveandGitHub 76d6725dd5 add movie and show genres (#61)
* load movie genres from sidecar metadata

* search movie and tv show genres

* rebuild all playouts (needed after time zone fix)

* code cleanup

* fix duplicate tv show search results
2021-03-11 02:42:04 +00:00
Jason DoveandGitHub c016cac8d4 fix playout time zone bugs (#60)
* fix time zone bugs with playout building

* more time zone fixes
2021-03-10 22:37:57 +00:00
Jason DoveandGitHub e624627ae1 fix editing channel number (#59) 2021-03-10 19:15:08 +00:00
Jason DoveandGitHub 46bcf03d9a regenerate sort titles for all media items (#58) 2021-03-10 18:06:27 +00:00
Jason DoveandGitHub ab9a8493d9 fix tv show sorting (#57) 2021-03-10 14:14:33 +00:00
Jason DoveandGitHub b1ecbafb6e tv ui update (#56)
* update tv show ui

* update tv season ui

* list episode details on tv season page

* remove episode page

* remove breadcrumbs

* move home link

* code cleanup

* update screenshots
2021-03-10 12:24:18 +00:00
Jason DoveandGitHub e3b91e62ae new movie layout, new dark ui (#55)
* include cache header on artwork responses

* rework movie page to include fan art

* full width app bar

* dark mode

* cleanup

* fix placeholder color
2021-03-10 03:26:51 +00:00
Jason DoveandGitHub 54da3a3159 fix channel sorting (#54) 2021-03-09 03:40:13 +00:00
Jason DoveandGitHub d53a2f8bbf add subchannel support (#53) 2021-03-09 02:46:22 +00:00
Jason DoveandGitHub c2cbb1d5ff fix collection item sorting in ui (#52) 2021-03-09 00:38:18 +00:00
Jason DoveandGitHub bd231d57a7 fix vaapi pipeline with mpeg4 content (#51) 2021-03-09 00:24:08 +00:00
Jason DoveandGitHub 77cb2c2270 include tzdata in docker to support TZ env var again (#50) 2021-03-08 13:41:59 +00:00
Jason DoveandGitHub 5244d5076a use output duration flag (#49)
* re-enable output duration flag

* calculate appropriate duration for offline image
2021-03-08 11:16:56 +00:00
Jason DoveandGitHub 9841640128 add m3u codec hints for channels app (#48) 2021-03-08 02:36:27 +00:00
Jason DoveandGitHub a256095e12 enforce unique schedule name (#47) 2021-03-07 21:46:48 +00:00
Jason DoveandGitHub ed592bd0a0 Fix offline stream (#46)
* publish offline stream background image

* add text to offline stream
2021-03-07 21:18:38 +00:00
Jason Dove 5998fd2f5f more docker tag tweaks [no ci] 2021-03-07 10:38:56 -06:00
Jason Dove 4f536adc99 fix nvidia tag 2021-03-07 10:26:20 -06:00
Jason Dove 2637ff657d fix readme link 2021-03-07 10:16:52 -06:00
Jason DoveandGitHub c4f7607a50 publish docker images on merge to main (#44)
* test docker build and push

* enable for test branch

* try to get tag another way

* add nvidia and vaapi pushes

* try to get tag again

* still looking for the tag

* include sha in version

* only build and push docker on merges to main

* push docker images on release

* add hw accel info to readme
2021-03-07 16:12:51 +00:00
Jason DoveandGitHub 0f052631a4 try to fix vaapi by always using nv12 or vaapi pixel format (#42) 2021-03-07 12:22:18 +00:00
Jason DoveandGitHub b13b2b9805 Hardware-accelerated transcoding (#41)
* add qsv transcoding support

* add basic nvenc support

* add nvenc to docker-compose

* add vaapi hardware acceleration

* add vaapi driver to dockerfile

* raise ffmpeg log level

* lots of progress with nvenc, qsv and vaapi remain untested

* qsv fixes

* code cleanup
2021-03-06 22:07:28 +00:00
Jason DoveandGitHub 51cdb372b9 Remove missing media (#40)
* remove movies that are no longer present on disk

* remove missing episodes, empty seasons, empty shows
2021-03-06 12:43:38 +00:00
Jason DoveandGitHub 363eb2c276 Rebuild playouts with modified collections (#39)
* rebuild playouts when items are removed from collections

* rebuild playouts when items are added to collections

* simplify logic
2021-03-05 00:50:26 +00:00
Jason DoveandGitHub c6ea2c88df remember selected collection (#36) 2021-03-04 03:22:15 +00:00
Jason DoveandGitHub 3ed83a276f fix database migration (#35) 2021-03-02 18:32:14 +00:00
529 changed files with 97686 additions and 3259 deletions
+4
View File
@@ -79,3 +79,7 @@ indent_size=2
indent_style=space
indent_size=4
tab_width=4
[*.yml]
indent_style = space
indent_size = 2
+78 -2
View File
@@ -1,12 +1,12 @@
name: Build
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
- develop
jobs:
build:
build_and_test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -32,3 +32,79 @@ jobs:
- name: Test
run: dotnet test --no-restore --verbosity normal
build_and_push:
name: Build & Publish to Docker Hub
needs: build_and_test
runs-on: ubuntu-latest
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no docker]')
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Extract Git Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2/prealpha/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- name: Set up Docker Buildx NVIDIA
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push base
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
context: .
file: ./docker/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker
tags: |
jasongdove/ersatztv:develop
jasongdove/ersatztv:${{ github.sha }}
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-nvidia.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
tags: |
jasongdove/ersatztv:develop-nvidia
jasongdove/ersatztv:${{ github.sha }}-nvidia
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
tags: |
jasongdove/ersatztv:develop-vaapi
jasongdove/ersatztv:${{ github.sha }}-vaapi
+19
View File
@@ -0,0 +1,19 @@
name: Publish docs via GitHub Pages
on:
push:
branches:
- main
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CUSTOM_DOMAIN: ersatztv.org
+72
View File
@@ -67,3 +67,75 @@ jobs:
ErsatzTV*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
name: Build & Publish to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Extract Git Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- name: Set up Docker Buildx NVIDIA
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push base
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
context: .
file: ./docker/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker
tags: |
jasongdove/ersatztv:latest
jasongdove/ersatztv:${{ env.DOCKER_TAG }}
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-nvidia.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
tags: |
jasongdove/ersatztv:latest-nvidia
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-nvidia
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
tags: |
jasongdove/ersatztv:latest-vaapi
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-vaapi
+3
View File
@@ -40,3 +40,6 @@ msbuild.wrn
core
scripts/generate-api-sdk/swagger.json
docker-compose.override.yml
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Globalization;
namespace ErsatzTV.Application.Artists
{
public record ArtistViewModel(
string Name,
string Disambiguation,
string Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods,
List<CultureInfo> Languages);
}
+46
View File
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Artists
{
internal static class Mapper
{
internal static ArtistViewModel ProjectToViewModel(Artist artist, List<string> languages)
{
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head();
return new ArtistViewModel(
metadata.Title,
metadata.Disambiguation,
metadata.Biography,
Artwork(metadata, ArtworkKind.Thumbnail),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Styles.Map(s => s.Name).ToList(),
metadata.Moods.Map(m => m.Name).ToList(),
LanguagesForArtist(languages));
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
private static List<CultureInfo> LanguagesForArtist(List<string> languages)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
}
}
@@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>;
}
@@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Artists.Mapper;
namespace ErsatzTV.Application.Artists.Queries
{
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchRepository _searchRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
{
_artistRepository = artistRepository;
_searchRepository = searchRepository;
}
public async Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken)
{
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist =>
{
List<string> languages = await _searchRepository.GetLanguagesForArtist(artist);
return ProjectToViewModel(artist, languages);
},
() => Task.FromResult(Option<ArtistViewModel>.None));
}
}
}
@@ -4,9 +4,10 @@ namespace ErsatzTV.Application.Channels
{
public record ChannelViewModel(
int Id,
int Number,
string Number,
string Name,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode);
}
@@ -8,8 +8,9 @@ namespace ErsatzTV.Application.Channels.Commands
public record CreateChannel
(
string Name,
int Number,
string Number,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
}
@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -8,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Channels.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Channels.Commands
{
@@ -35,9 +39,10 @@ namespace ErsatzTV.Application.Channels.Commands
_channelRepository.Add(c).Map(ProjectToViewModel);
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) =>
(ValidateName(request), ValidateNumber(request), await FFmpegProfileMustExist(request))
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request),
ValidatePreferredLanguage(request))
.Apply(
(name, number, ffmpegProfileId) =>
(name, number, ffmpegProfileId, preferredLanguageCode) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
@@ -58,7 +63,8 @@ namespace ErsatzTV.Application.Channels.Commands
Number = number,
FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode,
Artwork = artwork
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
};
});
@@ -66,9 +72,28 @@ namespace ErsatzTV.Application.Channels.Commands
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
// TODO: validate number does not exist?
private Validation<BaseError, int> ValidateNumber(CreateChannel createChannel) =>
createChannel.AtLeast(1)(c => c.Number);
private Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
{
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
return maybeExistingChannel.Match<Validation<BaseError, string>>(
_ => BaseError.New("Channel number must be unique"),
() =>
{
if (Regex.IsMatch(createChannel.Number, Channel.NumberValidator))
{
return createChannel.Number;
}
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
});
}
private async Task<Validation<BaseError, int>> FFmpegProfileMustExist(CreateChannel createChannel) =>
(await _ffmpegProfileRepository.Get(createChannel.FFmpegProfileId))
@@ -9,8 +9,9 @@ namespace ErsatzTV.Application.Channels.Commands
(
int ChannelId,
string Name,
int Number,
string Number,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
}
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -31,6 +33,7 @@ namespace ErsatzTV.Application.Channels.Commands
c.Name = update.Name;
c.Number = update.Number;
c.FFmpegProfileId = update.FFmpegProfileId;
c.PreferredLanguageCode = update.PreferredLanguageCode;
if (!string.IsNullOrWhiteSpace(update.Logo))
{
@@ -64,8 +67,9 @@ namespace ErsatzTV.Application.Channels.Commands
}
private async Task<Validation<BaseError, Channel>> Validate(UpdateChannel request) =>
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request))
.Apply((channelToUpdate, _, _) => channelToUpdate);
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request),
ValidatePreferredLanguage(request))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
private Task<Validation<BaseError, Channel>> ChannelMustExist(UpdateChannel updateChannel) =>
_channelRepository.Get(updateChannel.ChannelId)
@@ -75,16 +79,28 @@ namespace ErsatzTV.Application.Channels.Commands
updateChannel.NotEmpty(c => c.Name)
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
private async Task<Validation<BaseError, int>> ValidateNumber(UpdateChannel updateChannel)
private async Task<Validation<BaseError, string>> ValidateNumber(UpdateChannel updateChannel)
{
Option<Channel> match = await _channelRepository.GetByNumber(updateChannel.Number);
int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId);
int matchId = await match.Map(c => c.Id).IfNoneAsync(updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)
{
return updateChannel.AtLeast(1)(c => c.Number);
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
{
return updateChannel.Number;
}
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
}
return BaseError.New("Channel number must be unique");
}
private Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
}
}
+1
View File
@@ -13,6 +13,7 @@ namespace ErsatzTV.Application.Channels
channel.Name,
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode);
private static string GetLogo(Channel channel) =>
@@ -3,9 +3,9 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Channels.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Channels.Queries
{
@@ -15,7 +15,7 @@ namespace ErsatzTV.Application.Channels.Queries
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
_channelRepository.GetAll().Map(channels => channels.Map(ProjectToViewModel).ToList());
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Application.Configuration.Commands
{
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : MediatR.IRequest<Unit>;
}
@@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.Configuration.Commands
{
public class SaveConfigElementByKeyHandler : MediatR.IRequestHandler<SaveConfigElementByKey, Unit>
{
private readonly IConfigElementRepository _configElementRepository;
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
{
Option<ConfigElement> maybeElement = await _configElementRepository.Get(request.Key);
await maybeElement.Match(
ce =>
{
ce.Value = request.Value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = request.Key.Key, Value = request.Value };
return _configElementRepository.Add(ce);
});
return Unit.Default;
}
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Configuration
{
public record ConfigElementViewModel(string Key, string Value);
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Configuration
{
internal static class Mapper
{
internal static ConfigElementViewModel ProjectToViewModel(ConfigElement element) =>
new(element.Key, element.Value);
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Configuration.Queries
{
public record GetConfigElementByKey(ConfigElementKey Key) : IRequest<Option<ConfigElementViewModel>>;
}
@@ -0,0 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Configuration.Mapper;
namespace ErsatzTV.Application.Configuration.Queries
{
public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKey, Option<ConfigElementViewModel>>
{
private readonly IConfigElementRepository _configElementRepository;
public GetConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<Option<ConfigElementViewModel>> Handle(
GetConfigElementByKey request,
CancellationToken cancellationToken) =>
_configElementRepository.Get(request.Key).MapT(ProjectToViewModel);
}
}
@@ -2,11 +2,20 @@
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
</ItemGroup>
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -8,18 +9,18 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -41,20 +41,20 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
Name = name,
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
ResolutionId = resolutionId,
NormalizeResolution = request.NormalizeResolution,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,
NormalizeVideoCodec = request.NormalizeVideoCodec,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
AudioCodec = request.AudioCodec,
NormalizeAudioCodec = request.NormalizeAudioCodec,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
AudioVolume = request.AudioVolume,
NormalizeLoudness = request.NormalizeLoudness,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio
NormalizeAudio = request.NormalizeAudio,
FrameRate = request.FrameRate
});
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -62,7 +62,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(_ => createFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(CreateFFmpegProfile createFFmpegProfile) =>
(await _resolutionRepository.Get(createFFmpegProfile.ResolutionId))
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -9,18 +10,18 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -35,20 +35,20 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.ResolutionId = update.ResolutionId;
p.NormalizeResolution = update.NormalizeResolution;
p.NormalizeVideo = update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
p.NormalizeVideoCodec = update.NormalizeVideoCodec;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec;
p.NormalizeAudioCodec = update.NormalizeAudioCodec;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.AudioVolume = update.AudioVolume;
p.NormalizeLoudness = update.NormalizeLoudness;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio;
p.FrameRate = update.FrameRate;
await _ffmpegProfileRepository.Update(p);
return ProjectToViewModel(p);
}
@@ -68,7 +68,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(UpdateFFmpegProfile updateFFmpegProfile) =>
(await _resolutionRepository.Get(updateFFmpegProfile.ResolutionId))
@@ -1,6 +1,7 @@
using MediatR;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : IRequest;
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -1,70 +1,105 @@
using System.Threading;
using System.Diagnostics;
using System.IO;
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 LanguageExt;
using MediatR;
using Unit = MediatR.Unit;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings>
public class UpdateFFmpegSettingsHandler : MediatR.IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
public UpdateFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<Unit> Handle(UpdateFFmpegSettings request, CancellationToken cancellationToken)
public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem)
{
Option<ConfigElement> ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
Option<ConfigElement> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
Option<ConfigElement> defaultFFmpegProfileId =
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId);
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
}
ffmpegPath.Match(
public Task<Either<BaseError, Unit>> Handle(
UpdateFFmpegSettings request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyUpdate(request))
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{
if (!_localFileSystem.FileExists(path))
{
return BaseError.New($"{name} path does not exist");
}
var startInfo = new ProcessStartInfo
{
FileName = path,
Arguments = "-version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
var test = new Process
{
StartInfo = startInfo
};
test.Start();
string output = await test.StandardOutput.ReadToEndAsync();
await test.WaitForExitAsync();
return test.ExitCode == 0 && output.Contains($"{name} version")
? Unit.Default
: BaseError.New($"Unable to verify {name} version");
}
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
{
await Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath);
await Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
await Upsert(ConfigElementKey.FFmpegDefaultProfileId, request.Settings.DefaultFFmpegProfileId.ToString());
await Upsert(ConfigElementKey.FFmpegSaveReports, request.Settings.SaveReports.ToString());
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
}
await Upsert(ConfigElementKey.FFmpegPreferredLanguageCode, request.Settings.PreferredLanguageCode);
return Unit.Default;
}
private async Task Upsert(ConfigElementKey key, string value)
{
Option<ConfigElement> maybeElement = await _configElementRepository.Get(key);
await maybeElement.Match(
ce =>
{
ce.Value = request.Settings.FFmpegPath;
_configElementRepository.Update(ce);
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{ Key = ConfigElementKey.FFmpegPath.Key, Value = request.Settings.FFmpegPath };
_configElementRepository.Add(ce);
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
});
ffprobePath.Match(
ce =>
{
ce.Value = request.Settings.FFprobePath;
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{ Key = ConfigElementKey.FFprobePath.Key, Value = request.Settings.FFprobePath };
_configElementRepository.Add(ce);
});
defaultFFmpegProfileId.Match(
ce =>
{
ce.Value = request.Settings.DefaultFFmpegProfileId.ToString();
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
Value = request.Settings.DefaultFFmpegProfileId.ToString()
};
_configElementRepository.Add(ce);
});
return Unit.Value;
}
}
}
@@ -1,4 +1,5 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles
{
@@ -7,18 +8,18 @@ namespace ErsatzTV.Application.FFmpegProfiles
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
ResolutionViewModel Resolution,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio);
bool NormalizeAudio,
string FrameRate);
}
@@ -5,5 +5,7 @@
public string FFmpegPath { get; set; }
public string FFprobePath { get; set; }
public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; }
}
}
@@ -11,20 +11,20 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.Name,
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
Project(profile.Resolution),
profile.NormalizeResolution,
profile.NormalizeVideo,
profile.VideoCodec,
profile.NormalizeVideoCodec,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.AudioCodec,
profile.NormalizeAudioCodec,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.AudioVolume,
profile.NormalizeLoudness,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeAudio);
profile.NormalizeAudio,
profile.FrameRate);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
@@ -22,12 +22,18 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath);
Option<int> defaultFFmpegProfileId =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
return new FFmpegSettingsViewModel
{
FFmpegPath = ffmpegPath.IfNone(string.Empty),
FFprobePath = ffprobePath.IfNone(string.Empty),
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0)
FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty),
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng")
};
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.HDHR.Commands
{
public record UpdateHDHRTunerCount(int TunerCount) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.HDHR.Commands
{
public class UpdateHDHRTunerCountHandler : MediatR.IRequestHandler<UpdateHDHRTunerCount, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
public UpdateHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdateHDHRTunerCount request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
Optional(request.TunerCount)
.Filter(tc => tc > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask();
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
_configElementRepository.Get(key).Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
}).ToUnit();
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public record GetHDHRTunerCount : IRequest<int>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
.Map(result => result.IfNone(2));
}
}
@@ -42,6 +42,7 @@ namespace ErsatzTV.Application.Images.Queries
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
};
@@ -1,8 +1,10 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands
@@ -11,9 +13,13 @@ namespace ErsatzTV.Application.Libraries.Commands
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ILibraryRepository _libraryRepository;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
{
_libraryRepository = libraryRepository;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibraryPath request,
@@ -22,8 +28,13 @@ namespace ErsatzTV.Application.Libraries.Commands
.MapT(DoDeletion)
.Bind(t => t.ToEitherAsync());
private Task<Unit> DoDeletion(LibraryPath libraryPath) =>
_libraryRepository.DeleteLocalPath(libraryPath.Id).ToUnit();
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
{
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
await _searchIndex.RemoveItems(ids);
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
@@ -18,7 +18,11 @@ namespace ErsatzTV.Application.Libraries.Queries
public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(list => list.Filter(ShouldIncludeLibrary).Map(ProjectToViewModel).ToList());
.Map(
list => list.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
.ThenBy(l => l.MediaKind)
.Map(ProjectToViewModel).ToList());
private static bool ShouldIncludeLibrary(Library library) =>
library switch
@@ -1,12 +1,12 @@
using System;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
public record LogEntryViewModel(
int Id,
DateTime Timestamp,
string Level,
LogEventLevel Level,
string Exception,
string RenderedMessage,
string Properties);
string Message);
}
+35 -6
View File
@@ -1,16 +1,45 @@
using ErsatzTV.Core.Domain;
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using Newtonsoft.Json.Linq;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
internal static class Mapper
{
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry) =>
new(
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
{
string message = logEntry.RenderedMessage;
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
{
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
{
var token = $"{{{property.Key}}}";
if (message.Contains(token))
{
message = message.Replace(token, property.Value.ToString());
}
var destructureToken = $"{{@{property.Key}}}";
if (message.Contains(destructureToken))
{
message = message.Replace(destructureToken, property.Value.ToString());
}
}
}
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
{
level = LogEventLevel.Debug;
}
return new LogEntryViewModel(
logEntry.Id,
logEntry.Timestamp,
logEntry.Level,
level,
logEntry.Exception,
logEntry.RenderedMessage,
logEntry.Properties);
message);
}
}
}
@@ -0,0 +1,5 @@
namespace ErsatzTV.Application.MediaCards
{
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) :
MediaCardViewModel(Id, Name, Role, Name, Thumb);
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardResultsViewModel(
int Count,
List<ArtistCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster);
}
@@ -7,5 +7,10 @@ namespace ErsatzTV.Application.MediaCards
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards);
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
}
+44 -13
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@@ -18,7 +17,7 @@ namespace ErsatzTV.Application.MediaCards
internal static TelevisionSeasonCardViewModel ProjectToViewModel(Season season) =>
new(
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty),
season.Id,
season.SeasonNumber,
GetSeasonName(season.SeasonNumber),
@@ -32,12 +31,17 @@ namespace ErsatzTV.Application.MediaCards
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty),
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
m => m.Title ?? string.Empty,
() => string.Empty),
episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.EpisodeNumber,
episodeMetadata.Title,
$"Episode {episodeMetadata.Episode.EpisodeNumber}",
episodeMetadata.Episode.EpisodeNumber.ToString(),
GetThumbnail(episodeMetadata),
episodeMetadata.Episode.EpisodeNumber.ToString());
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
GetThumbnail(episodeMetadata));
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
new(
@@ -47,20 +51,47 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.SortTitle,
GetPoster(movieMetadata));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
musicVideoMetadata.Title,
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
GetThumbnail(musicVideoMetadata));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head()) with
{
CustomIndex = GetCustomIndex(collection, m.Id)
}).ToList(),
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList());
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static SearchCardResultsViewModel ProjectToSearchResults(List<MediaItem> items) =>
new(
items.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
items.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList());
internal static ActorCardViewModel ProjectToViewModel(Actor actor) =>
new(actor.Id, actor.Name, actor.Role, actor.Artwork?.Path);
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
.Map(ci => ci.CustomIndex ?? 0)
.IfNone(0);
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";
@@ -1,4 +1,4 @@
namespace ErsatzTV.Application.MediaCards
{
public record MediaCardViewModel(string Title, string Subtitle, string SortTitle, string Poster);
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
}
@@ -1,6 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards);
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards, Option<SearchPageMap> PageMap);
}
@@ -2,10 +2,12 @@
{
public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardResultsViewModel(
int Count,
List<MusicVideoCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,19 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(
int MusicVideoId,
string Title,
string Subtitle,
string SortTitle,
string Plot,
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}
@@ -16,11 +16,11 @@ namespace ErsatzTV.Application.MediaCards.Queries
public GetCollectionCardsHandler(IMediaCollectionRepository collectionRepository) =>
_collectionRepository = collectionRepository;
public async Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
public Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
GetCollectionCards request,
CancellationToken cancellationToken) =>
(await _collectionRepository.GetCollectionWithItemsUntracked(request.Id))
.ToEither(BaseError.New("Unable to load collection"))
.Map(ProjectToViewModel);
_collectionRepository.GetCollectionWithItemsUntracked(request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(ProjectToViewModel);
}
}
@@ -1,6 +0,0 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetMovieCards(int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
}
@@ -1,30 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class
GetMovieCardsHandler : IRequestHandler<GetMovieCards, MovieCardResultsViewModel>
{
private readonly IMovieRepository _movieRepository;
public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository;
public async Task<MovieCardResultsViewModel> Handle(GetMovieCards request, CancellationToken cancellationToken)
{
int count = await _movieRepository.GetMovieCount();
List<MovieCardViewModel> results = await _movieRepository
.GetPagedMovies(request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MovieCardResultsViewModel(count, results);
}
}
}
@@ -0,0 +1,7 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetMusicVideoCards
(int ArtistId, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, MusicVideoCardResultsViewModel>
{
private readonly IMusicVideoRepository _musicVideoRepository;
public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) =>
_musicVideoRepository = musicVideoRepository;
public async Task<MusicVideoCardResultsViewModel> Handle(
GetMusicVideoCards request,
CancellationToken cancellationToken)
{
int count = await _musicVideoRepository.GetMusicVideoCount(request.ArtistId);
List<MusicVideoCardViewModel> results = await _musicVideoRepository
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MusicVideoCardResultsViewModel(count, results, None);
}
}
}
@@ -1,8 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetSearchCards(string Query) : IRequest<Either<BaseError, SearchCardResultsViewModel>>;
}
@@ -1,25 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetSearchCardsHandler : IRequestHandler<GetSearchCards, Either<BaseError, SearchCardResultsViewModel>>
{
private readonly ISearchRepository _searchRepository;
public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository;
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
GetSearchCards request,
CancellationToken cancellationToken) =>
Try(_searchRepository.SearchMediaItems(request.Query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
}
}
@@ -1,6 +0,0 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
}
@@ -1,33 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class
GetTelevisionShowCardsHandler : IRequestHandler<GetTelevisionShowCards, TelevisionShowCardResultsViewModel>
{
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) =>
_televisionRepository = televisionRepository;
public async Task<TelevisionShowCardResultsViewModel> Handle(
GetTelevisionShowCards request,
CancellationToken cancellationToken)
{
int count = await _televisionRepository.GetShowCount();
List<TelevisionShowCardViewModel> results = await _televisionRepository
.GetPagedShows(request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new TelevisionShowCardResultsViewModel(count, results);
}
}
}
@@ -7,14 +7,16 @@ namespace ErsatzTV.Application.MediaCards
int EpisodeId,
DateTime Aired,
string ShowTitle,
int ShowId,
int SeasonId,
int Episode,
string Title,
string Subtitle,
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
string Plot,
string Poster) : MediaCardViewModel(
EpisodeId,
Title,
Subtitle,
SortTitle,
$"Episode {Episode}",
$"Episode {Episode}",
Poster)
{
}
@@ -10,6 +10,7 @@
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,
@@ -1,6 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardResultsViewModel(int Count, List<TelevisionShowCardViewModel> Cards);
public record TelevisionShowCardResultsViewModel(
int Count,
List<TelevisionShowCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -2,6 +2,7 @@
{
public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddArtistToCollection
(int CollectionId, int ArtistId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,68 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddArtistToCollectionHandler : MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{
private readonly IArtistRepository _artistRepository;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddArtistToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IArtistRepository artistRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_artistRepository = artistRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddArtistToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddArtistRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddArtistRequest(AddArtistToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ArtistId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddArtistToCollection request) =>
(await CollectionMustExist(request), await ValidateArtist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddArtistToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateArtist(AddArtistToCollection request) =>
LoadArtist(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<Artist>> LoadArtist(AddArtistToCollection request) =>
_artistRepository.GetArtist(request.ArtistId);
}
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands
public class
AddEpisodeToCollectionHandler : MediatR.IRequestHandler<AddEpisodeToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddEpisodeToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +32,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionEpisodeRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request) =>
_mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId);
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddEpisodeToCollection request) =>
(await CollectionMustExist(request), await ValidateEpisode(request))
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToCollection
(
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,84 @@
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddItemsToCollectionHandler : MediatR.IRequestHandler<AddItemsToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddItemsToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddItemsToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddItemsRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.ToList();
if (await _mediaCollectionRepository.AddMediaItems(request.CollectionId, allItems))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request))
.Apply((_, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Movie does not exist"));
private Task<Validation<BaseError, Unit>> ValidateShows(AddItemsToCollection request) =>
_televisionRepository.AllShowsExist(request.ShowIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
}
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -7,17 +9,21 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddMovieToCollectionHandler : MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>>
public class
AddMovieToCollectionHandler : MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
public AddMovieToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository)
IMovieRepository movieRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddMoviesRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddMoviesRequest(AddMovieToCollection request) =>
_mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId);
private async Task<Unit> ApplyAddMoviesRequest(AddMovieToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddMovieToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request))
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddMusicVideoToCollection
(int CollectionId, int MusicVideoId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,68 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddMusicVideoToCollectionHandler : MediatR.IRequestHandler<AddMusicVideoToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
public AddMusicVideoToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMusicVideoRepository musicVideoRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_musicVideoRepository = musicVideoRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddMusicVideoToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddMusicVideoRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddMusicVideoRequest(AddMusicVideoToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MusicVideoId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddMusicVideoToCollection request) =>
(await CollectionMustExist(request), await ValidateMusicVideo(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddMusicVideoToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMusicVideo(AddMusicVideoToCollection request) =>
LoadMusicVideo(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<MusicVideo>> LoadMusicVideo(AddMusicVideoToCollection request) =>
_musicVideoRepository.GetMusicVideo(request.MusicVideoId);
}
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -7,17 +9,21 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddSeasonToCollectionHandler : MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>>
public class
AddSeasonToCollectionHandler : MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddSeasonToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionSeasonRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request) =>
await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId);
private async Task<Unit> ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddSeasonToCollection request) =>
(await CollectionMustExist(request), await ValidateSeason(request))
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddShowToCollectionHandler : MediatR.IRequestHandler<AddShowToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddShowToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +32,22 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionShowRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionShowRequest(AddShowToCollection request)
=> _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId);
private async Task<Unit> ApplyAddTelevisionShowRequest(AddShowToCollection request)
{
var result = new Unit();
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return result;
}
private async Task<Validation<BaseError, Unit>> Validate(AddShowToCollection request) =>
(await CollectionMustExist(request), await ValidateShow(request))
@@ -1,6 +1,8 @@
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -11,20 +13,25 @@ namespace ErsatzTV.Application.MediaCollections.Commands
public class
RemoveItemsFromCollectionHandler : MediatR.IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public RemoveItemsFromCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository) =>
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(collection => ApplyAddTelevisionEpisodeRequest(request, collection))
.MapT(collection => ApplyRemoveItemsRequest(request, collection))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionEpisodeRequest(
private async Task<Unit> ApplyRemoveItemsRequest(
RemoveItemsFromCollection request,
Collection collection)
{
@@ -34,9 +41,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
return itemsToRemove.Any()
? _mediaCollectionRepository.Update(collection).ToUnit()
: Task.FromResult(Unit.Default);
if (itemsToRemove.Any() && await _mediaCollectionRepository.Update(collection))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private Task<Validation<BaseError, Collection>> Validate(
@@ -1,8 +1,12 @@
using ErsatzTV.Core;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateCollection
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>;
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>
{
public Option<bool> UseCustomPlaybackOrder { get; set; } = None;
}
}
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateCollectionCustomOrder
(
int CollectionId,
List<MediaItemCustomOrder> MediaItemCustomOrders) : MediatR.IRequest<Either<BaseError, Unit>>;
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
}
@@ -0,0 +1,66 @@
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
UpdateCollectionCustomOrderHandler : MediatR.IRequestHandler<UpdateCollectionCustomOrder,
Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateCollectionCustomOrderHandler(
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateCollectionCustomOrder request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollectionCustomOrder request)
{
foreach (MediaItemCustomOrder updateItem in request.MediaItemCustomOrders)
{
Option<CollectionItem> maybeCollectionItem =
c.CollectionItems.FirstOrDefault(ci => ci.MediaItemId == updateItem.MediaItemId);
await maybeCollectionItem.IfSomeAsync(ci => ci.CustomIndex = updateItem.CustomIndex);
}
if (await _mediaCollectionRepository.Update(c))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private Task<Validation<BaseError, Collection>> Validate(UpdateCollectionCustomOrder request) =>
CollectionMustExist(request);
private Task<Validation<BaseError, Collection>> CollectionMustExist(
UpdateCollectionCustomOrder request) =>
_mediaCollectionRepository.Get(request.CollectionId)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
}
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -9,10 +11,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
public UpdateCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateCollection request,
@@ -21,10 +29,21 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection update)
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection request)
{
c.Name = update.Name;
await _mediaCollectionRepository.Update(c);
c.Name = request.Name;
await request.UseCustomPlaybackOrder.IfSomeAsync(
useCustomPlaybackOrder => c.UseCustomPlaybackOrder = useCustomPlaybackOrder);
if (await _mediaCollectionRepository.Update(c) && request.UseCustomPlaybackOrder.IsSome)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
@@ -3,6 +3,7 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
Id,
Name,
string.Empty,
Name,
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedMediaCollectionsViewModel(int TotalCount, List<MediaCollectionViewModel> Page);
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedCollections(int PageNum, int PageSize) : IRequest<PagedMediaCollectionsViewModel>;
}
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedCollectionsHandler : IRequestHandler<GetPagedCollections, PagedMediaCollectionsViewModel>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetPagedCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<PagedMediaCollectionsViewModel> Handle(
GetPagedCollections request,
CancellationToken cancellationToken)
{
int count = await _mediaCollectionRepository.CountAllCollections();
List<MediaCollectionViewModel> page = await _mediaCollectionRepository
.GetPagedCollections(request.PageNum, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedMediaCollectionsViewModel(count, page);
}
}
}
+1 -55
View File
@@ -1,5 +1,4 @@
using System;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems
{
@@ -8,59 +7,6 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => ProjectToSearchViewModel(e),
Movie m => ProjectToSearchViewModel(m),
_ => throw new ArgumentOutOfRangeException()
};
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Episode mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"TV Show",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Movie mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"Movie",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
private static string GetDisplayDuration(MediaItem mediaItem)
{
MediaVersion version = mediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return string.Format(
version.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
version.Duration);
}
// TODO: fix this when search is reimplemented
private static string GetLibraryName(MediaItem item) =>
"Library Name";
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using System.Globalization;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAllLanguageCodes : IRequest<List<CultureInfo>>;
}
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<CultureInfo>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<CultureInfo>> Handle(GetAllLanguageCodes request, CancellationToken cancellationToken)
{
var result = new List<CultureInfo>();
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
foreach (string code in allLanguageCodes)
{
Option<CultureInfo> maybeCulture = allCultures.Find(
ci => string.Equals(code, ci.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
await maybeCulture.IfSomeAsync(cultureInfo => result.Add(cultureInfo));
}
return result;
}
}
}
@@ -1,7 +0,0 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record SearchAllMediaItems(string SearchString) : IRequest<List<MediaItemSearchResultViewModel>>;
}
@@ -1,23 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class SearchAllMediaItemsHandler : IRequestHandler<SearchAllMediaItems, List<MediaItemSearchResultViewModel>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public SearchAllMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public Task<List<MediaItemSearchResultViewModel>>
Handle(SearchAllMediaItems request, CancellationToken cancellationToken) =>
_mediaItemRepository.Search(request.SearchString).Map(list => list.Map(ProjectToSearchViewModel).ToList());
}
}
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -7,6 +8,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
@@ -21,7 +23,9 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMediator _mediator;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@@ -29,14 +33,18 @@ namespace ErsatzTV.Application.MediaSources.Commands
IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IEntityLocker entityLocker,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
{
_libraryRepository = libraryRepository;
_configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_entityLocker = entityLocker;
_mediator = mediator;
_logger = logger;
}
@@ -58,32 +66,62 @@ namespace ErsatzTV.Application.MediaSources.Commands
{
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
var sw = new Stopwatch();
sw.Start();
for (var i = 0; i < localLibrary.Paths.Count; i++)
{
foreach (LibraryPath libraryPath in localLibrary.Paths)
LibraryPath libraryPath = localLibrary.Paths[i];
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
{
switch (localLibrary.MediaKind)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath);
await _movieFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath);
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
case LibraryMediaKind.MusicVideos:
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
}
libraryPath.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(libraryPath);
}
localLibrary.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(localLibrary);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of library {Name}",
localLibrary.Name);
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax));
}
sw.Stop();
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0));
_entityLocker.UnlockLibrary(localLibrary.Id);
return Unit.Default;
}
+33 -3
View File
@@ -1,5 +1,9 @@
using System.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Movies
@@ -13,8 +17,34 @@ namespace ErsatzTV.Application.Movies
metadata.Title,
metadata.Year?.ToString(),
metadata.Plot,
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
.Match(a => a.Path, string.Empty));
Artwork(metadata, ArtworkKind.Poster),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList(),
LanguagesForMovie(movie),
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id).Map(MediaCards.Mapper.ProjectToViewModel)
.ToList());
}
private static List<CultureInfo> LanguagesForMovie(Movie movie)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return movie.MediaVersions
.Map(mv => mv.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).Map(s => s.Language))
.Flatten()
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
}
}
+16 -2
View File
@@ -1,4 +1,18 @@
namespace ErsatzTV.Application.Movies
using System.Collections.Generic;
using System.Globalization;
using ErsatzTV.Application.MediaCards;
namespace ErsatzTV.Application.Movies
{
public record MovieViewModel(string Title, string Year, string Plot, string Poster);
public record MovieViewModel(
string Title,
string Year,
string Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors);
}
@@ -48,7 +48,7 @@ namespace ErsatzTV.Application.Playouts.Commands
}
private async Task<Validation<BaseError, Playout>> Validate(CreatePlayout request) =>
(await ChannelMustExist(request), await ProgramScheduleMustExist(request), ValidatePlayoutType(request))
(await ValidateChannel(request), await ProgramScheduleMustExist(request), ValidatePlayoutType(request))
.Apply(
(channel, programSchedule, playoutType) => new Playout
{
@@ -57,10 +57,19 @@ namespace ErsatzTV.Application.Playouts.Commands
ProgramSchedulePlayoutType = playoutType
});
private Task<Validation<BaseError, Channel>> ValidateChannel(CreatePlayout createPlayout) =>
ChannelMustExist(createPlayout).BindT(ChannelMustNotHavePlayouts);
private async Task<Validation<BaseError, Channel>> ChannelMustExist(CreatePlayout createPlayout) =>
(await _channelRepository.Get(createPlayout.ChannelId))
.ToValidation<BaseError>("Channel does not exist.");
private async Task<Validation<BaseError, Channel>> ChannelMustNotHavePlayouts(Channel channel) =>
Optional(await _channelRepository.CountPlayouts(channel.Id))
.Filter(count => count == 0)
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout.");
private async Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
CreatePlayout createPlayout) =>
(await _programScheduleRepository.GetWithPlayouts(createPlayout.ProgramScheduleId))
+22 -8
View File
@@ -24,15 +24,28 @@ namespace ErsatzTV.Application.Playouts
private static PlayoutProgramScheduleViewModel Project(ProgramSchedule programSchedule) =>
new(programSchedule.Id, programSchedule.Name);
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
private static string GetDisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
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]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
}
private static string GetDisplayDuration(MediaItem mediaItem)
{
@@ -40,6 +53,7 @@ namespace ErsatzTV.Application.Playouts
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -1,4 +1,4 @@
namespace ErsatzTV.Application.Playouts
{
public record PlayoutChannelViewModel(int Id, int Number, string Name);
public record PlayoutChannelViewModel(int Id, string Number, string Name);
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public record SignOutOfPlex : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public class SignOutOfPlexHandler : MediatR.IRequestHandler<SignOutOfPlex, Either<BaseError, Unit>>
{
private readonly IEntityLocker _entityLocker;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexSecretStore _plexSecretStore;
private readonly ISearchIndex _searchIndex;
public SignOutOfPlexHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IEntityLocker entityLocker,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_entityLocker = entityLocker;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
{
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
await _searchIndex.RemoveItems(ids);
await _plexSecretStore.DeleteAll();
_entityLocker.UnlockPlex();
return Unit.Default;
}
}
}
@@ -3,5 +3,6 @@ using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>;
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>,
IPlexBackgroundServiceRequest;
}
@@ -78,10 +78,10 @@ namespace ErsatzTV.Application.Plex.Commands
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();
connectionParameters.PlexMediaSource.Libraries.AddRange(toAdd);
toRemove.ForEach(c => connectionParameters.PlexMediaSource.Libraries.Remove(c));
return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource);
return _mediaSourceRepository.UpdateLibraries(
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
},
error =>
{

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