Compare commits

..

206 Commits

Author SHA1 Message Date
Jason Dove
6847a133ca prep for release v25.7.1 [no ci] 2025-10-09 10:40:05 -05:00
Jason Dove
fd60c120ae add more cuda logging (#2503) 2025-10-08 20:04:18 -05:00
Jason Dove
371d1d89fb push cuda context when checking capabilities (#2502) 2025-10-08 19:44:29 -05:00
Jason Dove
ec6bc797f4 update cuda failure logging (#2501) 2025-10-08 19:13:24 -05:00
Jason Dove
4c57167864 package windows artifacts using zip instead of 7z (#2496) 2025-10-07 10:11:51 -05:00
Jason Dove
9016523757 cleanup some exceptions; add health check (#2495)
* handle artwork timeouts so they aren't reported

* catch some more cancellation errors

* add free space validation on startup

* add downgrade health check

* update dependencies
2025-10-07 09:50:55 -05:00
Jason Dove
6a38c91d54 update changelog [no ci] 2025-10-06 16:25:28 -05:00
Jason Dove
9ec4d0a85c show graphics engine scriban errors in log (#2491)
* show graphics engine scriban errors in log

* better fix
2025-10-06 16:24:40 -05:00
Jason Dove
4cb98242ba do not allow deleting default ffmpeg profile (#2490)
* remove dead code

* do not allow deleting default ffmpeg profile
2025-10-06 06:11:36 -05:00
Jason Dove
0e2084838a use a table for blocks (#2489) 2025-10-05 09:45:29 -05:00
Jason Dove
f3f900b4ca fix playback troubleshooting (#2488) 2025-10-05 06:06:56 -05:00
Jason Dove
c39858b2d8 fix hls direct (#2487) 2025-10-04 15:36:36 -05:00
Jason Dove
0363609923 allow h264 video profile using vaapi (#2485) 2025-10-04 08:40:10 -05:00
Jason Dove
a02aa37957 do not delete ffmpeg profile used by channel (#2484) 2025-10-04 08:29:07 -05:00
Jason Dove
86a7563da5 prep for release v25.7.0 [no ci] 2025-10-03 19:35:06 -05:00
Jason Dove
cd4715a32e update changelog [no ci] 2025-10-03 10:49:00 -05:00
Jason Arends
fc04118bf9 Emby: accept non-File protocols for Movie items (only require MediaSources present) (#2483) 2025-10-03 10:41:03 -05:00
Jason Dove
dd5fd1ef8f fix cropping jellyfin and emby content that is too small (#2481)
* fix cropping jellyfin and emby content that is too small

* fix transcoding tests with nvidia

* update dependencies
2025-10-02 20:13:40 -05:00
Jason Dove
404f898b2f fix build 2025-10-02 13:41:49 -05:00
Jason Dove
3e8ac9914c refactor playout build errors (#2480)
* refactor classic playout builds

* refactor sequential playout builds

* refactor block playout building

* don't fail building an empty block schedule

* fix scripted playout build errors
2025-10-02 13:36:39 -05:00
Jason Dove
a0788532a0 ignore embedded text subtitles that have not been extracted (#2479)
* ignore text subtitles that have not been extracted

* fix bug with channel paging
2025-10-02 12:41:04 -05:00
Jason Dove
b3daad7c3b improve playout error formatting (#2477) 2025-10-01 21:55:55 -05:00
Jason Dove
598de5d5d6 add playout build status (#2476)
* add playout build status

* show build status in playout list

* update changelog
2025-10-01 21:40:39 -05:00
Jason Dove
5c174eabdb add playback troubleshooting logs (#2475) 2025-10-01 16:29:04 -05:00
Jason Dove
18905a79dc add stream selector to playback troubleshooting (#2474) 2025-10-01 11:10:38 -05:00
Jason Dove
077fed6cac fix vaapi h264 constrained baseline decode (#2473)
* fix vaapi h264 constrained baseline decode

* update changelog
2025-09-30 19:32:45 -05:00
Jason Dove
fac0f36d35 add codec info to multivariant playlist (#2472)
* add codec info to multivariant playlist

* upgrade dependencies
2025-09-30 13:58:24 -05:00
Jason Dove
287adc34b5 add qsv av1 encoder (#2471) 2025-09-30 13:13:26 -05:00
Jason Dove
6ff153f01d add vaapi av1 encoder (#2470) 2025-09-30 12:18:29 -05:00
Jason Dove
e3af0f0b69 add nvidia av1 encoder (#2469) 2025-09-30 11:43:12 -05:00
Jason Dove
b46de50801 add hls segmenter fmp4 streaming mode (#2468)
* add streaming mode segmenter fmp4

* allow hevc channel preview
2025-09-30 10:04:02 -05:00
Jason Dove
77163e6746 use gif watermark metadata in graphics engine (#2465) 2025-09-29 14:28:02 -05:00
Jason Dove
1763c897eb fix filler expression with playlists (#2464) 2025-09-29 14:05:27 -05:00
Jason Dove
ac45d6acd4 use cuvid to check nvidia decode capabilities (#2461)
* detect nvidia decode capabilities

* use cuvid to check b-ref mode
2025-09-27 18:44:48 +00:00
Jason Dove
8b4b7cf16a fix nvidia in docker; minimize nvenc sessions (#2460) 2025-09-27 17:13:56 +00:00
Jason Dove
b820b798cb use nvenc to detect encoder capability (#2459) 2025-09-27 16:30:14 +00:00
Jason Dove
dc92a96bd9 fix playlist preview (#2457) 2025-09-25 13:54:44 +00:00
Jason Dove
18523dce64 add scripted playout timeout setting (#2456)
* add setting for scripted playout build timeout

* update dependencies
2025-09-25 02:33:49 +00:00
Jason Dove
ffb50a9404 fix link [no ci] 2025-09-21 20:06:56 -05:00
Jason Dove
7462039301 update readme [no ci] 2025-09-21 20:05:57 -05:00
Peter Dey
c71269058e Display hostname & build configuration when build config is not "release" (#2449)
* Display hostname & build configuration when build config is not "release" (default).

* Add missed ARG line for arm64/Dockerfile
2025-09-21 19:04:15 +00:00
Jason Dove
9ec220c122 add page to edit channel numbers (#2454) 2025-09-21 18:31:16 +00:00
Jason Dove
fc97a2da3c update issue template [no ci] 2025-09-21 09:02:13 -05:00
Jason Dove
ddc1120904 fix maintaining embedded text subtitles from media server (#2453)
* properly reset extracted flag on subtitles

* optimize subtitle updates; extract after targeted deep scan

* fix extracted text subtitle playback from media servers
2025-09-21 13:54:08 +00:00
Jason Dove
e70e4fb826 fix some invalid external subtitle data (#2451) 2025-09-21 03:13:20 +00:00
Jason Dove
b790b5944c fix external ssa subtitles from media servers (#2450) 2025-09-20 21:13:42 +00:00
Jason Dove
0ca1859802 limit nvidia workaround to h264 (#2448) 2025-09-20 17:31:55 +00:00
Jason Dove
07a160fcc6 work around nvidia green line (#2447) 2025-09-20 16:12:08 +00:00
Jason Dove
788a1ecdc4 add deco break content (#2446)
* add initial models

* migrations

* edit break content mode

* add and remove break content from ui

* use autocompletes in deco editor

* save break content to db

* allow break content playlists

* refactor default filler build

* fix slow startup

* start to implement adding break content

* use clone; try to fix block breaks

* fix updating history

* use consistent removebefore values

* cleanup logging

* only allow playlist break content

* update changelog
2025-09-19 18:48:51 +00:00
Jason Dove
004da8b7aa use better fields for filler preset duration (#2442) 2025-09-18 15:56:51 +00:00
Jason Dove
c8679144c5 add text element text_fit options (#2441) 2025-09-18 14:08:24 +00:00
Jason Dove
a389c1bbbe more search fields and highlighting (#2440) 2025-09-18 11:36:24 +00:00
Jason Dove
ecacf3960f add search fields to filter collections and schedules tables (#2439) 2025-09-18 10:44:11 +00:00
Jason Dove
aa5ba5a78e fix recent nvidia regression (#2437)
* fix recent nvidia regression

* update transcoding tests for graphics engine
2025-09-18 03:15:23 +00:00
Jason Dove
9e111a103e fix fallback on mirror channels (#2436) 2025-09-17 18:14:25 +00:00
Jason Dove
8bc3457de0 fix hls playlist filtering (#2435)
* add failing test

* fix hls playlist filtering
2025-09-17 15:42:23 +00:00
Jason Dove
febabcff6f fix running tests in github (timezone issue) (#2434) 2025-09-17 14:16:54 +00:00
Jason Dove
f26e48c063 block schedules: skip items and collections that will never fit (#2433)
* add first block playout builder test

* block schedules: skip items and collections that will never fit
2025-09-17 13:53:50 +00:00
Jason Dove
6465c416ff motion end behavior (#2431)
* add end behavior enum and properties

* support loop end behavior

* implement end behavior hold

* update changelog
2025-09-17 03:35:49 +00:00
Jason Dove
a0e0bb8753 fix motion element timing (#2430) 2025-09-17 01:09:00 +00:00
Jason Dove
b9451a6585 add motion graphics elements (#2428)
* crude motion graphics element

* fix motion element rendering

* implement motion element scaling

* implement motion start seconds

* update changelog
2025-09-16 21:34:08 +00:00
Jason Dove
0c49f4799f fix playback of content with unknown color range (#2427) 2025-09-16 16:20:43 +00:00
Jason Dove
ea008776b1 more nvidia 10-bit fixes (#2426)
* fix playback with invalid ffmpeg profile

* fix 10 bit output with nvidia and graphics engine
2025-09-16 14:44:22 +00:00
Jason Dove
9aa7c44388 add watermarks and graphics elements to block items (#2424) 2025-09-16 02:39:25 +00:00
Peter Dey
d855e4f20d Add initial support for Rockchip Media Process Platform (rkmpp) hardware acceleration (#2418)
* Add Rockchip Media Process Platform (rkmpp) acceleration

* remove fourcc stuff; it's exclusive to videotoolbox

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-09-16 00:51:56 +00:00
Jason Dove
9da655e210 keep at least 10 bit color throughout nvidia tonemap pipeline (#2423) 2025-09-15 20:47:15 +00:00
Jason Dove
03b9db7835 fix green output with libplacebo and nvidia 10 bit (#2422) 2025-09-15 19:40:27 +00:00
Jason Dove
245165c9d9 add rerun collection type (#2421)
* rename collection type

* split collections into separate pages

* add rerun collection types, migration, editor

* add rerun to classic schedule items

* rerun plumbing in classic playout builder

* start to implement rerun enumerator

* add scheduledAt to enumerator movenext

* maintain rerun history in db

* fix shuffle

* fix rerun allowed playback orders

* fix updating rerun collections

* update changelog; fix editing

* update changelog
2025-09-15 18:27:55 +00:00
Jason Dove
fe5dd80f70 prep for release v25.6.0 [no ci] 2025-09-14 12:54:06 -05:00
Jason Dove
8155e2e441 add shuffle in order for collections in playlists (#2417) 2025-09-14 13:08:42 +00:00
Jason Dove
307b9dadd2 partial v4l2m2m accel support (#2416)
* start to add v4l2m2m accel

* add v4l2m2m pipeline

* add encoders

* fix decoders and encoders

* output software frames from decoders

* more buffers

* hide v4l2m2m from ui
2025-09-14 11:48:24 +00:00
Jason Dove
5379a893f7 generate fake epg data for graphics elements when troubleshooting (#2415) 2025-09-14 03:01:16 +00:00
Jason Dove
64bb1b0d61 fix classic schedule flood bug (#2414)
* clean up some logging

* fix classic schedule flood bug
2025-09-14 02:36:50 +00:00
dependabot[bot]
89968f722a Bump jetbrains.resharper.globaltools from 2025.2.0 to 2025.2.1 (#2378)
---
updated-dependencies:
- dependency-name: jetbrains.resharper.globaltools
  dependency-version: 2025.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 15:11:19 +00:00
Jason Dove
bc721755f5 add graphics elements to decos (#2413)
* add deco graphics elements, selector and tests

* add migrations

* edit deco graphics elements from ui

* update changelog
2025-09-13 15:02:48 +00:00
Jason Dove
9182a8ad18 cleanup graphics element loading (#2412) 2025-09-13 13:39:20 +00:00
Jason Dove
c5265943f5 fix inefficient database migration (#2411) 2025-09-13 03:42:54 +00:00
Jason Dove
07a55da76e process graphics element yaml files with scriban (#2410)
* add content rating to media item template

* process graphics element yaml files with scriban
2025-09-13 03:04:46 +00:00
Jason Dove
3722bc8c9c add some database startup logging (#2409)
* add some database startup logging

* fix unscheduled playout gap offset

* fix database logging
2025-09-13 00:50:47 +00:00
Peter Dey
87bc779d48 Show fillers in the playout view in alternative shading (#2405)
* Add shading to filler rows in the playout view

* Insert rows in Playout listing for gaps in the playout (station offline)

* Make FillerKind in PlayoutItemViewModel optional.
Remove Unscheduled enum in FillerKind.

* Correctly handle "Show Filler" also for Unscheduled fillers.
* Moved the Unscheduled item generation for the playout view to GetFuturePlayoutItemsByIdHandle to handle ShowFiller
* Includes for the PlayoutItemDetails moved to an extension for maintainability.
* Bugfix: Page size was more than the desired for pagination because of the inserted unscheduled items.

* Add specified colours for playout fillers to make them less intense.

* use common queryable

* add playout gap model and migrations

* insert playout gaps after playout build

* optimize get future playout items handler

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-09-13 00:30:34 +00:00
Jason Dove
f124554fba add some debug logging for music video scanning (#2408) 2025-09-12 16:56:29 +00:00
Jason Dove
17c7774603 add playlist item count and shuffle playlist items (#2407)
* marathon cleanup

* add playlist item count, and shuffle playlist items
2025-09-12 14:19:05 +00:00
Jason Dove
4e065fe922 update dependencies (#2406) 2025-09-11 18:04:48 +00:00
Jason Dove
2cca3123aa fix issue [no ci] 2025-09-11 09:43:52 -05:00
Jason Dove
dabc67976a update github issues 2025-09-11 09:41:46 -05:00
Jason Dove
bd6954121f log api requests (#2404) 2025-09-10 00:50:03 +00:00
Jason Dove
a2fd23a131 fix deco selection logic (#2403) 2025-09-09 18:07:50 +00:00
Jason Dove
388623f82e fix changing playout source from mirror to generated (#2402) 2025-09-09 17:19:00 +00:00
Jason Dove
6b275f8a13 fix hwaccel health check on mobile (#2401)
* fix hw accel health check on mobile

* allow classic schedules to fast forward
2025-09-09 16:57:49 +00:00
Jason Dove
0d69dd58a4 add classic schedule marathon (#2400) 2025-09-09 15:38:07 +00:00
Jason Dove
79e8fa0877 ignore specials when using season, episode order (#2399) 2025-09-08 15:12:51 +00:00
Jason Dove
044c8b7ad3 fix graphics engine with scaling behavior crop (#2398) 2025-09-08 13:36:13 +00:00
Jason Dove
e8b51e8442 fix watermarks and graphics when using mid-roll (#2397) 2025-09-08 10:59:25 +00:00
Jason Dove
d8e8abb691 channel mirror validation improvements (#2396)
* improve channel mirror validation

* fix playout offset
2025-09-08 01:12:58 +00:00
Jason Dove
e9093d0c48 fix block playout build crash from empty collections (#2395) 2025-09-08 00:42:02 +00:00
khreezy
cb78b21d1c add support for aif, aifc, aiff (#2325)
* adds .aiff to supported audio file extension in local folder scanner

* add support for aif, aifc, aiff

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-09-07 19:24:43 +00:00
Jason Dove
906ec44a6e fix scripted pre-roll overlap (#2394)
* fix scripted pre-roll overlap

* cleanup
2025-09-07 18:36:10 +00:00
Jason Dove
e96ac0202b add mirror playout offset (#2391) 2025-09-07 16:45:41 +00:00
Jason Dove
5e7da19e5e add channel mirror (#2390)
* add channel playout source (doesn't do anything yet)

* configure mirror channel

* fix mirror playback

* sync epg for mirror channel

* update changelog
2025-09-07 16:00:27 +00:00
Jason Dove
e25b669cc4 fix scaling content in certain locales (#2389) 2025-09-07 14:20:11 +00:00
Jason Dove
d80c6737a9 fix mysql permissions (#2388) 2025-09-07 13:21:48 +00:00
Jason Dove
ef5de99f9c add create_playlist, pre_roll_on, pre_roll_off to scripted schedules (#2387) 2025-09-06 20:47:44 +00:00
Jason Dove
e047812a68 rework some scripted schedule model names (#2386) 2025-09-06 18:52:34 +00:00
Jason Dove
3da5144a0d update entrypoint so reset_playout is not required to return an updated context (#2385) 2025-09-06 16:22:07 +00:00
Jason Dove
31355ab887 add pad_until_exact and wait_until_exact scripted schedule calls (#2384) 2025-09-06 15:43:07 +00:00
Jason Dove
63bac272cd fix adding classic schedule items (#2383) 2025-09-06 00:15:05 +00:00
Jason Dove
8d5a208129 fix adding single items to playlists (#2382) 2025-09-05 18:44:08 +00:00
Jason Dove
487d99dc69 openapi improvements (#2381)
* generate openapi definitions as separate build task

* first defns

* install etv-client module in docker

* include python entrypoint in docker

* update changelog
2025-09-05 17:11:43 +00:00
Jason Dove
40ab7c2cff add graphics elements on classic schedule items (#2380) 2025-09-05 11:53:29 +00:00
Jason Dove
a6de96c2ea fix openapi client generation again (#2379) 2025-09-05 10:52:34 +00:00
Jason Dove
0d82e0234b fix scripted schedule validation (#2377) 2025-09-05 01:27:16 +00:00
Jason Dove
1e9e41b808 use tagged openapi for docs; no tags for client (#2376) 2025-09-05 01:01:28 +00:00
Jason Dove
57e9b4d264 more api docs (#2375) 2025-09-04 18:00:48 +00:00
Jason Dove
73b8d68a09 add existing api endpoints to scalar docs (#2374) 2025-09-04 16:36:26 +00:00
Jason Dove
03921e1ff7 more docs reorganization (#2373) 2025-09-04 15:20:02 +00:00
Jason Dove
70aae67873 reorganize scalar docs (#2372) 2025-09-04 14:59:17 +00:00
Jason Dove
67cb931a47 fix verbs, add scalar docs (#2371) 2025-09-04 14:01:16 +00:00
Jason Dove
704c1ec535 return context from scripted schedule api calls that modify the context (#2370) 2025-09-04 13:42:00 +00:00
Jason Dove
06332c8360 allow custom scripted schedule arguments (#2368) 2025-09-03 21:39:30 +00:00
Jason Dove
03b4419f67 rework scripted schedules (#2367)
* start to reorganize scripted playout building

* add openapi

* add all content fns

* add playout instructions

* add control instructions

* add request models

* prevent build loop

* rename

* update changelog

* tweak changelog
2025-09-03 20:53:14 +00:00
Jason Dove
7ac93c6aad fix transcoding bt709/pc (#2363) 2025-09-02 18:28:09 +00:00
Jason Dove
6ca72baa00 prep for release v25.5.0 [no ci] 2025-09-01 19:59:13 -05:00
Jason Dove
6b953ab5ca fix long season placeholder text (#2362) 2025-09-02 00:35:22 +00:00
Jason Dove
272f528f7a fix segmenter v2 with videotoolbox accel (#2361)
* fix segmenter v2 with videotoolbox

* more capabilities checks
2025-09-01 13:35:37 +00:00
Jason Dove
07c1156a63 update yaml schema for new pad_to_next fields (#2360) 2025-09-01 01:53:17 +00:00
Jason Dove
eadacc7f8c add stop_before_end and offline_tail to pad_to_next (#2359) 2025-08-31 23:03:46 +00:00
Jason Dove
380070731a startup improvements (#2356)
* redirect to index when initializing

* clear stale sqlite migration lock on startup
2025-08-30 13:16:54 +00:00
Jason Dove
7720e6ba39 fix hls segmenter v2 with amf accel (#2355) 2025-08-29 20:01:31 +00:00
Jason Dove
8a1cf72209 more alternate schedule fixes (#2354)
* always start with the first schedule item

* reset program schedule items to zero-based index on save

* log offline gaps from strict start times
2025-08-28 14:48:39 +00:00
Jason Dove
b9759c983c fix alternate schedule transitions in classic schedules (#2353) 2025-08-27 21:31:48 +00:00
Jason Dove
9462156148 fix mysql playout builds (#2352)
* more cancellation tokens and fixes

* so much cancellation token

* fix mysql playout builds
2025-08-27 18:09:56 +00:00
Jason Dove
1c07df5bc3 use cancellation tokens in many places (#2350)
* use cancellation tokens everywhere

* more cancellation tokens
2025-08-27 03:20:35 +00:00
Jason Dove
a6198892f0 more mysql ui fixes (#2349) 2025-08-26 03:02:40 +00:00
Jason Dove
02a91c4e14 fix editing remote libraries with mysql/mariadb (#2348) 2025-08-26 01:44:59 +00:00
Jason Dove
b62a76d339 fix mysql migrations (#2347) 2025-08-26 00:40:19 +00:00
Jason Dove
d9f2f51aee fix fallback filler playback (#2346) 2025-08-25 21:56:00 +00:00
Jason Dove
8e77330781 timeout all scripted playout builds (#2345)
* check for progress in is_done

* timeout all scripted playout builds
2025-08-25 18:15:08 +00:00
Jason Dove
66c28e9b5f rework scripted schedule signatures; add start_time and finish_time (#2344) 2025-08-25 17:07:14 +00:00
Jason Dove
51ec84c94a fix block playout history regression (#2343)
* minor tweaks

* fix block change detection bug

* history cleanup cleanup
2025-08-25 16:09:20 +00:00
Jason Dove
a072e4357e add scripted add_all, add_duration, pad_to_next, pad_until (#2342)
* add add_all

* add add_duration

* add pad_to_next

* add pad_until
2025-08-24 17:49:18 +00:00
Jason Dove
605c57bef3 add scripted control instructions (#2341)
* add start_epg_group, stop_epg_group

* fix imports

* add graphics_on, graphics_off

* add skip_items

* add skip_to_item

* add watermark_on, watermark_off
2025-08-24 16:14:43 +00:00
Jason Dove
4e2310d008 add all content sources to scripted schedules (#2340)
* add show content

* add multi collection content

* add smart collection content

* add playlist content

* fix infinite loop

* add marathon content
2025-08-24 14:39:34 +00:00
Jason Dove
61a99c250a expose current_time as a python datetime (#2339) 2025-08-24 11:58:37 +00:00
Jason Dove
bbddd50f00 add new scheduling engine, basic scripted schedule system (#2337)
* start to add content to scheduling engine

* add first content instruction

* add search content

* allow scripted schedule creation

* don't use scheduling engine in sequential playout builder, yet
2025-08-24 03:11:58 +00:00
Jason Dove
53f281ce32 add xmltv block behavior setting (#2336)
* replace playout externaljsonfile and templatefile with schedulefile

* add scripted schedule-based playout

* wip - not functional yet

* temp disable scripted playout creation

* allow fast-forwarding block playouts

* add xmltv block behavior setting
2025-08-23 20:16:12 +00:00
Jason Dove
e06ee54070 rename yaml playout to sequential schedule (#2335)
* clarify some schedule and playout terms

* more renaming
2025-08-23 14:27:32 +00:00
Jason Dove
af23c6d541 copy watermark overrides when copying schedule (#2334) 2025-08-23 12:51:04 +00:00
Jason Dove
988ed8db04 fix changing default alternate schedule (#2331) 2025-08-18 15:37:21 +00:00
Jason Dove
31c18162e1 add deco watermark mode merge (#2330) 2025-08-18 11:42:27 +00:00
Jason Dove
0318e71745 refactor watermark selection (#2328)
* move watermark options into watermark selector

* fix graphics engine overlay performance

* more refactoring

* add some tests for existing watermark selector behavior

* remove extra ffprobe call on all watermarks

* remove a bunch of unused code; add failing tests

* implement new watermark selection

* add tests for new selector

* probably sufficient (though verbose) test coverage

* more tests

* remove some unused code

* simplify watermark selection

* remove old selection code and tests

* more tests
2025-08-18 01:04:36 +00:00
Jason Dove
1e7f9a5709 fix saving yaml playout history (#2327)
* fix saving yaml playout history

* cleanup
2025-08-17 01:21:07 +00:00
Jason Dove
330195d5e3 fix seeking into extracted text subtitles (#2326) 2025-08-17 00:23:51 +00:00
Jason Dove
5d081ceeff fix editorconfig and run code cleanup (#2324)
* fix formatting rules

* reformat ersatztv

* reformat ersatztv.application

* reformat ersatztv.core

* refactor ersatztv.core.tests

* reformat ersatztv.ffmpeg

* reformat ersatztv.ffmpeg.tests

* reformat ersatztv.infrastructure

* cleanup infra mysql

* cleanup infra sqlite

* cleanup infra tests

* cleanup ersatztv.scanner

* cleanup ersatztv.scanner.tests

* sln cleanup

* update dependencies
2025-08-16 14:44:48 +00:00
Jason Dove
6d32dac51b fix graphics engine opacity (#2323)
* fix skia opacity wip

* fix graphics engine opacity
2025-08-16 02:59:07 +00:00
Jason Dove
4f02bedf69 fix image loading regression in graphics engine (#2322) 2025-08-15 21:30:54 +00:00
Jason Dove
d71443ef60 add subtitle graphics element (#2321) 2025-08-15 19:48:04 +00:00
Jason Dove
d5608ac75f multiple bug fixes (#2320)
* fix incorrect media counts in local libraries

* completely replace imagesharp with skiasharp

* fix song troubleshooting playback

* fix usings
2025-08-15 16:06:55 +00:00
Jason Dove
a6b01cbe28 convert graphics engine from imagesharp to skiasharp (#2319)
* use skiasharp in graphics engine

* start to use richtextkit

* move out some template functions

* move files

* add base graphics element

* use default style in text element

* support partial styling in text element

* fix static images

* load fonts from text element definition
2025-08-15 14:27:22 +00:00
midnite8177
d0af507bef add ability to deep scan just a single tv show for Plex, Emby, and Jellyfin (#2318)
* add ability to deep scan just a single tv show for Plex, Emby, and Jellyfin

Including "/api/libraries/{id:int}/scan-show" REST API endpoint to
trigger.

* restrict plex search results to the intended library

* restrict scanning to media server libraries that are marked to sync with etv

* fix previous commit

* also guard library scan api

* add scan buttons to show ui

* scan single plex show by id

* scan jellyfin and emby single shows by id

* update changelog

---------

Co-authored-by: Jeff Slutter <MrMustard@gmail.com>
Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-08-14 16:07:56 +00:00
midnite8177
f626954eb7 add external chapter file scanning (#2317)
* add external chapter file scanning

Support Matroska chapter xml files next to media file with extension .xml or .chapters

* only update chapters in db

---------

Co-authored-by: Jeff Slutter <MrMustard@gmail.com>
Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-08-14 03:34:47 +00:00
Jason Dove
62e140ec98 block scheduling ui cleanup (#2316)
* sort block tree views

* fix naming validation for block scheduling

* show deco group name in deco editor

* show block group name in block editor

* show template group name in template editor

* show deco template group name in deco template editor

* fix template rename crash

* fix block rename crash

* fix deco template rename crash
2025-08-14 01:37:21 +00:00
Jason Dove
93bb7a0531 skip unused hwaccel with amf (#2315) 2025-08-13 22:03:52 +00:00
Jason Dove
f31a48c429 fix episodes from multiple plex servers (#2314) 2025-08-13 20:56:27 +00:00
Jason Dove
0841bc400b fix saving watermarks and graphics on playout items (#2313) 2025-08-13 19:37:46 +00:00
Jason Dove
8cc0d30c0e add some template helper functions for text elements (#2312) 2025-08-13 18:50:04 +00:00
Jason Dove
4b18ee6b66 add custom stream selector content_condition (#2311) 2025-08-13 16:34:17 +00:00
Jason Dove
558e2ce333 rename opacity to opacity_percent for consistency (#2310) 2025-08-13 15:21:25 +00:00
Jason Dove
c9e6e601c2 automatically refresh graphics elements (#2309) 2025-08-13 15:11:14 +00:00
Jason Dove
d28d0a9805 fix yaml playout progress (#2308) 2025-08-13 13:21:53 +00:00
Jason Dove
ac75a67709 block history fixes (#2307)
* fix deco to only have one collection id for filler/fallback

* fix duplicate playout history for deco filler
2025-08-13 01:02:41 +00:00
Jason Dove
5e463758da ignore unreliable anamorphic flag from jellyfin (#2306) 2025-08-12 23:32:11 +00:00
Jason Dove
2cb0d12701 load a configurable number of epg entries for text graphics (#2305)
* wip

* load a configurable number of epg entries for text graphics

* cleanup
2025-08-12 21:00:55 +00:00
Jason Dove
44ec0f8a0f add more template data to text graphics element (#2304) 2025-08-12 14:33:52 +00:00
Jason Dove
b149f7f2a3 fix overlapping playout items check (#2303) 2025-08-12 11:35:12 +00:00
Jason Dove
771bfba01c fix overlapping block playout items (#2302)
* check for overlapping playout items

* tweak block filler builder

* fix overlapping block playout items

* update changelog

* minor cleanup
2025-08-12 11:14:24 +00:00
Jason Dove
820c2a5ccc fix watermark validation (#2301) 2025-08-11 18:55:45 +00:00
Jason Dove
91c4e8f575 add seek seconds to playback troubleshooting (#2300) 2025-08-11 18:10:36 +00:00
Jason Dove
a04adf45c0 fix green padding with vaapi i965 driver (#2298) 2025-08-11 17:39:01 +00:00
Jason Dove
8cbc3b083a fix placing watermarks within source content (#2297)
* fix placing watermarks within source content

* formatting
2025-08-11 16:02:16 +00:00
Jason Dove
1cac210765 fix segmenter v2 transitions (#2296) 2025-08-11 15:00:25 +00:00
Jason Dove
6f9952924b fix adding new schedule items (#2295) 2025-08-11 12:56:24 +00:00
Jason Dove
1bf5b9567b use graphics engine with segmenter v2 (#2294) 2025-08-11 11:56:48 +00:00
Jason Dove
a9f2037648 cleanup some unused watermark references (#2293) 2025-08-11 03:02:57 +00:00
Jason Dove
03c5b7e664 refactor some tests; upgrade dependencies (#2292)
* refactor some tests

* upgrade dependencies

* disable new test
2025-08-11 00:17:01 +00:00
Jason Dove
0e7ec6e3b9 fix qsv transitions when remote streaming (#2291) 2025-08-10 11:47:47 +00:00
Jason Dove
3f247288d3 fix on demand for block and yaml schedules (#2290) 2025-08-10 00:50:59 +00:00
Jason Dove
df0801f2c6 add image graphics element (#2288) 2025-08-09 17:42:23 +00:00
Jason Dove
908125f8a9 allow selecting multiple watermarks on decos (#2287)
* load fonts on demand

* add new table

* populate new table

* edit and use multiple watermarks in deco

* remove old field

* update changelog
2025-08-09 17:00:12 +00:00
Jason Dove
942cf9e225 allow selecting multiple watermarks on schedule items (#2286)
* add and populate new table

* add watermark multiselect

* remove old column

* update changelog

* fix tests
2025-08-09 13:53:37 +00:00
Jason Dove
075f3fcac7 pass music video variables to text element (#2285)
* pass music video variables to text element

* remove unused file
2025-08-09 01:29:20 +00:00
Jason Dove
f4eadae8ff set variables from yaml playout graphics_on instruction (#2284) 2025-08-08 23:02:13 +00:00
Jason Dove
2dc5bf58a7 add graphics_on and graphics_off yaml playout instructions (#2283) 2025-08-08 20:22:07 +00:00
Jason Dove
76a589b538 add text graphics element to playback troubleshooting (#2282)
* refactor graphics engine; async frame generation

* add text graphics element to playback troubleshooting
2025-08-08 19:18:15 +00:00
Jason Dove
9f3db05c17 fix graphics engine on vaapi (#2281) 2025-08-08 14:15:46 +00:00
Jason Dove
7ca2763109 allow multiple watermarks in playback troubleshooting (#2280) 2025-08-08 11:33:12 +00:00
Jason Dove
14539d00d4 add watermark z-index (#2279) 2025-08-08 00:43:00 +00:00
Jason Dove
bd09f3dfdc fix block filler progression (#2278) 2025-08-07 21:18:45 +00:00
Jason Dove
0c22eefad2 fix block playout progression (#2277) 2025-08-07 21:11:49 +00:00
Jason Dove
2f06e5b6f7 add linear fade functions to watermark opacity expression (#2276)
* add linear fade functions to watermark opacity expression

* cleanup
2025-08-07 20:46:16 +00:00
Jason Dove
f9db92d5e6 add content_total_seconds to watermark opacity expression (#2275) 2025-08-07 19:56:56 +00:00
Jason Dove
f2b6f5b919 enable graphics engine in playback troubleshooting (#2274)
* enable graphics engine in playback troubleshooting

* fix text subtitles with graphics engine (watermarks)
2025-08-07 18:37:55 +00:00
Jason Dove
c7fcaf8886 refactor playout building (#2273)
* refactor playout building

* remove playout items
2025-08-07 15:20:26 +00:00
Jason Dove
5a5c049835 support multiple watermarks in yaml schedules (#2267)
* add multiple watermarks per playout item

* fixes

* update yaml playout watermark to support multiple watermarks

* use graphics engine for intermittent watermarks
2025-08-06 21:22:20 +00:00
Jason Dove
a28f40e14b remove debug log 2025-08-06 13:27:33 -05:00
Jason Dove
a2fc99229e add watermark opacity expression (#2266)
* add watermark opacity expression

* implement watermark opacity expression parameters

* minor fixes
2025-08-06 18:26:44 +00:00
Jason Dove
036b6e63c7 add new graphics engine (#2265)
* spike new graphics engine

* fix remote watermarks; add graphics engine to vaapi

* add graphics engine to qsv
2025-08-06 15:04:43 +00:00
1234 changed files with 480071 additions and 15459 deletions

View File

@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2025.1.4",
"version": "2025.2.2.1",
"commands": [
"jb"
],

View File

@@ -1,28 +1,11 @@
[*]
charset=utf-8
end_of_line=lf
trim_trailing_whitespace=true
insert_final_newline=false
insert_final_newline=true
indent_style=space
indent_size=4
[*.json]
ij_json_array_wrapping = normal
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_keep_trailing_comma = false
ij_json_object_wrapping = normal
ij_json_property_alignment = do_not_align
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = false
ij_json_space_before_comma = false
ij_json_spaces_within_braces = true
ij_json_spaces_within_brackets = true
ij_json_wrap_long_lines = false
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers=false
csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
@@ -58,6 +41,8 @@ resharper_braces_for_for=required
resharper_braces_for_foreach=required
resharper_braces_for_ifelse=required
resharper_braces_for_while=required
resharper_csharp_arguments_literal=positional
resharper_csharp_arguments_named=positional
resharper_csharp_insert_final_newline=true
resharper_csharp_max_attribute_length_for_same_line=0
resharper_csharp_place_accessorholder_attribute_on_same_line=never
@@ -100,7 +85,22 @@ tab_width=4
indent_style = space
indent_size = 2
[*.json]
ij_json_array_wrapping = normal
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_keep_trailing_comma = false
ij_json_object_wrapping = normal
ij_json_property_alignment = do_not_align
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = false
ij_json_space_before_comma = false
ij_json_spaces_within_braces = true
ij_json_spaces_within_brackets = true
ij_json_wrap_long_lines = false
[*.cs]
# disable CA1848: Use the LoggerMessage delegates`
dotnet_diagnostic.ca1848.severity = none
dotnet_diagnostic.ca1848.severity = none

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: Feature Requests
url: https://features.ersatztv.org
about: Features
- name: Discord
url: https://discord.ersatztv.org
about: Chat
- name: Community
url: https://discuss.ersatztv.org
about: Forum
- name: Discussions
url: https://github.com/ErsatzTV/ErsatzTV/discussions
about: Discuss

77
.github/ISSUE_TEMPLATE/issue.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Issue Report
description: Report an issue
type: Bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this form! Please make sure to fill all fields, including the Title above.
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
options:
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://discord.ersatztv.org) first to troubleshoot with volunteers before creating a report.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I'm using an up to date version of ErsatzTV (full release or develop release); We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
required: true
- type: textarea
id: description
attributes:
label: Description
description: |
Description of the problem or issue here.
validations:
required: true
- type: textarea
id: repro-steps
attributes:
label: Steps to reproduce the problem.
description: |
1. Step 1
2. Step 2
3. Step 3
If this is a playback issue, follow these steps and post the resulting zip:
1. Search for the required content using the search bar.
2. Use the overflow/three dots menu on the content and select Troubleshoot Playback.
3. Select the appropriate Playback Settings that trigger the undesired behavior.
4. Click Play to start playback.
5. Repeat steps 3 and 4 until the undesired behavior is reproduced.
6. Click Download Results to have ErsatzTV collect relevant troubleshooting logs (ffmpeg log, ffmpeg profile, hardware capabilities, media info, etc) and compress them in a zip file.
7. Attach the zip to this field.
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: What is the current _bug_ behavior?
description: Write down the incorrect behavior that currently happens after following the reproduction steps.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: What is the expected _correct_ behavior?
description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
validations:
required: true
- type: input
id: version
attributes:
label: Specify full version
description: Provide the full version of ErsatzTV, which can be found below the left menu.
placeholder: |
25.5.0-bd695412-docker-amd64
validations:
required: true
- type: textarea
id: additional-information
attributes:
label: Additional information
description: Any additional information that might be useful to this issue.

View File

@@ -292,7 +292,7 @@ jobs:
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
mkdir "$release_name"
mv dotnet-build/scanner/* "$release_name/"
mv dotnet-build/main/* "$release_name/"
@@ -302,8 +302,8 @@ jobs:
mv rust-build/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
rm -f "$release_name/ffplay.exe"
7z a -tzip "${release_name}.zip" "./${release_name}/*"
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1

View File

@@ -5,6 +5,325 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [25.7.1] - 2025-10-09
### Added
- Add search field to filter blocks table
- Show full error/exception details in playback troubleshooting logs
- Add basic free space validation on startup
- ETV will now fail to start with less than 128 MB free space in config or transcode folders
- Add downgrade health check to inform users when they are doing something that WILL impact stability
### Fixed
- Do not allow deleting ffmpeg profiles that are used by channels
- Do not allow deleting default ffmpeg profile
- Allow ffmpeg profiles using VAAPI accel to set h264 video profile
- Fix HLS Direct playback, and make it accessible on separate streaming port
- Fix playback troubleshooting when using multiple watermarks or multiple graphics elements
### Changed
- Use table instead of tree view on blocks page
## [25.7.0] - 2025-10-03
### Added
- Add new collection type `Rerun Collection`
- This collection type will show up as *two* collection types in classic schedules
- `Rerun (First Run)`
- `Rerun (Rerun)`
- The playback order for each of these collection types can be set on the rerun collection itself
- e.g. `Season, Episode` order for first run, `Shuffle` for rerun
- When a first run item is added to a playout, it will immediately be made available in the rerun collection
- Rerun history is currently scoped to the playout, and only supported in classic schedules
- This means resetting the playout will reset the rerun history
- Items will still be scheduled from the rerun collection if it is used before the first run collection
- Otherwise, the rerun collection would be considered "empty" which prevents the playout build altogether
- Add `Rkmpp` hardware acceleration by @peterdey
- This is supported using jellyfin-ffmpeg7 on devices like Orange Pi 5 Plus and NanoPi R6S
- Block schedules: allow selecting multiple watermarks on block items
- Block schedules: allow selecting multiple graphics elements on block items
- Add `motion` graphics element type
- Supported in playback troubleshooting and all scheduling types
- Supports video files with alpha channel (e.g. vp8/vp9 webm, apple prores 4444)
- Supports EPG and Media Item replacement in entire template
- EPG data is sourced from XMLTV for the current time
- EPG data can also load a configurable number of subsequent (up next) entries
- Media Item data is sourced from the currently playing media item
- Template supports:
- Content (`video_path`)
- Placement (`location`, `horizontal_margin_percent`, `vertical_margin_percent`)
- Scaling (`scale`, `scale_width_percent`)
- Timing (`start_seconds`)
- End behavior (`end_behavior`)
- `disappear` (default) - disappear after playing once
- `loop` - loop forever
- `hold` - hold last frame forever, or `hold_seconds`
- Draw order (`z_index`)
- Add search fields to filter collections, schedules and playouts tables
- Add selected row background color to schedules and playouts tables
- Graphics engine text element: add `width_percent` and `text_fit` to support wrapping and scaling text
- `text_fit: none` or unspecified will keep existing behavior (render text exactly as configured)
- `text_fit: wrap` will wrap text to the given `width_percent`
- `text_fit: scale` will scale text *smaller* to fit the given `width_percent`
- Text that already fits with the configured style will not be adjusted
- Block schedules: add **experimental** `Break Content` to decos
- Break content is similar to filler from classic schedules
- Break content is currently limited to placement `Block Start` (play before anything else in the block)
- Future work will add other placement options
- Break content is currently limited to playlists (which do *not* pad - they simply play through the playlist one time)
- Future work will add other collection options which will pad to the full block duration
- Add page to reorder channels (edit channel numbers) using drag and drop
- New page is at **Channels** > **Edit Channel Numbers**
- Scripted schedules: add setting to configure timeout of scripted playout build
- New setting is at **Settings** > **Playout** > **Scripted Schedule Timeout**
- Add *experimental* streaming mode `HLS Segmenter (fmp4)`
- This mode is required for better compliance with HLS spec, and to support new output codecs
- This mode *will replace* `HLS Segmenter` when it has received more testing
- Allow HEVC playback in channel preview
- This is restricted to compatible browsers
- Preview button will be red when preview is disabled due to browser incompatibility
- Add AV1 encoding support with NVIDIA, VAAPI and QSV acceleration
- This also requires `HLS Segmenter (fmp4)`
- Add `Stream Selector` option to playback troubleshooting tool
- This can be helpful for validating stream selector behavior with specific content
- Manual subtitle selection will be disabled when using a stream selector
- Add basic log viewer to playback troubleshooting tool
- Streaming log level will be forced to `Debug` during troubleshooting
- Streaming log level will be restored to its previous value after troubleshooting completes
- Add playout build status to UI
- Playouts that fail to build will be highlighted yellow in the playouts table
- Clicking on the failed playout will display the warning or error that caused the playout build to fail
### Fixed
- Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile
- Fix playback when invalid video preset has been saved in FFmpegProfile
- This can happen when NVIDIA accel falls back to libx264 software encoder for 10-bit h264 output
- Fix 10-bit output when using NVIDIA and graphics engine (watermark or other overlays)
- Fix playback of Jellyfin content with unknown color range
- Block schedules: skip collections (block items) that will never fit in block duration
- Block schedules: skip media items that will never fit in block duration
- Fix HLS playlist generation for clients that actually care about discontinuities (like hls.js)
- This should resolve most playback issues with built-in channel preview
- Fix deco dead air fallback selection and duration on mirror channels
- Fix fallback filler duration on mirror channels
- Fix slow startup caused by check for overlapping playout items
- Fix green line in *most* cases when overlaying content using NVIDIA acceleration and H264 output
- Fix non-SRT (e.g. SSA/ASS) external subtitle playback from media servers
- Fix extracted text subtitle playback from media servers
- Fix extracted text subtitles getting into invalid state after media server deep scans
- Targeted deep scans will now extract text subtitles for the scanned show
- Fix playlist preview
- Use NVIDIA NvEnc API to detect encoder capability instead of heuristic based on GPU model/architecture
- Use NVIDIA Cuvid API to detect decoder capability instead of heuristic based on GPU model/architecture
- Fix filler expression not being respected when using a playlist as filler
- Use "repeat count" metadata from animated GIFs in graphics engine (i.e. watermarks)
- GIFs flagged to loop forever will loop forever
- GIFs with a specific loop count will loop the specified number of times and then hold the final frame
- Note that looping is relative to the start of the content, so this works best with permanent watermarks
- Fix some more hls.js warnings by adding codec information to multi-variant playlists
- Fix hardware decode of h264 constrained baseline content using VAAPI accel
- Custom stream selector: ignore embedded text subtitles that have not been extracted
- Fix cropping Jellyfin and Emby content that is smaller than the crop resolution
- Sync movies with non-file media sources (e.g. http/nfs) from Emby movie libraries by @jasonarends
### Changed
- Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration
- Use autocomplete fields for collection searching in deco editor
- This greatly improves the editor performance
## [25.6.0] - 2025-09-14
### Added
- Classic schedules: allow selecting multiple graphics elements on schedule items
- Block schedules: allow selecting multiple graphics elements on decos
- Add channel `Playout Source` setting
- `Generated`: default/existing behavior where channel must have its own playout
- `Mirror`: channel will play content from the specified `Mirror Source Channel`'s playout
- This allows the exact same content on different channels with different channel settings
- `Playout Offset` can be used to offset the times of scheduled playout items from the mirror source channel
- e.g. -2 hours will cause the mirror channel to play content 2 hours before the mirror source channel
- Add support for `.aif`, `.aifc`, `.aiff` song files
- Classic schedules: add playback order `Marathon`
- This can be used with collections and smart collections
- Items from the collection will be grouped by the `Marathon Group By` setting: `Artist`, `Album`, `Season` or `Show`
- The order of groups can optionally be shuffled
- The order of items in each group can optionally be shuffled (otherwise `Season, Episode` or `Chronological` as appropriate)
- A batch size can be set to limit the number of items to schedule from each group at a time
- Empty or zero batch size means play all items from each group before advancing
- Any other value means play the specified number of items before advancing to the next group
- Log API requests when `Request Logging Minimum Log Level` is set to `Debug`
- Add `Count` setting to each playlist item
- Previously, when `Play All` was unchecked, this was implicitly 1
- Now, the playlist can play a specific number of items from the collection before moving to the next playlist item
- Classic schedules: add `Shuffle Playlist Items` setting to shuffle the order of playlist items
- Shuffling happens initially (on playout reset), and after all items from the *entire playlist* have been played
- Add playout detail row coloring by @peterdey
- Filler has unique row colors
- Unscheduled gaps are now displayed and have a unique row color
- Process entire graphics element YAML files using scriban
- This allows things like different images based on `MediaItem_ContentRating` (movie) or `MediaItem_ShowContentRating` (episode)
- Playlists: add playback order `Shuffle In Order` for collections and smart collections
### Fixed
- Fix transcoding content with bt709/pc color metadata
- Fix scripted schedule validation (file exists) when creating or editing playout
- Fix adding single episode, movie, season, show to empty playlists
- Fix startup with MySql as non-superuser
- `local_infile=ON` is required when using MySQL (for bulk inserts when building playouts)
- ETV will set this automatically when it has permission
- When ETV does not have permission, startup will fail with logged instructions on how to configure MySql
- Fix scaling anamorphic content in locales that don't use period as a decimal separator (e.g. `,`)
- Block schedules: fix playout build crash when empty collection uses random playback order
- Fix watermarks and graphics elements on primary content split by mid-roll filler
- Fix watermarks and graphics elements when `Scaling Behavior` is `Crop`
- Fix hardware acceleration health check message on mobile
- Fix deco selection logic
- Fix inefficient database migration that would cause database initialization to get stuck
- Classic schedules: fix scheduling behavior when a flood item is before a flexible fixed start item
- Sometimes the flood item wouldn't schedule anything
- Fix troubleshooting certain text graphics elements by generating fake EPG data
### Changed
- **BREAKING CHANGE**: change how `Scripted Schedule` system works
- No longer uses embedded python (IronPython); instead uses HTTP API
- OpenAPI Description has been added at `/openapi/scripted-schedule.json`
- This allows scripted scheduling from *many* languages
- The scripted schedule file must now be directly executable (though a wrapper can be used to load a venv)
- The scripted schedule file will be passed the following arguments (in order):
- The API host (e.g. `http://localhost:8409`)
- The build id (a UUID string that is required on all API calls)
- The playout build mode (e.g. `reset` or `continue`, normally only used for specific logic when resetting a playout)
- Custom arguments can be included in the `Scripted Schedule` field in the playout editor
- Custom arguments will be passed *after* required arguments
- For example, a `Scripted Schedule` of `/home/jason/schedule.sh "party central" 23` will be executed like
- `/home/jason/schedule.sh http://localhost:8409 00000000-0000...0000 reset "party central" 23`
- This enables wrapper script re-use across multiple scripted schedules
- API reference is available at `/docs`
- Docker images contain pre-generated python api client and entrypoint script
- Entrypoint is at `/app/scripted-schedules/entrypoint.py`
- Scripts folder should be mounted to `/app/scripted-schedules/scripts`
- Playouts should be created with scripted schedule `/app/scripted-schedules/entrypoint.py script-name` (no trailing `.py`)
- Automatically ignore Specials/Season 0 when using `Season, Episode` playback order
## [25.5.0] - 2025-09-01
### Added
- Add *experimental* graphics engine
- All watermarks will use new graphics engine
- Add `Opacity Expression` watermark mode
- This allows specifying an expression that returns an opacity between 0.0 and 1.0
- The expression can use:
- `content_seconds` - the total number of seconds the frame is into the content
- `content_total_seconds` - the total number of seconds in the content
- `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
- `time_of_day_seconds` - the total number of seconds the frame is since midnight
- The expression can also use functions:
- `LinearFadeDuration(time, start, fadeSeconds, peakSeconds)`
- `LinearFadePoints(time, start, peakStart, peakEnd, end)`
- Add `Z-Index` to watermark editor
- The graphics engine will order by z-index when overlaying watermarks
- Add *experimental* `Graphics Element` template system
- Graphics elements are defined in YAML files inside ETV config folder / templates / graphics-elements subfolder
- Add `text` graphics element type
- Supported in playback troubleshooting and YAML playouts
- Displays multi-line text in a specified font, color, location, z-index
- Supports constant opacity and opacity expression
- Supports EPG and Media Item variable replacement
- EPG data is sourced from XMLTV for the current time
- EPG data can also load a configurable number of subsequent (up next) entries
- Media Item data is sourced from the currently playing media item
- Add `image` graphics element type
- Supported in playback troubleshooting and YAML playouts
- Displays an image, similar to a watermark
- Supports constant opacity and opacity expression
- Add `subtitle` graphics element type
- Supported in playback troubleshooting and YAML playouts
- Supports SRT and SSA/ASS subtitle formats
- Supports EPG and Media Item variable replacement
- EPG data is sourced from XMLTV for the current time
- EPG data can also load a configurable number of subsequent (up next) entries
- Media Item data is sourced from the currently playing media item
- YAML playout: add `graphics_on` and `graphics_off` instructions to control graphics elements
- `graphics_on` requires the name of a graphics element template, e.g. `text/cool_element.yml`
- The `variables` property can be used to dynamically replace text from the template
- `graphics_off` will turn off a specific element, or all elements if none are specified
- Add `Seek Seconds` to playback troubleshooting to support capturing timing-related issues
- Custom stream selector: add `content_condition` to allow channel and time-of-day based decisions
- `content_condition` expression can use
- `channel_number`
- `channel_name`
- `time_of_day_seconds` - the start time for the current item, represented in seconds since midnight
- Add support for external chapter files next to video files
- Currently supports Matroska Chapter XML format
- Chapter files have .xml or .chapters extension
- Add targeted (single-show) library scanning
- Supports quick and deep scans
- Can be triggered from the `Scan` button on show pages
- Can be triggered by API call to `/api/libraries/{library-id}/scan-show`
- Add XMLTV setting `XMLTV Block Behavior` to control how block schedules appear in the EPG
- `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG
- `Use Actual Times` - actual times are used for all items that are visible in the EPG
- This will introduce EPG gaps when filler is used, or when items are hidden from the EPG
- Add *experimental* `Scripted Schedule` playout system
- This system uses python scripts to support the highest degree of customization
- The goal is to expose methods equivalent to all sequential schedule (YAML) instructions
- YAML and Scripted schedules: add `offline_tail` and `stop_before_end` to `pad_to_next` instruction
- Both parameters default to `true`
### Fix
- Fix database operations that were slowing down playout builds
- YAML playouts in particular should build significantly faster
- Fix channel playout mode `On Demand` for Block and YAML schedules
- Fix QSV transitions when remote streaming from a media server
- Fix green output when padding with VAAPI accel and i965 driver
- Fix watermark custom image validation
- Fix playback when using any watermarks that were saved with invalid state (no image)
- Fix overlapping block playout items caused by `Stop scheduling block items` value `After Duration End`
- Existing overlapping items will not be removed, but no new overlapping items will be created
- Until these existing items age out, there will be warnings logged after each playout build/extension
- Fix playback of anamorphic content from Jellyfin
- This fix requires a manual deep scan of any affected Jellyfin library
- Fix bug where multiple Plex servers would mix their episodes
- Fix incorrect media item counts after removing paths from local libraries
- Fix song playback in playback troubleshooting
- Fix seeking into extracted text subtitles
- Fix error when changing default (lowest priority) alternate schedule
- Fix remote library editing, tv shows, artists with MySql/MariaDB
- Classic schedules: fix alternate schedule transitions (some edge cases would cause days to be skipped completely)
- Classic schedules: always start new alternate schedules with the first schedule item
- Classic Schedules: log offline gaps longer than 1 hour due to strict fixed start times
- Fix `HLS Segmenter V2` streaming mode with AMF acceleration
- Fix `HLS Segmenter V2` streaming mode with VideoToolbox acceleration
- Fix startup process for database and search index initialization
- Redirect all pages to home page when initializing to prevent errors
- Clear stale sqlite migration lock on startup to prevent getting stuck on database initialization
- Fix display of long season placeholder text (when season posters are unavailable)
### Changed
- Rename some schedule and playout terms for clarity
- Schedules are used to build playouts and are what actually differs
- The playout is the end result, and is the same no matter what schedule kind is used
- Supported schedule kinds:
- `Classic Schedules`
- `Block Schedules`
- `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`)
- `Scripted Schedules`
- `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`)
- Allow multiple watermarks in playback troubleshooting
- Classic schedules: allow selecting multiple watermarks on schedule items
- Block schedules: allow selecting multiple watermarks on decos
- Block schedules: change available watermark modes on decos. For reference, the levels from highest to lowest with block schedules are `Global` > `Channel` > `Playout Default Deco` > `Template Deco`.
- `Inherit` - Use watermarks configured at a higher level
- `Disable` - Disable watermarks at this level and above
- `Replace` - Replace all watermarks configured at a higher level with those on this deco
- This was renamed from `Override`
- `Merge` - Merge all watermarks configured at a higher level with those on this deco
- YAML playout: `watermark` instruction changes:
- When value is `true`, will add named watermark to list of active watermarks
- When value is `false` and `name` is specified, will remove named watermark from list of active watermarks
- When value is `false` and `name` is not specified, will clear all active watermarks
- Use consistent UI sorting and validation, and fix renaming errors for
- Block groups, blocks
- Template groups, templates
- Deco groups, decos
- Deco template groups, deco templates
## [25.4.0] - 2025-08-05
### Added
- Add `Troubleshoot Playback` to overflow menu on all media cards
@@ -1950,7 +2269,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Allow `Shuffle In Order` with Collections and Smart Collections
- Episodes will be grouped by show, and music videos will be grouped by artist
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
- All groups will be ordered chronologically (custom ordering is only supported in multi-collections)
### Fixed
- Generate XMLTV that validates successfully
@@ -2505,7 +2824,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...HEAD
[25.7.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...v25.7.1
[25.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0
[25.6.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.5.0...v25.6.0
[25.5.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...v25.5.0
[25.4.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.1...v25.4.0
[25.3.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.0...v25.3.1
[25.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.2.0...v25.3.0
@@ -2635,4 +2958,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
[0.0.5-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
[0.0.4-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
[0.0.3-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha

View File

@@ -21,7 +21,7 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
Option<Artwork> artwork = await dbContext.Artwork
.AsNoTracking()
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
.SelectOneAsync(a => a.Id, a => a.Id == request.Id, cancellationToken)
.MapT(Project);
return artwork.ToEither(BaseError.New("Artwork not found"));

View File

@@ -0,0 +1,10 @@
namespace ErsatzTV.Application.Channels;
public class ChannelSortViewModel
{
public int Id { get; set; }
public string Number { get; set; }
public string Name { get; set; }
public string OriginalNumber { get; set; }
public bool HasChanged => OriginalNumber != Number;
}

View File

@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
public record ChannelStreamingSpecsViewModel(
int Height,
int Width,
int Bitrate,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
FFmpegProfileAudioFormat AudioFormat);

View File

@@ -16,7 +16,10 @@ public record ChannelViewModel(
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,

View File

@@ -15,7 +15,10 @@ public record CreateChannel(
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -22,7 +23,7 @@ public class CreateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => PersistChannel(dbContext, c));
}
@@ -35,17 +36,19 @@ public class CreateChannelHandler(
return new CreateChannelResult(channel.Id);
}
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
(ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
.Apply((
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId) =>
fillerPresetId,
_) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
@@ -73,10 +76,14 @@ public class CreateChannelHandler(
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,
PlayoutOffset = request.PlayoutOffset,
StreamingMode = request.StreamingMode,
Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
@@ -94,6 +101,16 @@ public class CreateChannelHandler(
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
{
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
}
else
{
channel.MirrorSourceChannelId = null;
channel.PlayoutOffset = null;
}
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
@@ -113,10 +130,11 @@ public class CreateChannelHandler(
private static async Task<Validation<BaseError, string>> ValidateNumber(
TvContext dbContext,
CreateChannel createChannel)
CreateChannel createChannel,
CancellationToken cancellationToken)
{
Option<Channel> maybeExistingChannel = await dbContext.Channels
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number);
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number, cancellationToken);
return maybeExistingChannel.Match<Validation<BaseError, string>>(
_ => BaseError.New("Channel number must be unique"),
() =>
@@ -132,9 +150,10 @@ public class CreateChannelHandler(
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
TvContext dbContext,
CreateChannel createChannel) =>
CreateChannel createChannel,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.CountAsync(p => p.Id == createChannel.FFmpegProfileId)
.CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => createChannel.FFmpegProfileId)
@@ -142,7 +161,8 @@ public class CreateChannelHandler(
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
CreateChannel createChannel)
CreateChannel createChannel,
CancellationToken cancellationToken)
{
if (createChannel.WatermarkId is null)
{
@@ -150,7 +170,7 @@ public class CreateChannelHandler(
}
return await dbContext.ChannelWatermarks
.CountAsync(w => w.Id == createChannel.WatermarkId)
.CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.WatermarkId))
@@ -159,7 +179,8 @@ public class CreateChannelHandler(
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
TvContext dbContext,
CreateChannel createChannel)
CreateChannel createChannel,
CancellationToken cancellationToken)
{
if (createChannel.FallbackFillerId is null)
{
@@ -168,11 +189,53 @@ public class CreateChannelHandler(
return await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.CountAsync(w => w.Id == createChannel.FallbackFillerId)
.CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
}
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
CreateChannel createChannel,
CancellationToken cancellationToken)
{
if (createChannel.PlayoutSource is not ChannelPlayoutSource.Mirror)
{
return Unit.Default;
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
c => c.Id == createChannel.MirrorSourceChannelId,
c => c.Id == createChannel.MirrorSourceChannelId,
cancellationToken);
if (maybeMirrorSource.IsNone)
{
return BaseError.New("Mirror source channel does not exist.");
}
foreach (var mirrorSource in maybeMirrorSource)
{
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
{
return BaseError.New(
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
}
}
foreach (TimeSpan playoutOffset in Optional(createChannel.PlayoutOffset))
{
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
{
return BaseError.New("Playout offset must not be greater than 12 hours");
}
}
return Unit.Default;
}
}

View File

@@ -31,7 +31,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request);
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
}
@@ -57,10 +57,11 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
DeleteChannel deleteChannel)
DeleteChannel deleteChannel,
CancellationToken cancellationToken)
{
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken);
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
}
}

View File

@@ -11,6 +11,7 @@ using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
@@ -45,216 +46,261 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
{
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
try
{
File.Delete(targetFile);
return;
}
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
{
return;
}
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
var minifier = new XmlMinifier(
new XmlMinificationSettings
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Flood:
case ProgramSchedulePlayoutType.Yaml:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ExternalJsonFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
File.Delete(targetFile);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
{
return;
}
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.MirrorSourceChannel)
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
.SelectOneAsync(
c => c.Number == request.ChannelNumber,
c => c.Number == request.ChannelNumber,
cancellationToken);
foreach (Channel channel in maybeChannel)
{
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
}
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in floodSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in blockSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in externalJsonSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
}
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
File.Move(tempFile, targetFile, true);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
File.Move(tempFile, targetFile, true);
}
private async Task WritePlayoutXml(
@@ -267,10 +313,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template songTemplate,
Template otherVideoTemplate,
XmlMinifier minifier,
XmlWriter xml)
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
// skip all filler that isn't pre-roll
@@ -356,59 +403,106 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template songTemplate,
Template otherVideoTemplate,
XmlMinifier minifier,
XmlWriter xml)
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await _configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = xmltvTimeZone switch
switch (xmltvBlockBehavior)
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
DateTimeOffset actualFinish = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
currentStart = currentFinish;
currentFinish += perItem;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
}
}

View File

@@ -118,7 +118,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
from Channel C
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
where C.Id in (select ChannelId from Playout) and C.IsEnabled = 1 and C.ShowInEPG = 1
where (C.Id in (select ChannelId from Playout) or C.MirrorSourceChannelId in (select ChannelId from Playout)) and C.IsEnabled = 1 and C.ShowInEPG = 1
order by CAST(C.Number as double)";
// TODO: this needs to be fixed for sqlite/mariadb

View File

@@ -16,7 +16,10 @@ public record UpdateChannel(
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
@@ -23,14 +24,31 @@ public class UpdateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
UpdateChannel update,
CancellationToken cancellationToken)
{
// don't save mirror when playout exists
if (c.Playouts.Count > 0)
{
update = update with
{
PlayoutSource = ChannelPlayoutSource.Generated,
MirrorSourceChannelId = null
};
}
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
c.Number = update.Number;
c.SortNumber = double.Parse(update.Number, CultureInfo.InvariantCulture);
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
@@ -86,7 +104,7 @@ public class UpdateChannelHandler(
{
await dbContext.Entry(c)
.Collection(channel => channel.Artwork)
.LoadAsync();
.LoadAsync(cancellationToken);
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
{
@@ -95,54 +113,125 @@ public class UpdateChannelHandler(
}
}
c.PlayoutSource = update.PlayoutSource;
c.PlayoutMode = update.PlayoutMode;
if (c.PlayoutSource is ChannelPlayoutSource.Mirror)
{
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
}
else
{
c.MirrorSourceChannelId = null;
c.PlayoutOffset = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
if (c.SubtitleMode != ChannelSubtitleMode.None)
{
Option<Playout> maybePlayout = await dbContext.Playouts
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id);
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
foreach (Playout playout in maybePlayout)
{
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
}
}
await workerChannel.WriteAsync(new RefreshChannelList());
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
if (hasEpgChange)
{
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
}
return ProjectToViewModel(c);
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
}
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
(await ChannelMustExist(dbContext, request), ValidateName(request),
await ValidateNumber(dbContext, request))
.Apply((channelToUpdate, _, _) => channelToUpdate);
private static async Task<Validation<BaseError, Channel>> Validate(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken) =>
(await ChannelMustExist(dbContext, request, cancellationToken),
ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
UpdateChannel updateChannel) =>
UpdateChannel updateChannel,
CancellationToken cancellationToken) =>
dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId)
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
{
return Unit.Default;
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
c => c.Id == request.MirrorSourceChannelId,
c => c.Id == request.MirrorSourceChannelId,
cancellationToken);
if (maybeMirrorSource.IsNone)
{
return BaseError.New("Mirror source channel does not exist.");
}
foreach (var mirrorSource in maybeMirrorSource)
{
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
{
return BaseError.New(
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
}
}
foreach (TimeSpan playoutOffset in Optional(request.PlayoutOffset))
{
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
{
return BaseError.New("Playout offset must not be greater than 12 hours");
}
}
return Unit.Default;
}
private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
updateChannel.NotEmpty(c => c.Name)
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
private static async Task<Validation<BaseError, string>> ValidateNumber(
TvContext dbContext,
UpdateChannel updateChannel)
UpdateChannel updateChannel,
CancellationToken cancellationToken)
{
int matchId = await dbContext.Channels
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken)
.Match(c => c.Id, () => updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record UpdateChannelNumbers(List<ChannelSortViewModel> Channels) : IRequest<Option<BaseError>>;

View File

@@ -0,0 +1,63 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class UpdateChannelNumbersHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateChannelNumbers, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
var channelIds = numberUpdates.Keys;
List<Channel> channelsToUpdate = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
// give every channel a non-conflicting number
foreach (var channel in channelsToUpdate)
{
channel.Number = $"-{channel.Id}";
}
// save those changes
await dbContext.SaveChangesAsync(cancellationToken);
// give every channel the proper new number
foreach (var channel in channelsToUpdate)
{
channel.Number = numberUpdates[channel.Id];
}
// save those changes
await dbContext.SaveChangesAsync(cancellationToken);
// commit the transaction
await transaction.CommitAsync(cancellationToken);
// update channel list and xmltv
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
foreach (var channel in channelsToUpdate)
{
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
}
return Option<BaseError>.None;
}
catch (Exception ex)
{
return BaseError.New("Failed to update channel numbers: " + ex.Message);
}
}
}

View File

@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Channels;
internal static class Mapper
{
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new(
channel.Id,
channel.Number,
@@ -19,11 +19,14 @@ internal static class Mapper
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.PlayoutSource,
channel.PlayoutMode,
channel.MirrorSourceChannelId,
channel.PlayoutOffset,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0,
playoutCount,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
@@ -46,8 +49,14 @@ internal static class Mapper
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
new(resolution.Height, resolution.Width, bitrate);
internal static ChannelStreamingSpecsViewModel ProjectToSpecsViewModel(Channel channel) =>
new(
channel.FFmpegProfile.Resolution.Height,
channel.FFmpegProfile.Resolution.Width,
(int)((channel.FFmpegProfile.VideoBitrate * 1000 + channel.FFmpegProfile.AudioBitrate * 1000) * 1.2),
channel.FFmpegProfile.VideoFormat,
channel.FFmpegProfile.VideoProfile,
channel.FFmpegProfile.AudioFormat);
private static ArtworkContentTypeModel GetLogo(Channel channel)
{
@@ -72,6 +81,7 @@ internal static class Mapper
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterFmp4 => "HLS Segmenter (fmp4)",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};

View File

@@ -15,7 +15,7 @@ public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi,
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(ProjectToResponseModel).ToList();
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetAllChannelsForSort : IRequest<List<ChannelSortViewModel>>;

View File

@@ -0,0 +1,31 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForSortHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllChannelsForSort, List<ChannelSortViewModel>>
{
public async Task<List<ChannelSortViewModel>> Handle(
GetAllChannelsForSort request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Channels
.AsNoTracking()
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToSortViewModel)
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)).ToList());
}
private static ChannelSortViewModel ProjectToSortViewModel(Channel channel)
=> new()
{
Id = channel.Id,
Number = channel.Number,
Name = channel.Name,
OriginalNumber = channel.Number
};
}

View File

@@ -1,14 +1,30 @@
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsHandler : IRequestHandler<GetAllChannels, List<ChannelViewModel>>
public class GetAllChannelsHandler(IChannelRepository channelRepository)
: IRequestHandler<GetAllChannels, List<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
await channelRepository.GetAll(cancellationToken)
.Map(list => list.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
private static int GetPlayoutsCount(Channel channel)
{
var result = 0;
if (channel.Playouts != null)
{
result += channel.Playouts.Count;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
{
result += channel.MirrorSourceChannel.Playouts.Count;
}
return result;
}
}

View File

@@ -8,5 +8,5 @@ public class GetChannelByIdHandler(IChannelRepository channelRepository)
{
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
.MapT(c => ProjectToViewModel(c, 0));
}

View File

@@ -3,12 +3,9 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
public class GetChannelByNumberHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
channelRepository.GetByNumber(request.ChannelNumber).MapT(c => ProjectToViewModel(c, 0));
}

View File

@@ -10,7 +10,7 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
_channelRepository.GetAll(cancellationToken)
.Map(channels => channels.Where(c => c.IsEnabled)
.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
}

View File

@@ -16,7 +16,7 @@ public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameBy
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.MapT(p => p.Channel.Name);
}
}

View File

@@ -12,7 +12,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
_channelRepository = channelRepository;
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
_channelRepository.GetAll(cancellationToken)
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(
request.Scheme,
@@ -27,7 +27,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
var result = new List<Channel>();
foreach (Channel channel in channels)
{
if (channel.IsEnabled == false)
if (!channel.IsEnabled)
{
continue;
}
@@ -38,6 +38,10 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "segmenter-fmp4":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterFmp4;
result.Add(channel);
break;
case "segmenter-v2":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
result.Add(channel);

View File

@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolutionAndBitrate(string ChannelNumber) : IRequest<Option<ResolutionAndBitrateViewModel>>;

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelStreamingSpecs(string ChannelNumber) : IRequest<Option<ChannelStreamingSpecsViewModel>>;

View File

@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolutionAndBitrate, Option<ResolutionAndBitrateViewModel>>
public class GetChannelStreamingSpecsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelStreamingSpecs, Option<ChannelStreamingSpecsViewModel>>
{
public async Task<Option<ResolutionAndBitrateViewModel>> Handle(
GetChannelResolutionAndBitrate request,
public async Task<Option<ChannelStreamingSpecsViewModel>> Handle(
GetChannelStreamingSpecs request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -18,10 +18,8 @@ public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext>
.AsNoTracking()
.Include(c => c.FFmpegProfile)
.ThenInclude(ff => ff.Resolution)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
c.FFmpegProfile.Resolution,
(int)((c.FFmpegProfile.VideoBitrate * 1000 + c.FFmpegProfile.AudioBitrate * 1000) * 1.2)));
return maybeChannel.Map(Mapper.ProjectToSpecsViewModel);
}
}

View File

@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Channels;
public record ResolutionAndBitrateViewModel(int Height, int Width, int Bitrate);

View File

@@ -10,5 +10,5 @@ public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementBy
_configElementRepository = configElementRepository;
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
await _configElementRepository.Upsert(request.Key, request.Value);
await _configElementRepository.Upsert(request.Key, request.Value, cancellationToken);
}

View File

@@ -18,13 +18,14 @@ public class UpdateLibraryRefreshIntervalHandler :
Validate(request)
.MapT(_ => _configElementRepository.Upsert(
ConfigElementKey.LibraryRefreshInterval,
request.LibraryRefreshInterval))
request.LibraryRefreshInterval,
cancellationToken))
.Bind(v => v.ToEitherAsync());
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
Optional(request.LibraryRefreshInterval)
.Where(lri => lri is >= 0 and < 1_000_000)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Library refresh interval must be zero or greated")
.ToValidation<BaseError>("Library refresh interval must be zero or greater")
.AsTask();
}

View File

@@ -19,36 +19,36 @@ public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSetting
public async Task<Either<BaseError, Unit>> Handle(
UpdateLoggingSettings request,
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings);
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings, cancellationToken);
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings)
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings, CancellationToken cancellationToken)
{
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel);
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelScanning,
loggingSettings.ScanningMinimumLogLevel);
loggingSettings.ScanningMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelScheduling,
loggingSettings.SchedulingMinimumLogLevel);
loggingSettings.SchedulingMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelSearching,
loggingSettings.SearchingMinimumLogLevel);
loggingSettings.SearchingMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.SearchingLevelSwitch.MinimumLevel = loggingSettings.SearchingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelStreaming,
loggingSettings.StreamingMinimumLogLevel);
loggingSettings.StreamingMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelHttp,
loggingSettings.HttpMinimumLogLevel);
loggingSettings.HttpMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = loggingSettings.HttpMinimumLogLevel;
return Unit.Default;

View File

@@ -32,24 +32,40 @@ public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSetting
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Unit> validation = await Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(
dbContext,
request.PlayoutSettings,
cancellationToken));
}
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
private async Task<Unit> ApplyUpdate(
TvContext dbContext,
PlayoutSettingsViewModel playoutSettings,
CancellationToken cancellationToken)
{
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutDaysToBuild,
playoutSettings.DaysToBuild,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutSkipMissingItems,
playoutSettings.SkipMissingItems);
playoutSettings.SkipMissingItems,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
playoutSettings.ScriptedScheduleTimeoutSeconds,
cancellationToken);
// continue all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel)
.ToListAsync();
.ToListAsync(cancellationToken);
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number, CultureInfo.InvariantCulture))
.Map(p => p.Id))
{
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue), cancellationToken);
}
return Unit.Default;

View File

@@ -20,7 +20,7 @@ public class UpdateXmltvSettingsHandler(
{
int playoutDaysToBuild =
await configElementRepository
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
.IfNoneAsync(2);
if (playoutDaysToBuild < request.XmltvSettings.DaysToBuild)
@@ -29,19 +29,20 @@ public class UpdateXmltvSettingsHandler(
$"XMLTV days to build ({request.XmltvSettings.DaysToBuild}) cannot be greater than Playout days to build ({playoutDaysToBuild})");
}
return await ApplyUpdate(request.XmltvSettings);
return await ApplyUpdate(request.XmltvSettings, cancellationToken);
}
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings)
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings, CancellationToken cancellationToken)
{
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone);
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild);
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone, cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild, cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.XmltvBlockBehavior, xmltvSettings.BlockBehavior, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync())
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync(cancellationToken))
{
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber));
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
}
return Unit.Default;

View File

@@ -4,4 +4,5 @@ public class PlayoutSettingsViewModel
{
public int DaysToBuild { get; set; }
public bool SkipMissingItems { get; set; }
public int ScriptedScheduleTimeoutSeconds { get; set; }
}

View File

@@ -13,5 +13,5 @@ public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKe
public Task<Option<ConfigElementViewModel>> Handle(
GetConfigElementByKey request,
CancellationToken cancellationToken) =>
_configElementRepository.GetConfigElement(request.Key).MapT(ProjectToViewModel);
_configElementRepository.GetConfigElement(request.Key, cancellationToken).MapT(ProjectToViewModel);
}

View File

@@ -11,6 +11,6 @@ public class GetLibraryRefreshIntervalHandler : IRequestHandler<GetLibraryRefres
_configElementRepository = configElementRepository;
public Task<int> Handle(GetLibraryRefreshInterval request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.Map(result => result.IfNone(6));
}

View File

@@ -14,22 +14,32 @@ public class GetLoggingSettingsHandler : IRequestHandler<GetLoggingSettings, Log
public async Task<LoggingSettingsViewModel> Handle(GetLoggingSettings request, CancellationToken cancellationToken)
{
Option<LogEventLevel> maybeDefaultLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel, cancellationToken);
Option<LogEventLevel> maybeScanningLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScanning);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelScanning,
cancellationToken);
Option<LogEventLevel> maybeSchedulingLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScheduling);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelScheduling,
cancellationToken);
Option<LogEventLevel> maybeSearchingLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelSearching);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelSearching,
cancellationToken);
Option<LogEventLevel> maybeStreamingLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelStreaming);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelStreaming,
cancellationToken);
Option<LogEventLevel> maybeHttpLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelHttp);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelHttp,
cancellationToken);
return new LoggingSettingsViewModel
{

View File

@@ -12,15 +12,23 @@ public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, Pla
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
{
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(
ConfigElementKey.PlayoutDaysToBuild,
cancellationToken);
Option<bool> skipMissingItems =
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems, cancellationToken);
Option<int> scriptedScheduleTimeoutSeconds =
await _configElementRepository.GetValue<int>(
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
cancellationToken);
return new PlayoutSettingsViewModel
{
DaysToBuild = await daysToBuild.IfNoneAsync(2),
SkipMissingItems = await skipMissingItems.IfNoneAsync(false)
SkipMissingItems = await skipMissingItems.IfNoneAsync(false),
ScriptedScheduleTimeoutSeconds = await scriptedScheduleTimeoutSeconds.IfNoneAsync(30)
};
}
}

View File

@@ -8,15 +8,23 @@ public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepos
{
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
{
Option<int> daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.XmltvDaysToBuild);
Option<int> daysToBuild = await configElementRepository.GetValue<int>(
ConfigElementKey.XmltvDaysToBuild,
cancellationToken);
Option<XmltvTimeZone> maybeTimeZone =
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken);
Option<XmltvBlockBehavior> maybeBlockBehavior =
await configElementRepository.GetValue<XmltvBlockBehavior>(
ConfigElementKey.XmltvBlockBehavior,
cancellationToken);
return new XmltvSettingsViewModel
{
DaysToBuild = await daysToBuild.IfNoneAsync(2),
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local),
BlockBehavior = await maybeBlockBehavior.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly)
};
}
}

View File

@@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Configuration;
public enum XmltvBlockBehavior
{
SplitTimeEvenly = 0,
UseActualTimes = 1
}

View File

@@ -4,4 +4,5 @@ public class XmltvSettingsViewModel
{
public int DaysToBuild { get; set; }
public XmltvTimeZone TimeZone { get; set; }
public XmltvBlockBehavior BlockBehavior { get; set; }
}

View File

@@ -26,7 +26,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
@@ -40,10 +40,13 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
DateTime minDateTime = await dbContext.EmbyMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);

View File

@@ -38,7 +38,7 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
@@ -77,10 +77,11 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
ISynchronizeEmbyLibraryById request)
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{
DateTime minDateTime = await dbContext.EmbyLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);

View File

@@ -0,0 +1,79 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Emby;
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
{
public CallEmbyShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
SynchronizeEmbyShowById request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
private async Task<Either<BaseError, string>> Handle(
SynchronizeEmbyShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, string>>(error.Join());
});
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
SynchronizeEmbyShowById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-emby-show",
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture)
};
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
}
protected override Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizeEmbyShowById request,
CancellationToken cancellationToken) =>
Task.FromResult(DateTimeOffset.MinValue);
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeEmbyShowById request) =>
true;
}

View File

@@ -33,18 +33,21 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
public Task<Either<BaseError, Unit>> Handle(
SynchronizeEmbyLibraries request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(SynchronizeLibraries)
Validate(request, cancellationToken)
.MapT(p => SynchronizeLibraries(p, cancellationToken))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyLibraries request) =>
MediaSourceMustExist(request)
private Task<Validation<BaseError, ConnectionParameters>> Validate(
SynchronizeEmbyLibraries request,
CancellationToken cancellationToken) =>
MediaSourceMustExist(request, cancellationToken)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
SynchronizeEmbyLibraries request) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
SynchronizeEmbyLibraries request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
@@ -65,7 +68,9 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
.ToValidation<BaseError>("Emby media source requires an api key");
}
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
private async Task<Unit> SynchronizeLibraries(
ConnectionParameters connectionParameters,
CancellationToken cancellationToken)
{
Either<BaseError, List<EmbyLibrary>> maybeLibraries = await _embyApiClient.GetLibraries(
connectionParameters.ActiveConnection.Address,
@@ -91,7 +96,8 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
connectionParameters.EmbyMediaSource.Id,
toAdd,
toRemove,
toUpdate);
toUpdate,
cancellationToken);
if (ids.Count != 0)
{
await _searchIndex.RemoveItems(ids);

View File

@@ -23,7 +23,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
SynchronizeEmbyMediaSources request,
CancellationToken cancellationToken)
{
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby(cancellationToken);
foreach (EmbyMediaSource mediaSource in mediaSources)
{
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan)
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;

View File

@@ -23,7 +23,7 @@ public class
UpdateEmbyLibraryPreferences request,
CancellationToken cancellationToken)
{
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
var toDisable = request.Preferences.Filter(p => !p.ShouldSyncItems).Map(p => p.Id).ToList();
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();

View File

@@ -15,7 +15,7 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
public Task<Either<BaseError, Unit>> Handle(
UpdateEmbyPathReplacements request,
CancellationToken cancellationToken) =>
Validate(request)
Validate(request, cancellationToken)
.MapT(pms => MergePathReplacements(request, pms))
.Bind(v => v.ToEitherAsync());
@@ -37,12 +37,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath };
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request) =>
EmbyMediaSourceMustExist(request);
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
EmbyMediaSourceMustExist(request, cancellationToken);
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
UpdateEmbyPathReplacements request) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
.Map(v => v.ToValidation<BaseError>(
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
}

View File

@@ -13,5 +13,5 @@ public class GetAllEmbyMediaSourcesHandler : IRequestHandler<GetAllEmbyMediaSour
public Task<List<EmbyMediaSourceViewModel>> Handle(
GetAllEmbyMediaSources request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetAllEmby().Map(list => list.Map(ProjectToViewModel).ToList());
_mediaSourceRepository.GetAllEmby(cancellationToken).Map(list => list.Map(ProjectToViewModel).ToList());
}

View File

@@ -34,7 +34,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
}
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
await Validate()
await Validate(cancellationToken)
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
@@ -47,13 +47,14 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
error => error);
}
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
EmbyMediaSourceMustExist()
private Task<Validation<BaseError, ConnectionParameters>> Validate(CancellationToken cancellationToken) =>
EmbyMediaSourceMustExist(cancellationToken)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
private Task<Validation<BaseError, EmbyMediaSource>>
EmbyMediaSourceMustExist(CancellationToken cancellationToken) =>
_mediaSourceRepository.GetAllEmby(cancellationToken).Map(list => list.HeadOrNone())
.Map(v => v.ToValidation<BaseError>(
"Emby media source does not exist."));

View File

@@ -14,5 +14,5 @@ public class
public Task<Option<EmbyMediaSourceViewModel>> Handle(
GetEmbyMediaSourceById request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId).MapT(ProjectToViewModel);
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken).MapT(ProjectToViewModel);
}

View File

@@ -6,6 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Configurations>Debug;Release;Debug No Sync</Configurations>
</PropertyGroup>
<ItemGroup>
@@ -13,13 +14,13 @@
<PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
@@ -30,4 +31,4 @@
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -11,6 +11,8 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=health_005Cqueries/@EntryIndexedValue">True</s:Boolean>

View File

@@ -24,7 +24,7 @@ public class CreateFFmpegProfileHandler :
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
}
@@ -40,8 +40,10 @@ public class CreateFFmpegProfileHandler :
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
CreateFFmpegProfile request) =>
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
CreateFFmpegProfile request,
CancellationToken cancellationToken) =>
(ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
{
Name = name,
@@ -84,9 +86,10 @@ public class CreateFFmpegProfileHandler :
private static Task<Validation<BaseError, int>> ResolutionMustExist(
TvContext dbContext,
CreateFFmpegProfile createFFmpegProfile) =>
CreateFFmpegProfile createFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId)
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken)
.MapT(r => r.Id)
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
}

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -7,23 +8,18 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles;
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
public class DeleteFFmpegProfileHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ISearchTargets searchTargets)
: IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p));
}
@@ -31,14 +27,58 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
{
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
searchTargets.SearchTargetsChanged();
return Unit.Default;
}
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
DeleteFFmpegProfile request,
CancellationToken cancellationToken) =>
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
DeleteFFmpegProfile request) =>
DeleteFFmpegProfile request,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId)
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
TvContext dbContext,
DeleteFFmpegProfile request,
CancellationToken cancellationToken)
{
int count = await dbContext.Channels
.AsNoTracking()
.Where(c => c.FFmpegProfileId == request.FFmpegProfileId)
.CountAsync(cancellationToken);
if (count > 0)
{
return BaseError.New(
$"Cannot delete FFmpeg Profile that is used by {count} {(count > 1 ? "channels" : "channel")}");
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeDefault(
DeleteFFmpegProfile request,
CancellationToken cancellationToken)
{
Option<int> defaultFFmpegProfileId =
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
if (defaultFFmpegProfileId.Any(id => id == request.FFmpegProfileId))
{
return BaseError.New("Cannot delete default FFmpeg Profile");
}
return Unit.Default;
}
}

View File

@@ -18,7 +18,7 @@ public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegP
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int defaultResolutionId = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId)
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId, cancellationToken)
.IfNoneAsync(0);
List<Resolution> allResolutions = await dbContext.Resolutions

View File

@@ -1,6 +1,10 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -24,14 +28,15 @@ public class
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
TvContext dbContext,
FFmpegProfile p,
UpdateFFmpegProfile update)
UpdateFFmpegProfile update,
CancellationToken cancellationToken)
{
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
@@ -52,6 +57,13 @@ public class
? FFmpegProfileBitDepth.EightBit
: update.BitDepth;
if (p.HardwareAcceleration is not (HardwareAccelerationKind.Nvenc or HardwareAccelerationKind.Vaapi
or HardwareAccelerationKind.Qsv) &&
p.VideoFormat is FFmpegProfileVideoFormat.Av1)
{
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
}
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.TonemapAlgorithm = update.TonemapAlgorithm;
@@ -63,7 +75,19 @@ public class
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.DeinterlaceVideo = update.DeinterlaceVideo;
await dbContext.SaveChangesAsync();
// don't save invalid preset
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
p.HardwareAcceleration,
p.VideoFormat,
p.BitDepth);
if (!presets.Contains(p.VideoPreset))
{
p.VideoPreset = VideoPreset.Unset;
}
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
@@ -72,16 +96,19 @@ public class
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
UpdateFFmpegProfile request) =>
(await FFmpegProfileMustExist(dbContext, request), ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request))
UpdateFFmpegProfile request,
CancellationToken cancellationToken) =>
(await FFmpegProfileMustExist(dbContext, request, cancellationToken), ValidateName(request),
ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile) =>
UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId)
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
@@ -93,9 +120,10 @@ public class
private static Task<Validation<BaseError, int>> ResolutionMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile) =>
UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId)
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
.MapT(r => r.Id)
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
}

View File

@@ -29,7 +29,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
UpdateFFmpegSettings request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyUpdate(request))
.MapT(_ => ApplyUpdate(request, cancellationToken))
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
@@ -69,19 +69,22 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
: BaseError.New($"Unable to verify {name} version");
}
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request, CancellationToken cancellationToken)
{
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath);
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegDefaultProfileId,
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture));
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture),
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSaveReports,
request.Settings.SaveReports.ToString());
request.Settings.SaveReports.ToString(),
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegHlsDirectOutputFormat,
request.Settings.HlsDirectOutputFormat);
request.Settings.HlsDirectOutputFormat,
cancellationToken);
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{
@@ -90,61 +93,69 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegPreferredLanguageCode,
request.Settings.PreferredAudioLanguageCode);
request.Settings.PreferredAudioLanguageCode,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
request.Settings.UseEmbeddedSubtitles);
request.Settings.UseEmbeddedSubtitles,
cancellationToken);
// do not extract when subtitles are not used
if (request.Settings.UseEmbeddedSubtitles == false)
if (!request.Settings.UseEmbeddedSubtitles)
{
request.Settings.ExtractEmbeddedSubtitles = false;
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
request.Settings.ExtractEmbeddedSubtitles);
request.Settings.ExtractEmbeddedSubtitles,
cancellationToken);
// queue extracting all embedded subtitles
if (request.Settings.ExtractEmbeddedSubtitles)
{
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None));
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
}
if (request.Settings.GlobalWatermarkId is not null)
{
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalWatermarkId,
request.Settings.GlobalWatermarkId.Value);
request.Settings.GlobalWatermarkId.Value,
cancellationToken);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
}
if (request.Settings.GlobalFallbackFillerId is not null)
{
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalFallbackFillerId,
request.Settings.GlobalFallbackFillerId.Value);
request.Settings.GlobalFallbackFillerId.Value,
cancellationToken);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId);
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout);
request.Settings.HlsSegmenterIdleTimeout,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegWorkAheadSegmenters,
request.Settings.WorkAheadSegmenterLimit);
request.Settings.WorkAheadSegmenterLimit,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegInitialSegmentCount,
request.Settings.InitialSegmentCount);
request.Settings.InitialSegmentCount,
cancellationToken);
return Unit.Default;
}

View File

@@ -4,18 +4,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
public class GetAllFFmpegProfilesHandler : IRequestHandler<GetAllFFmpegProfiles, List<FFmpegProfileViewModel>>
public class GetAllFFmpegProfilesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllFFmpegProfiles, List<FFmpegProfileViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllFFmpegProfilesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<FFmpegProfileViewModel>> Handle(
GetAllFFmpegProfiles request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.ToListAsync(cancellationToken)

View File

@@ -22,7 +22,7 @@ public class
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)
.MapT(ProjectToFullResponseModel);
}
}

View File

@@ -19,7 +19,7 @@ public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById,
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)
.MapT(ProjectToViewModel);
}
}

View File

@@ -15,30 +15,30 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
GetFFmpegSettings request,
CancellationToken cancellationToken)
{
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath);
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath);
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken);
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken);
Option<int> defaultFFmpegProfileId =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
Option<string> preferredAudioLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode, cancellationToken);
Option<bool> useEmbeddedSubtitles =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles);
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken);
Option<bool> extractEmbeddedSubtitles =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles);
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
Option<int> fallbackFiller =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId);
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
Option<int> workAheadSegmenterLimit =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
Option<int> initialSegmentCount =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
Option<OutputFormatKind> outputFormatKind =
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat);
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
var result = new FFmpegSettingsViewModel
{

View File

@@ -28,7 +28,7 @@ public class
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext);
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
GetHardwareAccelerationKinds,
@@ -66,14 +66,25 @@ public class
result.Add(HardwareAccelerationKind.Amf);
}
// TODO: fix and enable V4L2 M2M
// if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.V4l2m2m))
// {
// result.Add(HardwareAccelerationKind.V4l2m2m);
// }
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Rkmpp))
{
result.Add(HardwareAccelerationKind.Rkmpp);
}
return result;
}
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext) =>
await FFmpegPathMustExist(dbContext);
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext, CancellationToken cancellationToken) =>
await FFmpegPathMustExist(dbContext, cancellationToken);
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext, CancellationToken cancellationToken) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
}

View File

@@ -12,7 +12,7 @@ public record CreateFillerPreset(
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,

View File

@@ -39,7 +39,8 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
SmartCollectionId = request.SmartCollectionId,
PlaylistId = request.PlaylistId,
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null,
UseChaptersAsMediaItems = request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems
UseChaptersAsMediaItems =
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems
};
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);

View File

@@ -18,7 +18,7 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request, cancellationToken);
return await validation.Apply(ps => DoDeletion(dbContext, ps));
}
@@ -30,8 +30,9 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
DeleteFillerPreset request) =>
DeleteFillerPreset request,
CancellationToken cancellationToken) =>
dbContext.FillerPresets
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId)
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FillerPreset {request.FillerPresetId} does not exist."));
}

View File

@@ -13,7 +13,7 @@ public record UpdateFillerPreset(
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,

View File

@@ -16,7 +16,10 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(
dbContext,
request,
cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
}
@@ -39,7 +42,8 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
existing.SmartCollectionId = request.SmartCollectionId;
existing.PlaylistId = request.PlaylistId;
existing.Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null;
existing.UseChaptersAsMediaItems = request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems;
existing.UseChaptersAsMediaItems =
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems;
await dbContext.SaveChangesAsync();
@@ -48,8 +52,9 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
UpdateFillerPreset request) =>
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
dbContext.FillerPresets
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id)
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("FillerPreset does not exist"));
}

View File

@@ -13,7 +13,7 @@ public record FillerPresetViewModel(
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
ProgramScheduleItemCollectionType CollectionType,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,

View File

@@ -16,7 +16,7 @@ public class GetFillerPresetByIdHandler(IDbContextFactory<TvContext> dbContextFa
return await dbContext.FillerPresets
.AsNoTracking()
.Include(i => i.Playlist)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id, cancellationToken)
.MapT(ProjectToViewModel);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Graphics;
public record RefreshGraphicsElements : IRequest, IBackgroundServiceRequest;

View File

@@ -0,0 +1,107 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Graphics;
public class RefreshGraphicsElementsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
ILogger<RefreshGraphicsElementsHandler> logger)
: IRequestHandler<RefreshGraphicsElements>
{
public async Task Handle(RefreshGraphicsElements request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// cleanup existing elements
List<GraphicsElement> allExisting = await dbContext.GraphicsElements
.ToListAsync(cancellationToken);
foreach (GraphicsElement existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path)))
{
logger.LogWarning(
"Removing graphics element that references non-existing file {File}",
existing.Path);
dbContext.GraphicsElements.Remove(existing);
}
// add new text elements
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder)
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (string path in newTextPaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Text
};
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new image elements
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder)
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (string path in newImagePaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Image
};
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new motion elements
var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder)
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (string path in newMotionPaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Motion
};
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new subtitle elements
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder)
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (string path in newSubtitlePaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Subtitle
};
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Graphics;
public record GraphicsElementViewModel(int Id, string Name);

View File

@@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Graphics;
public static class Mapper
{
public static GraphicsElementViewModel ProjectToViewModel(GraphicsElement graphicsElement)
{
string fileName = Path.GetFileName(graphicsElement.Path);
return graphicsElement.Kind switch
{
GraphicsElementKind.Text => new GraphicsElementViewModel(graphicsElement.Id, $"text/{fileName}"),
GraphicsElementKind.Image => new GraphicsElementViewModel(graphicsElement.Id, $"image/{fileName}"),
GraphicsElementKind.Subtitle => new GraphicsElementViewModel(graphicsElement.Id, $"subtitle/{fileName}"),
GraphicsElementKind.Motion => new GraphicsElementViewModel(graphicsElement.Id, $"motion/{fileName}"),
_ => new GraphicsElementViewModel(graphicsElement.Id, graphicsElement.Path)
};
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Graphics;
public record GetAllGraphicsElements : IRequest<List<GraphicsElementViewModel>>;

View File

@@ -0,0 +1,19 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Graphics.Mapper;
namespace ErsatzTV.Application.Graphics;
public class GetAllGraphicsElementsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllGraphicsElements, List<GraphicsElementViewModel>>
{
public async Task<List<GraphicsElementViewModel>> Handle(
GetAllGraphicsElements request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.GraphicsElements
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}

View File

@@ -18,7 +18,7 @@ public class UpdateHDHRTunerCountHandler : IRequestHandler<UpdateHDHRTunerCount,
Validate(request)
.MapT(_ => _configElementRepository.Upsert(
ConfigElementKey.HDHRTunerCount,
request.TunerCount.ToString(CultureInfo.InvariantCulture)))
request.TunerCount.ToString(CultureInfo.InvariantCulture), cancellationToken))
.Bind(v => v.ToEitherAsync());
private static Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>

View File

@@ -11,6 +11,6 @@ public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount, cancellationToken)
.Map(result => result.IfNone(2));
}

View File

@@ -12,11 +12,11 @@ public class GetHDHRUUIDHandler : IRequestHandler<GetHDHRUUID, Guid>
public async Task<Guid> Handle(GetHDHRUUID request, CancellationToken cancellationToken)
{
Option<Guid> maybeGuid = await _configElementRepository.GetValue<Guid>(ConfigElementKey.HDHRUUID);
Option<Guid> maybeGuid = await _configElementRepository.GetValue<Guid>(ConfigElementKey.HDHRUUID, cancellationToken);
return await maybeGuid.IfNoneAsync(async () =>
{
var guid = Guid.NewGuid();
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid);
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid, cancellationToken);
return guid;
});
}

View File

@@ -28,7 +28,7 @@ public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbCon
else
{
Option<ImageFolderDuration> maybeExisting = await dbContext.ImageFolderDurations
.SelectOneAsync(ifd => ifd.LibraryFolderId, ifd => ifd.LibraryFolderId == request.LibraryFolderId);
.SelectOneAsync(ifd => ifd.LibraryFolderId, ifd => ifd.LibraryFolderId == request.LibraryFolderId, cancellationToken);
if (maybeExisting.IsNone)
{
@@ -53,7 +53,7 @@ public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbCon
Option<LibraryFolder> maybeFolder = await dbContext.LibraryFolders
.AsNoTracking()
.Include(lf => lf.ImageFolderDuration)
.SelectOneAsync(lf => lf.Id, lf => lf.Id == request.LibraryFolderId);
.SelectOneAsync(lf => lf.Id, lf => lf.Id == request.LibraryFolderId, cancellationToken);
var queue = new Queue<FolderWithParentDuration>();
foreach (LibraryFolder libraryFolder in maybeFolder)
@@ -67,7 +67,7 @@ public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbCon
Option<LibraryFolder> maybeParent = await dbContext.LibraryFolders
.AsNoTracking()
.Include(lf => lf.ImageFolderDuration)
.SelectOneAsync(lf => lf.Id, lf => lf.Id == currentFolder.ParentId);
.SelectOneAsync(lf => lf.Id, lf => lf.Id == currentFolder.ParentId, cancellationToken);
if (maybeParent.IsNone)
{

View File

@@ -30,15 +30,16 @@ public class
GetCachedImagePath request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate();
Validation<BaseError, string> validation = await Validate(cancellationToken);
return await validation.Match(
ffmpegPath => Handle(ffmpegPath, request),
ffmpegPath => Handle(ffmpegPath, request, cancellationToken),
error => Task.FromResult<Either<BaseError, CachedImagePathViewModel>>(error.Join()));
}
private async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
string ffmpegPath,
GetCachedImagePath request)
GetCachedImagePath request,
CancellationToken cancellationToken)
{
try
{
@@ -75,7 +76,7 @@ public class
withExtension,
request.MaxHeight.Value);
CommandResult resize = await process.ExecuteAsync();
CommandResult resize = await process.ExecuteAsync(cancellationToken);
if (resize.ExitCode != 0)
{
@@ -106,11 +107,11 @@ public class
}
}
private async Task<Validation<BaseError, string>> Validate() =>
await ValidateFFmpegPath();
private async Task<Validation<BaseError, string>> Validate(CancellationToken cancellationToken) =>
await ValidateFFmpegPath(cancellationToken);
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
private Task<Validation<BaseError, string>> ValidateFFmpegPath(CancellationToken cancellationToken) =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists)
.Map(ffmpegPath => ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
}

View File

@@ -26,7 +26,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
@@ -42,10 +42,11 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizeJellyfinCollections request)
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
DateTime minDateTime = await dbContext.JellyfinMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId)
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);

View File

@@ -39,7 +39,7 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
@@ -78,10 +78,11 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
ISynchronizeJellyfinLibraryById request)
ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken)
{
DateTime minDateTime = await dbContext.JellyfinLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);

View File

@@ -0,0 +1,79 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>,
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>
{
public CallJellyfinShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle(
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
private async Task<Either<BaseError, string>> Handle(
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, string>>(error.Join());
});
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-jellyfin-show",
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture)
};
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
}
protected override Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken) =>
Task.FromResult(DateTimeOffset.MinValue);
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeJellyfinShowById request) =>
true;
}

View File

@@ -35,7 +35,7 @@ public class
SynchronizeJellyfinLibraries request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(SynchronizeLibraries)
.MapT(p => SynchronizeLibraries(p, cancellationToken))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
@@ -66,7 +66,9 @@ public class
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
private async Task<Unit> SynchronizeLibraries(
ConnectionParameters connectionParameters,
CancellationToken cancellationToken)
{
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
connectionParameters.ActiveConnection.Address,
@@ -93,7 +95,8 @@ public class
connectionParameters.JellyfinMediaSource.Id,
toAdd,
toRemove,
toUpdate);
toUpdate,
cancellationToken);
if (ids.Count != 0)
{
await _searchIndex.RemoveItems(ids);

View File

@@ -23,7 +23,7 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
SynchronizeJellyfinMediaSources request,
CancellationToken cancellationToken)
{
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin(cancellationToken);
foreach (JellyfinMediaSource mediaSource in mediaSources)
{
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan)
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;

View File

@@ -23,7 +23,7 @@ public class
UpdateJellyfinLibraryPreferences request,
CancellationToken cancellationToken)
{
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
var toDisable = request.Preferences.Filter(p => !p.ShouldSyncItems).Map(p => p.Id).ToList();
List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();

View File

@@ -15,5 +15,5 @@ public class
public Task<List<JellyfinMediaSourceViewModel>> Handle(
GetAllJellyfinMediaSources request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetAllJellyfin().Map(list => list.Map(ProjectToViewModel).ToList());
_mediaSourceRepository.GetAllJellyfin(cancellationToken).Map(list => list.Map(ProjectToViewModel).ToList());
}

View File

@@ -29,7 +29,7 @@ public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfi
}
Either<BaseError, JellyfinConnectionParametersViewModel> maybeParameters =
await Validate()
await Validate(cancellationToken)
.MapT(cp => new JellyfinConnectionParametersViewModel(cp.ActiveConnection.Address))
.Map(v => v.ToEither<JellyfinConnectionParametersViewModel>());
@@ -42,12 +42,13 @@ public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfi
error => error);
}
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
JellyfinMediaSourceMustExist()
private Task<Validation<BaseError, ConnectionParameters>> Validate(CancellationToken cancellationToken) =>
JellyfinMediaSourceMustExist(cancellationToken)
.BindT(MediaSourceMustHaveActiveConnection);
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist() =>
_mediaSourceRepository.GetAllJellyfin().Map(list => list.HeadOrNone())
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetAllJellyfin(cancellationToken).Map(list => list.HeadOrNone())
.Map(v => v.ToValidation<BaseError>(
"Jellyfin media source does not exist."));

View File

@@ -21,7 +21,7 @@ namespace ErsatzTV.Application.Libraries;
public abstract class CallLibraryScannerHandler<TRequest>
{
private readonly int _batchSize = 100;
private const int BatchSize = 100;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -55,8 +55,7 @@ public abstract class CallLibraryScannerHandler<TRequest>
using var forcefulCts = new CancellationTokenSource();
await using CancellationTokenRegistration link =
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
);
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)));
CommandResult process = await Cli.Wrap(scanner)
.WithArguments(arguments)
@@ -72,12 +71,14 @@ public abstract class CallLibraryScannerHandler<TRequest>
if (_toReindex.Count > 0)
{
// ReSharper disable once PossiblyMistakenUseOfCancellationToken
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()), cancellationToken);
_toReindex.Clear();
}
if (_toRemove.Count > 0)
{
// ReSharper disable once PossiblyMistakenUseOfCancellationToken
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()), cancellationToken);
_toRemove.Clear();
}
@@ -139,14 +140,14 @@ public abstract class CallLibraryScannerHandler<TRequest>
}
_toReindex.AddRange(progressUpdate.ItemsToReindex);
if (_toReindex.Count >= _batchSize)
if (_toReindex.Count >= BatchSize)
{
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()));
_toReindex.Clear();
}
_toRemove.AddRange(progressUpdate.ItemsToRemove);
if (_toRemove.Count >= _batchSize)
if (_toRemove.Count >= BatchSize)
{
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()));
_toRemove.Clear();
@@ -169,46 +170,56 @@ public abstract class CallLibraryScannerHandler<TRequest>
}
}
protected abstract Task<DateTimeOffset> GetLastScan(TvContext dbContext, TRequest request);
protected abstract Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
TRequest request,
CancellationToken cancellationToken);
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
protected async Task<Validation<BaseError, string>> Validate(TRequest request)
protected async Task<Validation<BaseError, string>> Validate(TRequest request, CancellationToken cancellationToken)
{
int libraryRefreshInterval = await _configElementRepository
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.IfNoneAsync(0);
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
try
{
return new ScanIsNotRequired();
}
int libraryRefreshInterval = await _configElementRepository
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.IfNoneAsync(0);
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
? "ErsatzTV.Scanner.exe"
: "ErsatzTV.Scanner";
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
string processFileName = Environment.ProcessPath ?? string.Empty;
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
string folderName = Path.GetDirectoryName(processFileName);
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
{
folderName = AppContext.BaseDirectory;
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(folderName))
{
string localFileName = Path.Combine(folderName, executable);
if (File.Exists(localFileName))
DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken);
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
{
return localFileName;
return new ScanIsNotRequired();
}
}
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
? "ErsatzTV.Scanner.exe"
: "ErsatzTV.Scanner";
string processFileName = Environment.ProcessPath ?? string.Empty;
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
string folderName = Path.GetDirectoryName(processFileName);
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
{
folderName = AppContext.BaseDirectory;
}
if (!string.IsNullOrWhiteSpace(folderName))
{
string localFileName = Path.Combine(folderName, executable);
if (File.Exists(localFileName))
{
return localFileName;
}
}
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return BaseError.New("Scan was canceled");
}
}
}

View File

@@ -25,7 +25,10 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(
dbContext,
request,
cancellationToken);
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
}
@@ -77,8 +80,9 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
DeleteLocalLibrary request) =>
DeleteLocalLibrary request,
CancellationToken cancellationToken) =>
dbContext.LocalLibraries
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId)
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"Local library {request.LocalLibraryId} does not exist."));
}

View File

@@ -2,5 +2,5 @@
public interface ILocalLibraryRequest
{
public string Name { get; }
string Name { get; }
}

View File

@@ -38,17 +38,17 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => MovePath(dbContext, parameters));
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => MovePath(dbContext, parameters, cancellationToken));
}
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
{
LibraryPath path = parameters.LibraryPath;
LocalLibrary newLibrary = parameters.Library;
path.LibraryId = newLibrary.Id;
if (await dbContext.SaveChangesAsync() > 0)
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
@@ -57,14 +57,14 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
foreach (int id in ids)
{
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id, cancellationToken);
foreach (MediaItem mediaItem in maybeMediaItem)
{
_logger.LogInformation("Moving item at {Path}", await GetPath(dbContext, mediaItem));
await _searchIndex.UpdateItems(
_searchRepository,
_fallbackMetadataProvider,
new List<MediaItem> { mediaItem });
[mediaItem]);
}
}
}
@@ -74,24 +74,28 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
MoveLocalLibraryPath request) =>
(await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request))
MoveLocalLibraryPath request,
CancellationToken cancellationToken) =>
(await LibraryPathMustExist(dbContext, request, cancellationToken),
await LocalLibraryMustExist(dbContext, request, cancellationToken))
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
MoveLocalLibraryPath request,
CancellationToken cancellationToken) =>
dbContext.LibraryPaths
.Include(lp => lp.Library)
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId)
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("LibraryPath does not exist."));
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
MoveLocalLibraryPath request,
CancellationToken cancellationToken) =>
dbContext.LocalLibraries
.Include(ll => ll.Paths)
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
private static async Task<string> GetPath(TvContext dbContext, MediaItem mediaItem) =>

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