Compare commits

...

111 Commits

Author SHA1 Message Date
Jason Dove
e7ebb32a1d navigate to schedule items after creating new schedule (#118) 2021-03-30 11:15:31 +00:00
Jason Dove
9ea4459988 cache artwork async (#117) 2021-03-30 11:09:47 +00:00
Jason Dove
745b03af73 add custom title option to schedule items (#116) 2021-03-29 21:46:03 +00:00
Jason Dove
a62c4ecfcf fix playout builds using duration or multiple (#115) 2021-03-29 20:01:46 +00:00
Jason Dove
c48f0a7d51 don't require preferred language on channels (#114) 2021-03-29 14:43:09 +00:00
Jason Dove
f2c105174b fix stream selection for non-normalized playback (#113) 2021-03-29 14:42:20 +00:00
Jason Dove
076a88230e optimize local library scanning (#112) 2021-03-29 10:34:33 +00:00
Jason Dove
f06a04ed0e fix search index updates for local libraries (#111) 2021-03-29 10:28:38 +00:00
Jason Dove
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 Dove
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 Dove
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 Dove
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 Dove
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 Dove
22c4fe2a27 fix indexing shows without nfo metadata (#105) 2021-03-27 23:32:10 +00:00
Jason Dove
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 Dove
e42b000b7f fix plex sign in (#99) 2021-03-23 02:09:05 +00:00
Jason Dove
489f8d92ff properly store plex timestamps on update (#98) 2021-03-22 02:20:31 +00:00
Jason Dove
527d3c6e4b attach existing episodes to correct show and season when adding nfo metadata (#97) 2021-03-22 01:57:32 +00:00
Jason Dove
c33c037188 use folder.ext when poster.ext is not found for movies or shows (#96) 2021-03-21 21:50:31 +00:00
Jason Dove
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 Dove
00fdc272e9 remove plex items from index after sign out (#94) 2021-03-21 15:23:53 +00:00
Jason Dove
f04c18c810 index release date for searching (#93) 2021-03-21 01:49:10 +00:00
Jason Dove
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 Dove
cf9479d2a9 log search indexing errors and continue indexing (#91) 2021-03-20 21:33:37 +00:00
Jason Dove
b6331331b0 use default ffmpeg profile with new channels (#90) 2021-03-20 20:56:15 +00:00
Jason Dove
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 Dove
c0a6677861 optimize memory use during search index rebuild (#88) 2021-03-20 16:08:28 +00:00
Jason Dove
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 Dove
f46041305c add docs to schedule items page (#86) 2021-03-19 02:10:56 +00:00
Jason Dove
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 Dove
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 Dove
c5c28cb92d fix playback for media containing attached pictures (#83) 2021-03-18 01:49:30 +00:00
Jason Dove
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 Dove
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 Dove
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 Dove
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 Dove
898a21dcd9 clean up tables (#78)
* add plex library sorting options

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

* fix tests
2021-03-14 17:54:32 +00:00
Jason Dove
8b1f8dd36b support plex media with missing release date (#76) 2021-03-14 17:32:49 +00:00
Jason Dove
e9b26d6bdb fix plex async genre sync (#75) 2021-03-14 16:27:06 +00:00
Jason Dove
79b2e9dbfe fix plex movie scanning performance (#74) 2021-03-14 16:25:05 +00:00
Jason Dove
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 Dove
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 Dove
a13f964200 add movie poster to xmltv (#71) 2021-03-13 11:55:15 +00:00
Jason Dove
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 Dove
50fafbfb98 remove duplicate subtitle tag from xmltv (#69) 2021-03-13 03:03:59 +00:00
Jason Dove
914d128610 set title, subtitle, category in xmltv (#68) 2021-03-13 02:47:34 +00:00
Jason Dove
1a2f36f561 fix loading seasons with empty episode plot (#67) 2021-03-13 02:15:35 +00:00
Jason Dove
96887fbd79 properly set sort title on new tv shows (#66) 2021-03-13 00:51:08 +00:00
Jason Dove
c07e2afff4 fix playouts that use shows or seasons (#65) 2021-03-13 00:50:17 +00:00
Jason Dove
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 Dove
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 Dove
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 Dove
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 Dove
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 Dove
e624627ae1 fix editing channel number (#59) 2021-03-10 19:15:08 +00:00
Jason Dove
46bcf03d9a regenerate sort titles for all media items (#58) 2021-03-10 18:06:27 +00:00
Jason Dove
ab9a8493d9 fix tv show sorting (#57) 2021-03-10 14:14:33 +00:00
Jason Dove
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 Dove
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 Dove
54da3a3159 fix channel sorting (#54) 2021-03-09 03:40:13 +00:00
Jason Dove
d53a2f8bbf add subchannel support (#53) 2021-03-09 02:46:22 +00:00
Jason Dove
c2cbb1d5ff fix collection item sorting in ui (#52) 2021-03-09 00:38:18 +00:00
Jason Dove
bd231d57a7 fix vaapi pipeline with mpeg4 content (#51) 2021-03-09 00:24:08 +00:00
Jason Dove
77cb2c2270 include tzdata in docker to support TZ env var again (#50) 2021-03-08 13:41:59 +00:00
Jason Dove
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 Dove
9841640128 add m3u codec hints for channels app (#48) 2021-03-08 02:36:27 +00:00
Jason Dove
a256095e12 enforce unique schedule name (#47) 2021-03-07 21:46:48 +00:00
Jason Dove
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 Dove
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 Dove
0f052631a4 try to fix vaapi by always using nv12 or vaapi pixel format (#42) 2021-03-07 12:22:18 +00:00
Jason Dove
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 Dove
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 Dove
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 Dove
c6ea2c88df remember selected collection (#36) 2021-03-04 03:22:15 +00:00
Jason Dove
3ed83a276f fix database migration (#35) 2021-03-02 18:32:14 +00:00
Jason Dove
09578beef5 prioritize xmltv_ns over onscreen for episode-num 2021-03-01 21:28:49 -06:00
Jason Dove
df94a9e704 fix xmltv crash with missing episode metadata 2021-03-01 21:26:22 -06:00
Jason Dove
f281d9fca5 Interface improvements (#34)
* fix plex library synchronization

* add basic plex movie synchronization

* proxy plex movie thumbnails (posters)

* add plex path replacements

* use transcoded plex artwork

* remove unsynchronized plex movies on save; queue plex library scan on save

* log plex path replacements

* prefer buttons instead of menus

* lock plex libraries before sync

* add movie to collection from paged view

* fix plex import memory use; quick add seasons/shows

* quick add episode to collection

* add favicon

* add search page

* disable plex for now
2021-03-02 02:54:23 +00:00
Jason Dove
aef486103e rebuild all playouts because of time zone change in db (#33) 2021-02-28 19:41:23 +00:00
Jason Dove
9568a0e22f Fix channel logo migration (#32)
* fix channel logo migration

* add onscreen episode-num to epg

* bump log level for nfo parse failures
2021-02-28 19:15:02 +00:00
Jason Dove
f392bab118 Database redesign (#31)
* starting database redesign

* set season and episode numbers

* use datetimes in db (utc); update movie metadata

* get movie cards from new table

* copy show/episode metadata

* remove old movie metadata type

* rename new movie metadata type

* code cleanup

* start to remove old television classes

* remove old television tables from database

* fix playout building

* fix collection views

* fix show/season views

* clean up movie metadata table

* fix scanner tests

* add libraries ui

* code cleanup

* fix movie scanning/metadata

* add library scan button to ui

* delete library path from ui

* temp disable movie scanning

* remove orphan media items and prevent duplicate paths

* attach artwork to metadata

* fix split show/season display

* fix television artwork

* store year distinct from release date

* fix collections ui

* code cleanup

* add library paths from ui

* fix adding to collections from ui

* fix schedule items loading

* schedule editing works again

* remove some todos

* more cleanup

* fix unit tests

* fix episode sorting

* fix deleting show library paths

* remove unused class

* fix playout list in ui

* fix log viewer

* start to use version/file instead of statistics

* clean up old columns

* fix playout display (time zone)

* fix playback

* fix channel guide time zone

* cascade more deletes

* fix compiler warnings

* fix adding new seasons

* use artwork for channel logo

* clean cache folder on startup (move channel logos, delete everything else)

* log database migration

* update homepage docs for libraries

* fix adding new channel with logo

* fix episode numbers in epg
2021-02-28 17:48:01 +00:00
Jason Dove
e25b9edd01 add github funding 2021-02-23 15:43:17 -06:00
Jason Dove
e2cea69f25 use dapper in a few places (#29)
* use dapper in a few places

* use single dapper queries
2021-02-23 11:08:48 +00:00
Jason Dove
38ab6c00ab media source scan interval (#28)
* scan media sources once every six hours

* cleanup

* force scan from ui
2021-02-22 19:01:58 +00:00
Jason Dove
871a031467 rework television media (#26)
* rework television media

* refactor poster saving

* television and movie views are working again

* remove dead code

* use paper styling for all cards

* add show poster, plot to seasons page

* remove missing shows; cleanup interfaces

* fix split show display (same show in different folders/sources)

* add placeholder "add to schedule" button

* no more duplicate television shows, even with the same show split across sources

* stop releasing CLI for now

* use season number as season placeholder

* add television shows to collections

* add television seasons to collections

* add television episodes to collections

* add movies to collections

* remove movies, shows, seasons, episodes from collections

* fix page width and menus

* fix buffer size defaults

* fix chronological episode ordering

* allow deleting media collections

* don't get stuck building a playout with an empty collection

* schedule editing and playouts work again

* minor cleanup

* remove dead code

* fix bugs with viewing movies as they are loading

* add scanner tests; support nested movie folders

* update collections docs

* rearrange order of schedule items

* add show and season to schedule

* delete schedules that use legacy collections, reset all posters

* move cleanup to new migration

* load fallback metadata when nfo fails; don't require metadata in ui

* update readme and screenshots
2021-02-22 00:54:41 +00:00
Jason Dove
98cf922b3c fix sort title for ae (e) (#27) 2021-02-19 00:12:47 +00:00
693 changed files with 115999 additions and 7281 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: jasongdove
custom: "https://www.paypal.me/jasongdove"

View File

@@ -4,9 +4,8 @@ on:
push:
branches:
- main
- develop
jobs:
build:
build_and_test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -32,3 +31,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 ci]')
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

View File

@@ -39,24 +39,24 @@ jobs:
# Define some variables for things we need
tag=$(git describe --tags --abbrev=0)
release_name="ErsatzTV-$tag-${{ matrix.target }}"
release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
#release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
#dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
# Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*"
#7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*"
else
tar czvf "${release_name}.tar.gz" "$release_name"
tar czvf "${release_name_cli}.tar.gz" "$release_name_cli"
#tar czvf "${release_name_cli}.tar.gz" "$release_name_cli"
fi
# Delete output directory
rm -r "$release_name"
rm -r "$release_name_cli"
#rm -r "$release_name_cli"
- name: Publish
uses: softprops/action-gh-release@v1
@@ -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

View File

@@ -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);
}

View File

@@ -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>>;
}

View File

@@ -1,4 +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;
@@ -7,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
{
@@ -34,21 +39,61 @@ 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) => new Channel(Guid.NewGuid())
(name, number, ffmpegProfileId, preferredLanguageCode) =>
{
Name = name, Number = number, FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
{
artwork.Add(
new Artwork
{
Path = request.Logo,
ArtworkKind = ArtworkKind.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
return new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
};
});
private Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
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))

View File

@@ -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>>;
}

View File

@@ -1,4 +1,9 @@
using System.Threading;
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;
using ErsatzTV.Core.Domain;
@@ -6,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
{
@@ -27,15 +33,43 @@ namespace ErsatzTV.Application.Channels.Commands
c.Name = update.Name;
c.Number = update.Number;
c.FFmpegProfileId = update.FFmpegProfileId;
c.Logo = update.Logo;
c.PreferredLanguageCode = update.PreferredLanguageCode;
if (!string.IsNullOrWhiteSpace(update.Logo))
{
c.Artwork ??= new List<Artwork>();
Option<Artwork> maybeLogo =
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
maybeLogo.Match(
artwork =>
{
artwork.Path = update.Logo;
artwork.DateUpdated = DateTime.UtcNow;
},
() =>
{
var artwork = new Artwork
{
Path = update.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
};
c.Artwork.Add(artwork);
});
}
c.StreamingMode = update.StreamingMode;
await _channelRepository.Update(c);
return ProjectToViewModel(c);
}
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)
@@ -45,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);
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");
}
}

View File

@@ -1,10 +1,23 @@
using ErsatzTV.Core.Domain;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Channels
{
internal static class Mapper
{
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
new(channel.Id, channel.Number, channel.Name, channel.FFmpegProfileId, channel.Logo, channel.StreamingMode);
new(
channel.Id,
channel.Number,
channel.Name,
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty);
}
}

View File

@@ -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();
}
}

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -8,6 +9,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
string VideoCodec,

View File

@@ -41,6 +41,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
Name = name,
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
ResolutionId = resolutionId,
NormalizeResolution = request.NormalizeResolution,
VideoCodec = request.VideoCodec,

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -9,6 +10,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
string VideoCodec,

View File

@@ -35,6 +35,7 @@ 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.VideoCodec = update.VideoCodec;

View File

@@ -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>>;
}

View File

@@ -1,28 +1,77 @@
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 _configElementRepository.Get(ConfigElementKey.FFmpegPath).Match(
ce =>
{
ce.Value = request.Settings.FFmpegPath;
@@ -35,7 +84,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_configElementRepository.Add(ce);
});
ffprobePath.Match(
await _configElementRepository.Get(ConfigElementKey.FFprobePath).Match(
ce =>
{
ce.Value = request.Settings.FFprobePath;
@@ -48,7 +97,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_configElementRepository.Add(ce);
});
defaultFFmpegProfileId.Match(
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId).Match(
ce =>
{
ce.Value = request.Settings.DefaultFFmpegProfileId.ToString();
@@ -64,7 +113,45 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_configElementRepository.Add(ce);
});
return Unit.Value;
await _configElementRepository.Get(ConfigElementKey.FFmpegSaveReports).Match(
ce =>
{
ce.Value = request.Settings.SaveReports.ToString();
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{
Key = ConfigElementKey.FFmpegSaveReports.Key,
Value = request.Settings.SaveReports.ToString()
};
_configElementRepository.Add(ce);
});
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
}
await _configElementRepository.Get(ConfigElementKey.FFmpegPreferredLanguageCode).Match(
ce =>
{
ce.Value = request.Settings.PreferredLanguageCode;
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{
Key = ConfigElementKey.FFmpegPreferredLanguageCode.Key,
Value = request.Settings.PreferredLanguageCode
};
_configElementRepository.Add(ce);
});
return Unit.Default;
}
}
}

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles
{
@@ -7,6 +8,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
ResolutionViewModel Resolution,
bool NormalizeResolution,
string VideoCodec,

View File

@@ -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; }
}
}

View File

@@ -11,6 +11,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.Name,
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
Project(profile.Resolution),
profile.NormalizeResolution,
profile.VideoCodec,

View File

@@ -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)
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0),
SaveReports = saveReports.IfNone(false),
PreferredLanguageCode = preferredLanguageCode.IfNone("eng")
};
}
}

View File

@@ -1,9 +0,0 @@
namespace ErsatzTV.Application
{
public interface IMediaCard
{
string Title { get; }
string SortTitle { get; }
string Subtitle { get; }
}
}

View File

@@ -1,9 +1,10 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Images.Commands
{
// ReSharper disable once SuggestBaseTypeForParameter
public record SaveImageToDisk(byte[] Buffer) : IRequest<Either<BaseError, string>>;
public record SaveArtworkToDisk(byte[] Buffer, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
}

View File

@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Images.Commands
{
public class SaveArtworkToDiskHandler : IRequestHandler<SaveArtworkToDisk, Either<BaseError, string>>
{
private readonly IImageCache _imageCache;
public SaveArtworkToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public Task<Either<BaseError, string>> Handle(SaveArtworkToDisk request, CancellationToken cancellationToken) =>
_imageCache.SaveArtworkToCache(request.Buffer, request.ArtworkKind);
}
}

View File

@@ -1,20 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Images.Commands
{
public class SaveImageToDiskHandler : IRequestHandler<SaveImageToDisk, Either<BaseError, string>>
{
private readonly IImageCache _imageCache;
public SaveImageToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public Task<Either<BaseError, string>> Handle(
SaveImageToDisk request,
CancellationToken cancellationToken) => _imageCache.SaveImage(request.Buffer);
}
}

View File

@@ -1,8 +1,10 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Images.Queries
{
public record GetImageContents(string FileName) : IRequest<Either<BaseError, ImageViewModel>>;
public record GetImageContents
(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<Either<BaseError, ImageViewModel>>;
}

View File

@@ -3,6 +3,8 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
@@ -13,9 +15,14 @@ namespace ErsatzTV.Application.Images.Queries
public class GetImageContentsHandler : IRequestHandler<GetImageContents, Either<BaseError, ImageViewModel>>
{
private static readonly MimeTypes MimeTypes = new();
private readonly IImageCache _imageCache;
private readonly IMemoryCache _memoryCache;
public GetImageContentsHandler(IMemoryCache memoryCache) => _memoryCache = memoryCache;
public GetImageContentsHandler(IImageCache imageCache, IMemoryCache memoryCache)
{
_imageCache = imageCache;
_memoryCache = memoryCache;
}
public async Task<Either<BaseError, ImageViewModel>> Handle(
GetImageContents request,
@@ -29,8 +36,26 @@ namespace ErsatzTV.Application.Images.Queries
{
entry.SlidingExpiration = TimeSpan.FromHours(1);
string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, request.FileName);
string subfolder = request.FileName.Substring(0, 2);
string baseFolder = request.ArtworkKind switch
{
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string fileName = Path.Combine(baseFolder, request.FileName);
byte[] contents = await File.ReadAllBytesAsync(fileName, cancellationToken);
if (request.MaxHeight.HasValue)
{
Either<BaseError, byte[]> resizeResult = await _imageCache
.ResizeImage(contents, request.MaxHeight.Value);
resizeResult.IfRight(result => contents = result);
}
MimeType mimeType = MimeTypes.GetMimeType(contents);
return new ImageViewModel(contents, mimeType.Name);
});

View File

@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Commands
{
public record CreateLocalLibraryPath
(int LibraryId, string Path) : IRequest<Either<BaseError, LocalLibraryPathViewModel>>;
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static LanguageExt.Prelude;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Commands
{
public class CreateLocalLibraryPathHandler : IRequestHandler<CreateLocalLibraryPath,
Either<BaseError, LocalLibraryPathViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) =>
_libraryRepository = mediaSourceRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistLocalLibraryPath).Bind(v => v.ToEitherAsync());
private Task<LocalLibraryPathViewModel> PersistLocalLibraryPath(LibraryPath p) =>
_libraryRepository.Add(p).Map(ProjectToViewModel);
private Task<Validation<BaseError, LibraryPath>> Validate(CreateLocalLibraryPath request) =>
ValidateFolder(request)
.MapT(
folder =>
new LibraryPath
{
LibraryId = request.LibraryId,
Path = folder
});
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalLibraryPath request)
{
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path)
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands
{
public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,45 @@
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
{
public class
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ILibraryRepository _libraryRepository;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
{
_libraryRepository = libraryRepository;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibraryPath request,
CancellationToken cancellationToken) =>
MediaSourceMustExist(request)
.MapT(DoDeletion)
.Bind(t => t.ToEitherAsync());
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))
.HeadOrNone()
.ToValidation<BaseError>(
$"Local library path {request.LocalLibraryPathId} does not exist.");
}
}

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind);
}

View File

@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Libraries
{
public record LocalLibraryPathViewModel(int Id, int LibraryId, string Path);
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
: LibraryViewModel("Local", Id, Name, MediaKind);
}

View File

@@ -0,0 +1,22 @@
using System;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
internal static class Mapper
{
public static LibraryViewModel ProjectToViewModel(Library library) =>
library switch
{
LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
_ => throw new ArgumentOutOfRangeException(nameof(library))
};
public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) =>
new(library.Id, library.Name, library.MediaKind);
public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) =>
new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path);
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
: LibraryViewModel("Plex", Id, Name, MediaKind);
}

View File

@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record CountMediaItemsByLibraryPath(int LibraryPathId) : IRequest<int>;
}

View File

@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public class CountMediaItemsByLibraryPathHandler : IRequestHandler<CountMediaItemsByLibraryPath, int>
{
private readonly ILibraryRepository _libraryRepository;
public CountMediaItemsByLibraryPathHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<int> Handle(CountMediaItemsByLibraryPath request, CancellationToken cancellationToken) =>
_libraryRepository.CountMediaItemsByPath(request.LibraryPathId);
}
}

View File

@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record GetAllLibraries : IRequest<List<LibraryViewModel>>;
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetAllLibrariesHandler : IRequestHandler<GetAllLibraries, List<LibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetAllLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(list => list.Filter(ShouldIncludeLibrary).Map(ProjectToViewModel).ToList());
private static bool ShouldIncludeLibrary(Library library) =>
library switch
{
LocalLibrary => true,
PlexLibrary plex => plex.ShouldSyncItems,
_ => false
};
}
}

View File

@@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record GetLocalLibraryById(int LibraryId) : IRequest<Option<LocalLibraryViewModel>>;
}

View File

@@ -0,0 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetLocalLibraryByIdHandler : IRequestHandler<GetLocalLibraryById, Option<LocalLibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetLocalLibraryByIdHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<Option<LocalLibraryViewModel>> Handle(
GetLocalLibraryById request,
CancellationToken cancellationToken) =>
_libraryRepository.GetLocal(request.LibraryId).MapT(ProjectToViewModel);
}
}

View File

@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record GetLocalLibraryPaths(int LocalLibraryId) : IRequest<List<LocalLibraryPathViewModel>>;
}

View File

@@ -0,0 +1,25 @@
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.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetLocalLibraryPathsHandler : IRequestHandler<GetLocalLibraryPaths, List<LocalLibraryPathViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetLocalLibraryPathsHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<List<LocalLibraryPathViewModel>> Handle(
GetLocalLibraryPaths request,
CancellationToken cancellationToken) =>
_libraryRepository.GetLocalPaths(request.LocalLibraryId)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards
{
public record CollectionCardResultsViewModel(
string Name,
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards
{
internal static class Mapper
{
internal static TelevisionShowCardViewModel ProjectToViewModel(ShowMetadata showMetadata) =>
new(
showMetadata.ShowId,
showMetadata.Title,
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata));
internal static TelevisionSeasonCardViewModel ProjectToViewModel(Season season) =>
new(
season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty),
season.Id,
season.SeasonNumber,
GetSeasonName(season.SeasonNumber),
string.Empty,
GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
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,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
GetThumbnail(episodeMetadata));
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
new(
movieMetadata.MovieId,
movieMetadata.Title,
movieMetadata.Year?.ToString(),
movieMetadata.SortTitle,
GetPoster(movieMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
collection.Name,
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()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
.Map(ci => ci.CustomIndex ?? 0)
.IfNone(0);
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());
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";
private static string GetPoster(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
.Match(a => a.Path, string.Empty);
private static string GetThumbnail(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail))
.Match(a => a.Path, string.Empty);
}
}

View File

@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCards
{
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
}

View File

@@ -0,0 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards, Option<SearchPageMap> PageMap);
}

View File

@@ -0,0 +1,13 @@
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}

View File

@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetCollectionCards(int Id) : IRequest<Either<BaseError, CollectionCardResultsViewModel>>;
}

View File

@@ -0,0 +1,26 @@
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;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetCollectionCardsHandler : IRequestHandler<GetCollectionCards,
Either<BaseError, CollectionCardResultsViewModel>>
{
private readonly IMediaCollectionRepository _collectionRepository;
public GetCollectionCardsHandler(IMediaCollectionRepository collectionRepository) =>
_collectionRepository = collectionRepository;
public Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
GetCollectionCards request,
CancellationToken cancellationToken) =>
_collectionRepository.GetCollectionWithItemsUntracked(request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(ProjectToViewModel);
}
}

View File

@@ -0,0 +1,7 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetTelevisionEpisodeCards
(int TelevisionSeasonId, int PageNumber, int PageSize) : IRequest<TelevisionEpisodeCardResultsViewModel>;
}

View File

@@ -0,0 +1,34 @@
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
GetTelevisionEpisodeCardsHandler : IRequestHandler<GetTelevisionEpisodeCards,
TelevisionEpisodeCardResultsViewModel>
{
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionEpisodeCardsHandler(ITelevisionRepository televisionRepository) =>
_televisionRepository = televisionRepository;
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
GetTelevisionEpisodeCards request,
CancellationToken cancellationToken)
{
int count = await _televisionRepository.GetEpisodeCount(request.TelevisionSeasonId);
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new TelevisionEpisodeCardResultsViewModel(count, results);
}
}
}

View File

@@ -0,0 +1,7 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetTelevisionSeasonCards
(int TelevisionShowId, int PageNumber, int PageSize) : IRequest<TelevisionSeasonCardResultsViewModel>;
}

View File

@@ -0,0 +1,34 @@
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
GetTelevisionSeasonCardsHandler : IRequestHandler<GetTelevisionSeasonCards, TelevisionSeasonCardResultsViewModel
>
{
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionSeasonCardsHandler(ITelevisionRepository televisionRepository) =>
_televisionRepository = televisionRepository;
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
GetTelevisionSeasonCards request,
CancellationToken cancellationToken)
{
int count = await _televisionRepository.GetSeasonCount(request.TelevisionShowId);
List<TelevisionSeasonCardViewModel> results = await _televisionRepository
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new TelevisionSeasonCardResultsViewModel(count, results);
}
}
}

View File

@@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards
{
public record SearchCardResultsViewModel(
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards);
}

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionEpisodeCardResultsViewModel(int Count, List<TelevisionEpisodeCardViewModel> Cards);
}

View File

@@ -0,0 +1,23 @@
using System;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionEpisodeCardViewModel
(
int EpisodeId,
DateTime Aired,
string ShowTitle,
int ShowId,
int SeasonId,
int Episode,
string Title,
string Plot,
string Poster) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
$"Episode {Episode}",
Poster)
{
}
}

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionSeasonCardResultsViewModel(int Count, List<TelevisionSeasonCardViewModel> Cards);
}

View File

@@ -0,0 +1,20 @@
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionSeasonCardViewModel
(
string ShowTitle,
int TelevisionSeasonId,
int TelevisionSeasonNumber,
string Title,
string Subtitle,
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,
Poster)
{
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardResultsViewModel(
int Count,
List<TelevisionShowCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}

View File

@@ -0,0 +1,12 @@
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster)
{
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddEpisodeToCollection(int CollectionId, int EpisodeId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,67 @@
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;
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,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddEpisodeToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddTelevisionEpisodeRequest(request))
.Bind(v => v.ToEitherAsync());
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))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddEpisodeToCollection request) =>
_mediaCollectionRepository.Get(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateEpisode(AddEpisodeToCollection request) =>
LoadTelevisionEpisode(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
private Task<Option<int>> LoadTelevisionEpisode(AddEpisodeToCollection request) =>
_televisionRepository.GetEpisode(request.EpisodeId).MapT(e => e.Id);
}
}

View File

@@ -0,0 +1,9 @@
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) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,80 @@
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)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
{
// 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"));
}
}

View File

@@ -1,9 +0,0 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToSimpleMediaCollection
(int MediaCollectionId, List<int> ItemIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -1,79 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddItemsToSimpleMediaCollectionHandler : MediatR.IRequestHandler<AddItemsToSimpleMediaCollection,
Either<BaseError, Unit>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMediaItemRepository _mediaItemRepository;
public AddItemsToSimpleMediaCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMediaItemRepository mediaItemRepository)
{
_mediaCollectionRepository = mediaCollectionRepository;
_mediaItemRepository = mediaItemRepository;
}
public Task<Either<BaseError, Unit>> Handle(
AddItemsToSimpleMediaCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(ApplyAddItemsRequest)
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddItemsRequest(RequestParameters parameters)
{
foreach (MediaItem item in parameters.ItemsToAdd.Where(
item => parameters.Collection.Items.All(i => i.Id != item.Id)))
{
parameters.Collection.Items.Add(item);
}
await _mediaCollectionRepository.Update(parameters.Collection);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>>
Validate(AddItemsToSimpleMediaCollection request) =>
(await SimpleMediaCollectionMustExist(request), await ValidateItems(request))
.Apply(
(simpleMediaCollectionToUpdate, itemsToAdd) =>
new RequestParameters(simpleMediaCollectionToUpdate, itemsToAdd));
private Task<Validation<BaseError, SimpleMediaCollection>> SimpleMediaCollectionMustExist(
AddItemsToSimpleMediaCollection updateSimpleMediaCollection) =>
_mediaCollectionRepository.GetSimpleMediaCollection(updateSimpleMediaCollection.MediaCollectionId)
.Map(v => v.ToValidation<BaseError>("SimpleMediaCollection does not exist."));
private Task<Validation<BaseError, List<MediaItem>>> ValidateItems(
AddItemsToSimpleMediaCollection request) =>
LoadAllMediaItems(request)
.Map(v => v.ToValidation<BaseError>("MediaItem does not exist"));
private async Task<Option<List<MediaItem>>> LoadAllMediaItems(AddItemsToSimpleMediaCollection request)
{
var items = (await request.ItemIds.Map(async id => await _mediaItemRepository.Get(id)).Sequence())
.ToList();
if (items.Any(i => i.IsNone))
{
return None;
}
return items.Somes().ToList();
}
private record RequestParameters(SimpleMediaCollection Collection, List<MediaItem> ItemsToAdd);
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddMovieToCollection(int CollectionId, int MovieId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -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
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,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddMovieToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddMoviesRequest(request))
.Bind(v => v.ToEitherAsync());
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))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddMovieToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddMovieToCollection request) =>
LoadMovie(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Movie does not exist"));
private Task<Option<Movie>> LoadMovie(AddMovieToCollection request) =>
_movieRepository.GetMovie(request.MovieId);
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddSeasonToCollection(int CollectionId, int SeasonId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,69 @@
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
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,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddSeasonToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddTelevisionSeasonRequest(request))
.Bind(v => v.ToEitherAsync());
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))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddSeasonToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateSeason(AddSeasonToCollection request) =>
LoadTelevisionSeason(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
private Task<Option<Season>> LoadTelevisionSeason(
AddSeasonToCollection request) =>
_televisionRepository.GetSeason(request.SeasonId);
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddShowToCollection(int CollectionId, int ShowId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,69 @@
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 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,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddShowToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddTelevisionShowRequest(request))
.Bind(v => v.ToEitherAsync());
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))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddShowToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateShow(AddShowToCollection request) =>
LoadTelevisionShow(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
private Task<Option<Show>> LoadTelevisionShow(AddShowToCollection request) =>
_televisionRepository.GetShow(request.ShowId);
}
}

View File

@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateCollection(string Name) : IRequest<Either<BaseError, MediaCollectionViewModel>>;
}

View File

@@ -12,28 +12,33 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateSimpleMediaCollectionHandler : IRequestHandler<CreateSimpleMediaCollection,
Either<BaseError, MediaCollectionViewModel>>
public class
CreateCollectionHandler : IRequestHandler<CreateCollection, Either<BaseError, MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public CreateSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
public CreateCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Either<BaseError, MediaCollectionViewModel>> Handle(
CreateSimpleMediaCollection request,
CreateCollection request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistCollection).Bind(v => v.ToEitherAsync());
private Task<MediaCollectionViewModel> PersistCollection(SimpleMediaCollection c) =>
private Task<MediaCollectionViewModel> PersistCollection(Collection c) =>
_mediaCollectionRepository.Add(c).Map(ProjectToViewModel);
private Task<Validation<BaseError, SimpleMediaCollection>> Validate(CreateSimpleMediaCollection request) =>
ValidateName(request).MapT(name => new SimpleMediaCollection { Name = name });
private Task<Validation<BaseError, Collection>> Validate(CreateCollection request) =>
ValidateName(request).MapT(
name => new Collection
{
Name = name,
MediaItems = new List<MediaItem>()
});
private async Task<Validation<BaseError, string>> ValidateName(CreateSimpleMediaCollection createCollection)
private async Task<Validation<BaseError, string>> ValidateName(CreateCollection createCollection)
{
List<string> allNames = await _mediaCollectionRepository.GetSimpleMediaCollections()
List<string> allNames = await _mediaCollectionRepository.GetAll()
.Map(list => list.Map(c => c.Name).ToList());
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
@@ -41,7 +46,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var result2 = Optional(createCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("Media collection name must be unique");
.ToValidation<BaseError>("Collection name must be unique");
return (result1, result2).Apply((_, _) => createCollection.Name);
}

View File

@@ -1,9 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateSimpleMediaCollection
(string Name) : IRequest<Either<BaseError, MediaCollectionViewModel>>;
}

View File

@@ -5,5 +5,5 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteSimpleMediaCollection(int SimpleMediaCollectionId) : IRequest<Either<BaseError, Task>>;
public record DeleteCollection(int CollectionId) : IRequest<Either<BaseError, Task>>;
}

View File

@@ -7,28 +7,27 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
DeleteSimpleMediaCollectionHandler : IRequestHandler<DeleteSimpleMediaCollection, Either<BaseError, Task>>
public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<BaseError, Task>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public DeleteSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
public DeleteCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<Either<BaseError, Task>> Handle(
DeleteSimpleMediaCollection request,
DeleteCollection request,
CancellationToken cancellationToken) =>
(await SimpleMediaCollectionMustExist(request))
(await CollectionMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
private Task DoDeletion(int mediaCollectionId) => _mediaCollectionRepository.Delete(mediaCollectionId);
private async Task<Validation<BaseError, int>> SimpleMediaCollectionMustExist(
DeleteSimpleMediaCollection deleteMediaCollection) =>
(await _mediaCollectionRepository.GetSimpleMediaCollection(deleteMediaCollection.SimpleMediaCollectionId))
private async Task<Validation<BaseError, int>> CollectionMustExist(
DeleteCollection deleteMediaCollection) =>
(await _mediaCollectionRepository.Get(deleteMediaCollection.CollectionId))
.ToValidation<BaseError>(
$"SimpleMediaCollection {deleteMediaCollection.SimpleMediaCollectionId} does not exist.")
$"Collection {deleteMediaCollection.CollectionId} does not exist.")
.Map(c => c.Id);
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record RemoveItemsFromCollection(int MediaCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>
{
public List<int> MediaItemIds { get; set; } = new();
}
}

View File

@@ -0,0 +1,65 @@
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
RemoveItemsFromCollectionHandler : MediatR.IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public RemoveItemsFromCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(collection => ApplyRemoveItemsRequest(request, collection))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyRemoveItemsRequest(
RemoveItemsFromCollection request,
Collection collection)
{
var itemsToRemove = collection.MediaItems
.Filter(m => request.MediaItemIds.Contains(m.Id))
.ToList();
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
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(
RemoveItemsFromCollection request) =>
CollectionMustExist(request);
private Task<Validation<BaseError, Collection>> CollectionMustExist(
RemoveItemsFromCollection updateCollection) =>
_mediaCollectionRepository.GetCollectionWithItems(updateCollection.MediaCollectionId)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
}
}

View File

@@ -1,11 +0,0 @@
using System.Collections.Generic;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record ReplaceSimpleMediaCollectionItems
(int MediaCollectionId, List<int> MediaItemIds) : IRequest<Either<BaseError, List<MediaItemViewModel>>>;
}

View File

@@ -1,65 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class ReplaceSimpleMediaCollectionItemsHandler : IRequestHandler<ReplaceSimpleMediaCollectionItems,
Either<BaseError, List<MediaItemViewModel>>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMediaItemRepository _mediaItemRepository;
public ReplaceSimpleMediaCollectionItemsHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMediaItemRepository mediaItemRepository)
{
_mediaCollectionRepository = mediaCollectionRepository;
_mediaItemRepository = mediaItemRepository;
}
public Task<Either<BaseError, List<MediaItemViewModel>>> Handle(
ReplaceSimpleMediaCollectionItems request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(mediaItems => PersistItems(request, mediaItems))
.Bind(v => v.ToEitherAsync());
private async Task<List<MediaItemViewModel>> PersistItems(
ReplaceSimpleMediaCollectionItems request,
List<MediaItem> mediaItems)
{
await _mediaCollectionRepository.ReplaceItems(request.MediaCollectionId, mediaItems);
return mediaItems.Map(MediaItems.Mapper.ProjectToViewModel).ToList();
}
private Task<Validation<BaseError, List<MediaItem>>> Validate(ReplaceSimpleMediaCollectionItems request) =>
MediaCollectionMustExist(request).BindT(_ => MediaItemsMustExist(request));
private async Task<Validation<BaseError, SimpleMediaCollection>> MediaCollectionMustExist(
ReplaceSimpleMediaCollectionItems request) =>
(await _mediaCollectionRepository.GetSimpleMediaCollection(request.MediaCollectionId))
.ToValidation<BaseError>("[MediaCollectionId] does not exist.");
private async Task<Validation<BaseError, List<MediaItem>>> MediaItemsMustExist(
ReplaceSimpleMediaCollectionItems replaceItems)
{
var allMediaItems = (await replaceItems.MediaItemIds.Map(i => _mediaItemRepository.Get(i)).Sequence())
.ToList();
if (allMediaItems.Any(x => x.IsNone))
{
return BaseError.New("[MediaItemId] does not exist");
}
return allMediaItems.Sequence().ValueUnsafe().ToList();
}
}
}

View File

@@ -0,0 +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>>
{
public Option<bool> UseCustomPlaybackOrder { get; set; } = None;
}
}

View File

@@ -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);
}

View File

@@ -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);
maybeCollectionItem.IfSome(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."));
}
}

View File

@@ -0,0 +1,64 @@
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 UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection request)
{
c.Name = request.Name;
request.UseCustomPlaybackOrder.IfSome(
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;
}
private async Task<Validation<BaseError, Collection>>
Validate(UpdateCollection request) =>
(await CollectionMustExist(request), ValidateName(request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private Task<Validation<BaseError, Collection>> CollectionMustExist(
UpdateCollection updateCollection) =>
_mediaCollectionRepository.Get(updateCollection.CollectionId)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Validation<BaseError, string> ValidateName(UpdateCollection updateSimpleMediaCollection) =>
updateSimpleMediaCollection.NotEmpty(c => c.Name)
.Bind(_ => updateSimpleMediaCollection.NotLongerThan(50)(c => c.Name));
}
}

View File

@@ -1,8 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateSimpleMediaCollection
(int MediaCollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -1,47 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
UpdateSimpleMediaCollectionHandler : MediatR.IRequestHandler<UpdateSimpleMediaCollection,
Either<BaseError, Unit>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdateSimpleMediaCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyUpdateRequest(SimpleMediaCollection c, UpdateSimpleMediaCollection update)
{
c.Name = update.Name;
await _mediaCollectionRepository.Update(c);
return Unit.Default;
}
private async Task<Validation<BaseError, SimpleMediaCollection>>
Validate(UpdateSimpleMediaCollection request) =>
(await SimpleMediaCollectionMustExist(request), ValidateName(request))
.Apply((simpleMediaCollectionToUpdate, _) => simpleMediaCollectionToUpdate);
private Task<Validation<BaseError, SimpleMediaCollection>> SimpleMediaCollectionMustExist(
UpdateSimpleMediaCollection updateSimpleMediaCollection) =>
_mediaCollectionRepository.GetSimpleMediaCollection(updateSimpleMediaCollection.MediaCollectionId)
.Map(v => v.ToValidation<BaseError>("SimpleMediaCollection does not exist."));
private Validation<BaseError, string> ValidateName(UpdateSimpleMediaCollection updateSimpleMediaCollection) =>
updateSimpleMediaCollection.NotEmpty(c => c.Name)
.Bind(_ => updateSimpleMediaCollection.NotLongerThan(50)(c => c.Name));
}
}

View File

@@ -1,19 +1,10 @@
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(MediaCollection mediaCollection) =>
new(mediaCollection.Id, mediaCollection.Name);
internal static MediaCollectionSummaryViewModel ProjectToViewModel(
MediaCollectionSummary mediaCollectionSummary) =>
new(
mediaCollectionSummary.Id,
mediaCollectionSummary.Name,
mediaCollectionSummary.ItemCount,
mediaCollectionSummary.IsSimple);
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name);
}
}

View File

@@ -1,4 +1,11 @@
namespace ErsatzTV.Application.MediaCollections
using ErsatzTV.Application.MediaCards;
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(int Id, string Name);
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
Id,
Name,
string.Empty,
Name,
string.Empty);
}

View File

@@ -3,5 +3,5 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllMediaCollections : IRequest<List<MediaCollectionViewModel>>;
public record GetAllCollections : IRequest<List<MediaCollectionViewModel>>;
}

View File

@@ -9,15 +9,15 @@ using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetAllMediaCollectionsHandler : IRequestHandler<GetAllMediaCollections, List<MediaCollectionViewModel>>
public class GetAllCollectionsHandler : IRequestHandler<GetAllCollections, List<MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetAllMediaCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
public GetAllCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<List<MediaCollectionViewModel>> Handle(
GetAllMediaCollections request,
GetAllCollections request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetAll().Map(list => list.Map(ProjectToViewModel).ToList());
}

View File

@@ -1,7 +0,0 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllSimpleMediaCollections : IRequest<List<MediaCollectionViewModel>>;
}

View File

@@ -1,25 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class
GetAllSimpleMediaCollectionsHandler : IRequestHandler<GetAllSimpleMediaCollections,
List<MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetAllSimpleMediaCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<List<MediaCollectionViewModel>> Handle(
GetAllSimpleMediaCollections request,
CancellationToken cancellationToken) =>
(await _mediaCollectionRepository.GetSimpleMediaCollections()).Map(ProjectToViewModel).ToList();
}
}

View File

@@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetCollectionById(int Id) : IRequest<Option<MediaCollectionViewModel>>;
}

View File

@@ -8,18 +8,18 @@ using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class
GetSimpleMediaCollectionByIdHandler : IRequestHandler<GetSimpleMediaCollectionById,
GetCollectionByIdHandler : IRequestHandler<GetCollectionById,
Option<MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetSimpleMediaCollectionByIdHandler(IMediaCollectionRepository mediaCollectionRepository) =>
public GetCollectionByIdHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Option<MediaCollectionViewModel>> Handle(
GetSimpleMediaCollectionById request,
GetCollectionById request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetSimpleMediaCollection(request.Id)
_mediaCollectionRepository.Get(request.Id)
.MapT(ProjectToViewModel);
}
}

View File

@@ -5,5 +5,5 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetSimpleMediaCollectionItems(int Id) : IRequest<Option<IEnumerable<MediaItemViewModel>>>;
public record GetCollectionItems(int Id) : IRequest<Option<IEnumerable<MediaItemViewModel>>>;
}

View File

@@ -9,18 +9,18 @@ using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetSimpleMediaCollectionItemsHandler : IRequestHandler<GetSimpleMediaCollectionItems,
public class GetCollectionItemsHandler : IRequestHandler<GetCollectionItems,
Option<IEnumerable<MediaItemViewModel>>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetSimpleMediaCollectionItemsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
public GetCollectionItemsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Option<IEnumerable<MediaItemViewModel>>> Handle(
GetSimpleMediaCollectionItems request,
GetCollectionItems request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetSimpleMediaCollectionItems(request.Id)
_mediaCollectionRepository.GetItems(request.Id)
.MapT(mediaItems => mediaItems.Map(ProjectToViewModel));
}
}

View File

@@ -1,7 +0,0 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetMediaCollectionSummaries(string SearchString) : IRequest<List<MediaCollectionSummaryViewModel>>;
}

View File

@@ -1,27 +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.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class
GetMediaCollectionSummariesHandler : IRequestHandler<GetMediaCollectionSummaries,
List<MediaCollectionSummaryViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetMediaCollectionSummariesHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<List<MediaCollectionSummaryViewModel>> Handle(
GetMediaCollectionSummaries request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetSummaries(request.SearchString)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}

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