Compare commits

...

225 Commits

Author SHA1 Message Date
Jason Dove
ae13db981d fix secrets in release workflow 2026-02-02 14:52:27 -06:00
Jason Dove
b7cc8499a3 prep for release v26.2.0 [no ci] 2026-02-02 14:47:44 -06:00
Jason Dove
36147b9e9c fix indexing collections in elasticsearch (#2806)
* fix indexing collections in elasticsearch

* more safety
2026-01-29 18:23:07 -06:00
Jason Dove
bf8c821012 improve erasing playout items and history (#2805)
* improve erasing playout items and history

* fixes
2026-01-28 09:17:56 -06:00
Jason Dove
a0f5d8d5d5 detect more local movie artwork (#2804)
* expand test coverage

* support "backdrop" files as local movie fanart fallback
2026-01-27 16:35:28 -06:00
Jason Dove
f1072b70c7 add chapter title to filler expression (#2803)
* fix transcoding tests

* pass chapter title to filler expression

* update changelog
2026-01-27 09:40:38 -06:00
Jason Dove
e10b28bc0b add normalization options (#2802)
* add new fields to database

* update editor

* audio and video normalization settings appear to work

* implement optional color normalization

* fix transcoding tests

* update changelog
2026-01-26 23:43:56 -06:00
Jason Dove
cd2bb0f2e0 fix playout build failures due to playlist enumerator access (#2801) 2026-01-26 14:44:07 -06:00
Jason Dove
e80f687612 add marathon group by director (#2800) 2026-01-26 09:10:01 -06:00
Jason Dove
317ca1967c fix building playouts when fill with group mode is used with graphics elements (#2799) 2026-01-25 15:29:18 -06:00
Jason Dove
b86f45844c add health check to verify ffmpeg capabilities (filters) (#2798)
* add health check to verify ffmpeg capabilities (filters)

* fix loudnorm
2026-01-25 12:28:09 -06:00
Jason Dove
353f029452 fix null ref scanning other videos with nfo file (#2797)
* fix null ref scanning other videos with nfo file

* also fix movie null ref
2026-01-25 11:34:06 -06:00
Jason Dove
1754e7d5fb add health check for empty classic schedules (#2796) 2026-01-23 15:47:48 -06:00
Jason Dove
f96be8f99f update plex episode metadata during scan (#2795) 2026-01-21 16:59:01 -06:00
Jason Dove
08ceb53b2b make count an expression in classic schedules (#2794)
* make count an expression in classic schedules

* add tests
2026-01-20 09:50:45 -06:00
Jason Dove
3d81f760ee fix z-index sorting in graphics engine (#2786) 2026-01-18 09:07:21 -06:00
Jason Dove
4ce87feac1 log graphics element z index (#2785) 2026-01-17 08:15:43 -06:00
Jason Dove
f217ba185b sync jf and emby library name and type changes (#2784) 2026-01-17 06:14:45 -06:00
Jason Dove
e925bd6913 sync plex library name changes (#2783)
* sync plex library name changes

* feedback
2026-01-16 19:45:34 -06:00
Jason Dove
3f4c9e063b don't delete channel watermarks that are still used (#2781)
* don't delete channel watermarks that are still used

* fix folder cleanup check
2026-01-16 14:24:03 -06:00
Jason Dove
7f361d1ea9 update dependencies (#2780)
* update messaging

* update dependencies
2026-01-16 13:57:25 -06:00
Jason Dove
35d24ffea6 cleanup artwork cache folder (#2779)
* cleanup artwork cache folder

* fixes

* ignore watermarks that no longer exist on the file system
2026-01-16 13:38:31 -06:00
Jason Dove
a2d023ee69 local scanner artwork cleanup (#2778)
* move plex artwork removal to its own repository

* clean up old local movie artwork

* clien up old music video/artist artwork

* clean up old remote stream artwork

* clean up old song artwork

* clean up old show artwork; properly update season artwork
2026-01-16 10:23:26 -06:00
Jason Dove
36f44f14bb fix other video artwork in xmltv (#2777) 2026-01-15 22:42:16 -06:00
Jason Dove
ccb917d0df add ffmpeg profile pad mode (#2775)
* add ffmpeg profile pad mode

* update changelog
2026-01-15 09:39:45 -06:00
Jason Dove
343a4619a6 downmix ac3 to stereo to match output layout (#2774) 2026-01-14 10:40:49 -06:00
Jason Dove
e167c9318c fix failing unit tests (#2772) 2026-01-14 06:47:34 -06:00
Jason Dove
de230f92db fix issue reading xmltv fragments (#2771)
* fix issue reading xmltv fragments

* cleanup
2026-01-13 22:30:31 -06:00
Jason Dove
974020a98f optimize searching for shows, seasons and movies (#2768)
* load search logging level on startup

* optimize searching for shows, seasons and movies

* use season metadata directly
2026-01-12 19:42:49 -06:00
Jason Dove
da957c9377 restore roboto font (#2767) 2026-01-12 08:49:57 -06:00
Jason Dove
b72d150775 add day_of_week to channel stream selector content_condition (#2766) 2026-01-10 11:28:14 -06:00
Jason Dove
b0b7bd17b3 respect z_index on all graphics element types (#2765) 2026-01-09 10:26:59 -06:00
Jason Dove
1f2f04f3bd more fixes 2026-01-08 21:47:28 -06:00
Jason Dove
5bc90bb245 give id-token write permission 2026-01-08 20:47:30 -06:00
Jason Dove
f73a32ec13 restore permissions 2026-01-08 20:36:09 -06:00
Jason Dove
748ed1cf71 properly define secrets 2026-01-08 20:26:24 -06:00
Jason Dove
f2deaa6f7a properly pass secrets 2026-01-08 20:25:20 -06:00
Jason Dove
3698fa5b7d try again 2026-01-08 20:19:47 -06:00
Jason Dove
dc92cb4ac3 use separate azure login step 2026-01-08 19:59:55 -06:00
Jason Dove
69410b1a9b try to fix signing 2026-01-08 19:39:21 -06:00
Jason Dove
4aee03e066 use code signing on all windows executables (#2764) 2026-01-08 19:27:45 -06:00
Jason Dove
e16d6c67f1 prep for release v26.1.1 [no ci] 2026-01-08 16:01:29 -06:00
Jason Dove
5d8877975d fix macos build (#2763)
* fix macos build

* also update host
2026-01-08 11:51:16 -06:00
Jason Dove
367305d960 include web resources locally, using libman (#2762) 2026-01-08 11:24:48 -06:00
Jason Dove
aa08ad5765 optimize check for orphaned artwork (#2760) 2026-01-07 16:46:17 -06:00
Jason Dove
40c6c504fe prep for release v26.1.0 [no ci] 2026-01-06 15:15:00 -06:00
Jason Dove
933b6530e4 fix build 2026-01-06 13:41:25 -06:00
Jason Dove
885330f8c5 rework windows launcher build process (#2758)
* update license

* download pre-compiled windows launcher instead of building it with each commit

* remove windows launcher project which has moved to its own repo
2026-01-06 13:32:20 -06:00
Jason Dove
effb96a2c2 alternate schedule and template consistency (#2757)
* refactor classic and block schedules to use same alternate schedule selector

* handle start year and end year

* add migrations for classic and block schedules

* allow editing block template start and end year

* add tests that include years

* add date range editing to classic (alternate) schedules

* fix running tests locally

* restore media files load; needed for local folder scanners

* update changelog

* feedback
2026-01-06 12:51:07 -06:00
Jason Dove
cc521326d9 hide jellyfin timing stats by default; enable with env var (#2756) 2026-01-06 12:08:55 -06:00
James Dearlove
5c42609527 Add base URL to variant playlists (#2755)
* Add PathBase to variant playlists

* add commented code to help with testing

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-01-05 09:13:04 -06:00
Jason Dove
a96ef328a5 more scanning improvements related to media file table (#2754) 2026-01-04 19:16:06 -06:00
Jason Dove
89bb3759de more jf episode scanning improvements (#2753)
* more jf episode scanning improvements

* bump log level on important message

* add transaction
2026-01-03 12:43:26 -06:00
Jason Dove
12f2583c96 add timings for jf episode loading and saving during update (#2752) 2026-01-02 20:16:30 -06:00
Jason Dove
7c82ecdfff use separate load queries for jf episode inserts and updates (#2751) 2026-01-02 16:28:19 -06:00
Jason Dove
38343e3ea2 lazy load media card images (#2750) 2026-01-02 13:40:44 -06:00
Jason Dove
bcd2ea7db3 db optimizations around names and case-sensitivity (#2749)
* generate (case-insensitive) unique names for fields that should be unique

* move name case-insensitivity down to schema level

* update changelog
2026-01-02 09:25:23 -06:00
Jason Dove
80f6e468eb collect and print timings during jellyfin show library scans (#2748)
* collect and print timings during jellyfin show library scans

* update p99
2026-01-01 20:02:24 -06:00
Jason Dove
474e647d6d more jellyfin performance improvements (#2747)
* fix slow db and api logging so it also works in scanner project

* don't request people from jellyfin by default
2025-12-31 22:28:15 -06:00
Jon Crall
daff1c6533 Add select all controls to media lists (#2738)
* Add select all controls to media lists

* Refine select-all helper and add coverage

* Adjust select-all button alignment

* Tighten select-all helper semantics

* Allow tests to access internal members

* Rename select-all helper and avoid shift tracking

* Simplify select-all reset helper

* Keep pager centered and move select-all right

* Add missing div

* create test project for main app; move and rename new tests

* remove core => main app reference

* cleanup unused imports

* Fix button behavior when the screen is small

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-12-31 15:18:07 -06:00
Jason Dove
14d2dd0c3a optimize jellyfin database fields and indexes (#2746)
* optimize jellyfin database fields and indexes

* copy paste
2025-12-31 14:30:53 -06:00
Jason Dove
c606319030 add some performance troubleshooting env vars (#2745)
* add slow query logging

* add slow api logging for jellyfin

* add configurable jellyfin page size

* feedback
2025-12-31 14:04:12 -06:00
Jason Dove
1b72b8491c improve multi-episode grouping logic (#2744) 2025-12-31 11:24:07 -06:00
Jason Dove
9fea25a77d allow string values for count instruction in sequential schedules (#2741)
* allow string values for count instruction in sequential schedules

* fix potential div by zero
2025-12-30 18:19:33 -06:00
Jason Dove
74b049b6e3 fix nvenc playback when color metadata changes mid-stream (#2740)
* fix nvenc playback when color metadata changes mid-stream

* update dependencies (needed to fix unit test runner)

* limit noautoscale to when it's not already present
2025-12-30 11:55:25 -06:00
Jason Dove
b2caf8ee8d fix remote stream indexing due to missing titles (#2739) 2025-12-30 08:51:06 -06:00
Jason Dove
b582b4cbf7 fix downgrade health check failure for mariadb (#2737) 2025-12-24 22:29:57 -06:00
Jason Dove
0af81ad839 add target loudness to ffmpeg profile (#2727)
* add target loudness to ffmpeg profile

* fix filter
2025-12-19 14:46:17 -06:00
Jason Dove
2f0cd1eb6c update dependencies (#2726) 2025-12-19 13:56:26 -06:00
Jason Dove
e4f1a93db0 fix some mysql migrations that failed on mariadb (#2725) 2025-12-17 22:40:03 -06:00
Jason Dove
6562d616fb smart collection names must be case insensitive (#2721)
* smart collection names must be case insensitive

* use explicit ci collation for mysql
2025-12-13 18:32:02 -06:00
Jason Dove
d8122edad6 fix duplicate smart collection names (#2720)
* fix duplicate smart collection names

* fix update error
2025-12-13 14:59:06 -06:00
Jason Dove
99b8c56a31 rework fallback filler (#2719)
* fallback fixes

* use hardware encoding for fallback filler

* rework fallback filler

* fixes
2025-12-13 09:02:48 -06:00
Jason Dove
09858df654 fix case when cuda hw decode falls back to sw (#2718)
* fix case when cuda hw decode falls back to sw

* use a new filter
2025-12-12 15:11:32 -06:00
Jason Dove
038286c92b use playlist item count when playlists are used as filler (#2716)
* use playlist item count when playlists are used as filler

* expand test
2025-12-10 22:14:07 -06:00
Jason Dove
8575ab5c32 fix bt2020 playback (#2714)
* fix bt2020 playback

* update pixel format

* update changelog
2025-12-10 19:50:47 -06:00
Jason Dove
8b768a2990 allow playlists to have no items included in epg (#2713) 2025-12-10 16:15:04 -06:00
Jason Dove
f9e4c4d386 improve build time by only running analyzers explicitly (#2710)
* improve build time by only running analyzers explicitly

* don't exclude scanner from analyzers

* Revert "don't exclude scanner from analyzers"

This reverts commit d927f9850a.

* fix sed syntax for linux
2025-12-09 14:17:05 -06:00
Jason Dove
a1f9b86fc1 add download media sample button to playback troubleshooting (#2709)
* add download media sample button to playback troubleshooting

* fixes
2025-12-09 11:49:07 -06:00
Jason Dove
5dc20ebd1b use software pad with amd vaapi h264 main (#2708) 2025-12-06 10:03:24 -06:00
Jason Dove
d30e8b4102 only use packed headers with vaapi when supported by encoder (#2706) 2025-12-05 11:20:16 -06:00
Jason Dove
c14f373f23 implement rectangles packet for script element (#2704)
* implement rectangles packet for script element

* fixes
2025-12-04 20:57:26 -06:00
Jason Dove
a90fe26d50 script element packet spike (#2703)
* script element packet spike

* fixes
2025-12-04 15:40:18 -06:00
Jason Dove
7a263ddaed add migration to fix any incorrect channel sort numbers (#2701) 2025-12-03 19:23:59 -06:00
Jason Dove
3e0a9aae1e fix channel sort number when reordering channels (#2700)
* fix channel sort number when reordering channels

* tryparse
2025-12-03 18:33:47 -06:00
Jason Dove
72dc401829 fix chronological sorting for other videos (#2699) 2025-12-03 10:38:29 -06:00
Jason Dove
85e25ca6ea add channel start time template data (#2698)
* add channel start time template data

* rename
2025-12-03 10:16:09 -06:00
Jason Dove
9c23b03758 fix mirror channels (#2697) 2025-12-03 09:56:52 -06:00
Jason Dove
e12888ebee fix recent regression to subtitle graphics element (#2696) 2025-12-03 07:33:17 -06:00
Jason Dove
468ff087d4 fix loading epg entries for motion and script elements (#2693) 2025-12-02 15:46:22 -06:00
Jason Dove
54606c76f9 framerate improvements (#2692)
* framerate improvements

* fixes
2025-12-02 12:20:09 -06:00
Jason Dove
6bd49ffcec add remote stream metadata (#2690)
* add remote stream metadata

* use ifilesystem for last write time

* clean up unused inheritance

* optimize channel data query

* revert removeartwork change

* properly handle remote stream artwork in orphaned artwork check
2025-12-01 12:48:03 -06:00
Jason Dove
c524bc0d7d add script graphics element (#2681)
* add script graphics element

* pass template data as json to stdin

* update changelog
2025-11-30 14:20:18 -06:00
Jason Dove
42bcadf936 work around buggy radeonsi hevc_vaapi behavior (#2680)
* try to workaround amd crop metadata ffmpeg bug

* limit workaround to hevc_vaapi encoder

* update changelog
2025-11-30 12:14:20 -06:00
Jason Dove
1f31beab5b fix plex other video library detection (#2679) 2025-11-30 08:35:04 -06:00
Jason Dove
b45c22092d fix startup on systems unsupported by nvencsharp (#2678) 2025-11-30 06:26:57 -06:00
Jason Dove
7bd8cefe2e more dotnet 10 updates (#2676)
* update more libraries to dotnet 10

* fix dockerfiles

* fix numeric types
2025-11-29 15:47:08 -06:00
Jason Dove
f101d0b366 prep for release v25.9.0 [no ci] 2025-11-29 10:36:17 -06:00
Jason Dove
73aabdabda fix transcoding tests (#2675) [no ci] 2025-11-29 10:34:32 -06:00
Jason Dove
bcea96d53a always log scanner exit code when it is non-zero (#2670)
* always log scanner exit code when it is non-zero

* remove test abort
2025-11-26 12:50:19 -06:00
Jason Dove
d7952e4cfa fix docker assets (#2669) 2025-11-26 11:25:56 -06:00
Jason Dove
758399e339 fix missing net9.0 to net10.0 in docker/github (#2668) 2025-11-26 10:54:24 -06:00
Jason Dove
6c635a4be9 upgrade to dotnet 10 (#2667)
* upgrade to dotnet 10

* remove packages that would be pruned

* properly fix tests
2025-11-26 10:49:01 -06:00
Jason Dove
9d637cdd54 update dependencies (#2661) 2025-11-25 16:06:48 -06:00
Jason Dove
cc287ffc6e fix hls direct streams remaining open (#2660) 2025-11-24 22:40:26 -06:00
Jason Dove
371659c5c5 cache bust new logo (#2659) 2025-11-24 20:10:57 -06:00
Jason Dove
7afb1866ad update logo (#2658) 2025-11-24 20:01:53 -06:00
Jason Dove
7bc1dd63fe fix file system test on windows (#2657) [no ci] 2025-11-24 13:40:04 -06:00
Jason Dove
076b8a7188 fix editing certain playouts when using mysql (#2656) 2025-11-24 13:31:00 -06:00
Jason Dove
ec0d8ea6ac work around sequential schedule validation limit (#2655)
* remove readalltext

* remove unused method

* remove fileexists

* remove folderexists

* remove readalllines

* remove fake local file system

* show playlist name in playout build errors

* add basic sequential schedule validator tests

* work around sequential schedule validation limit
2025-11-24 12:08:43 -06:00
Jason Dove
e40d192aea limit hw sw decode downgrade polaris (#2654)
* use a more precise carve out for polaris workaround

* fix decode capability ordering
2025-11-22 12:35:29 -06:00
Jason Dove
bd7fd8984c fix 10-bit decoding with amd polaris (#2653)
* fix color conversion on amd polaris

* try software decode for polaris

* update changelog
2025-11-22 11:29:17 -06:00
Jason Dove
2682912f5a update icon (#2652) 2025-11-21 13:34:23 -06:00
Jason Dove
505e135482 use fonts cache folder for subtitle graphics elements (#2651) 2025-11-18 10:18:22 -06:00
Jason Dove
fdf1e70e0d fix subtitle graphics element path and fonts (#2650) 2025-11-18 10:16:03 -06:00
Jason Dove
5c51710e2f add disabled and hidden channel indicators (#2649) 2025-11-16 10:47:10 -06:00
Jason Dove
3cb84c2491 refresh classic playouts by default (#2647) 2025-11-15 20:45:28 -06:00
Jason Dove
21f4439aa4 block ui improvements (#2646)
* template editor improvements

* more keyboard navigation

* replace template tree view with template table
2025-11-13 19:25:44 -06:00
Jason Dove
d88e721d2f optimize database calls related to search index (#2645) 2025-11-13 13:27:37 -06:00
Jason Dove
b6d509b9cd add search query collection type to block schedules (#2644)
* add search query collection type to block schedules

* fix history
2025-11-13 10:48:04 -06:00
Jason Dove
6603500132 fix content_total_duration in graphics engine (#2643) 2025-11-12 20:07:54 -06:00
Jason Dove
48b1aa3e64 fix block playout build bug (#2642) 2025-11-12 16:53:06 -06:00
Jason Dove
42b35f7aae add channel playback troubleshooter (#2641)
* fix motion graphics loop when seeking

* add channel playback troubleshooter

* fix errors
2025-11-12 13:21:18 -06:00
Jason Dove
8b18f2a304 expose arbitrary epg data to graphics engine (#2633) 2025-11-11 12:41:45 -06:00
Jason Dove
1e0bba0dc6 allow custom song background images (#2632)
* allow custom song background images

* allow custom missing album art
2025-11-11 10:40:45 -06:00
Jason Dove
3984bc7dbe add troubleshoot playback links for movies and episodes (#2631) 2025-11-11 06:33:10 -06:00
Jason Dove
e0977fa65b update dmg icon (#2630) 2025-11-11 06:12:46 -06:00
Jason Dove
d9c668c7f6 more dependency updates (#2629) 2025-11-11 05:45:46 -06:00
Jason Dove
dcea6d474f update dependencies (#2627) 2025-11-10 22:20:42 -06:00
Jason Dove
ba37c6dabe update macos again (#2626) 2025-11-10 20:19:38 -06:00
Jason Dove
d652372f78 update macos project (#2625) 2025-11-10 19:52:34 -06:00
Jason Dove
e2d8dee8cd artwork updates (#2624)
* add new logo svg; replace favicons

* replace background

* allow error/offline background customization
2025-11-10 16:01:47 -06:00
Jason Dove
d93c404607 fix interlaced check again (#2623)
* fix interlaced check again

* add interlaced ratio to media item info
2025-11-10 09:14:40 -06:00
Jason Dove
bc400de94c clear custom title when stopping guide group in scripted schedule (#2622) 2025-11-10 08:51:34 -06:00
Jason Dove
b9a73226a8 fix interlaced check (#2621)
* fix interlaced check

* reset any incorrect interlaced probe results
2025-11-10 08:39:44 -06:00
Jason Dove
d0505cd5c5 add better check for interlaced content (#2620) 2025-11-10 06:39:04 -06:00
Jason Dove
d9cdbc72de fix tests [no ci] 2025-11-09 13:50:18 -06:00
Jason Dove
2b0079fedc allow graphics elements with yml and yaml extension (#2617) 2025-11-09 13:40:34 -06:00
Jason Dove
9d2cff53c5 fix removing tags from local libraries (#2616) 2025-11-09 13:30:37 -06:00
Jason Dove
ac361b3165 add troubleshoot button to classic schedules (#2615)
* add troubleshoot button to classic schedules

* move troubleshoot button
2025-11-09 10:17:24 -06:00
Jason Dove
dd9317e3e8 fix mpegts script on windows (#2614) 2025-11-08 10:09:56 -06:00
Jason Dove
7530c592ff add graphics element name (#2613)
* add graphics element name

* update dependencies
2025-11-08 08:26:00 -06:00
Jason Dove
132466b3d3 add avisynth script support to all local libraries (#2612)
* detect avisynth demuxer

* cache ffmpeg capabilities

* check for working avisynth

* scan avs files in all local libraries

* update changelog
2025-11-07 20:48:54 -06:00
Jason Dove
d709cc9f21 include streamlink in amd64 docker image (#2611)
* include streamlink in docker images

* tweak command
2025-11-07 14:12:43 -06:00
Jason Dove
5083e748ed fix mpegts script loading (#2610) 2025-11-07 13:30:37 -06:00
Jason Dove
053b3cd1d7 add mpegts script system (#2609)
* add basic mpegts script

* use custom mpegts script

* update changelog
2025-11-07 13:20:17 -06:00
Joe Kirchoff
c3c7ff2669 Update audio codec in default FFmpeg profile name (#2608)
Quick fix for mismatch against profile audio codec setting.
2025-11-07 05:27:09 -06:00
Jason Dove
e6824cf251 graphics engine: add scaled resolution and place within source content (#2606) 2025-11-06 14:15:55 -06:00
Jason Dove
d87561d140 update subtitle titles (#2605) 2025-11-06 06:11:57 -06:00
Jason Dove
f79fa9a50a fix subtitle title updates (#2604) 2025-11-06 06:03:32 -06:00
Jason Dove
629b3d7d9f fix effective block tests (#2600)
* fix effective block tests running on github

* update dependencies

* pass tz again

* use tzconvert for time zones in tests

* temporary logging

* maybe fix

* test cleanup
2025-11-05 06:48:49 -06:00
Jason Dove
453737a521 add custom_title to start_epg_group (#2599) 2025-11-04 16:08:07 -06:00
Jason Dove
dd38ba19ea add collection type search query (#2598) 2025-11-04 12:22:41 -06:00
Jason Dove
8e2a15296f sync subtitle titles from jellyfin (#2595) 2025-11-03 11:04:44 -06:00
Jason Dove
d2cbfcb79a fix error screen generation (#2594) 2025-11-02 10:41:56 -06:00
Jason Dove
89133255d3 fix classic schedule start time calculation across a UTC offset change (#2593)
* fix classic schedule start time calculation across a UTC offset change

* update changelog
2025-11-02 09:38:18 -06:00
Jason Dove
c6245bae0c fix indexing songs with null artists/album artists (#2592) 2025-11-02 09:25:46 -06:00
Jason Dove
2912e71c10 fix xmltv generation for on-demand playout mode (#2591) 2025-11-01 23:10:02 -05:00
Jason Dove
9e54d42e5f reduce search index batch size (#2590) 2025-11-01 22:45:14 -05:00
Jason Dove
63f342e6a7 fix on-demand playouts having empty xmltv (#2589) 2025-11-01 22:08:49 -05:00
Jason Dove
5a7c59d602 insert unscheduled gaps as utc (#2588) 2025-11-01 20:28:24 -05:00
Jason Dove
4822ba5486 fix block start time calculation (#2587) 2025-11-01 20:16:04 -05:00
Jason Dove
b24617fe7c add some graphics engine template data fields (#2586)
* move resolution out of media item template data key

* add some media item template data
2025-11-01 08:29:56 -05:00
Jason Dove
5045a411b1 fix sequential schedule building across offsets (#2584) 2025-10-31 09:20:09 -05:00
Jason Dove
425fb34317 show playout warnings count in left menu (#2583) 2025-10-31 09:05:25 -05:00
Jason Dove
e5ef9be09c fix explicit audio decoder on combined input (#2582) 2025-10-30 14:37:16 -05:00
Jason Dove
e9338b534b fix seeking content with dts audio (#2581)
* fix seeking content with dts audio

* formatting
2025-10-30 14:13:00 -05:00
Jason Dove
191e694545 fix block playout ui crash (#2580) 2025-10-29 14:16:59 -05:00
Jason Dove
727a978689 fix block history and smart_collection search bugs (#2579)
* fix block history when using mirror offset

* fix smart_collection search crashes
2025-10-29 14:01:14 -05:00
Jason Dove
7a133d46da fix remote stream scripts (#2573) 2025-10-28 12:57:13 -05:00
Jason Dove
b1fbf651a2 log remote stream scripts (#2572) 2025-10-28 09:49:48 -05:00
Jason Dove
0dbdcc3674 fix hls direct with jellyfin 10.11 (#2570) 2025-10-26 18:09:37 -05:00
Jason Dove
9eb7bbf0e6 prep for release v25.8.0 [no ci] 2025-10-26 09:22:02 -05:00
Jason Dove
e851a295a6 fix scripted schedule building across offsets (#2569) 2025-10-26 09:07:21 -05:00
Jason Dove
3b254735e6 fix transcoding tests; fix vaapi subtitle crop (#2568)
* fix transcoding tests using text subtitles

* fix vaapi picture subtitle overlay with crop

* more test improvements
2025-10-26 08:51:24 -05:00
Jason Dove
1f8834c280 block playout fixes; hls direct fixes (#2566)
* fix block playout builder with empty collection

* fix hls direct when selecting audio

* allow embedded subtitles with hls direct
2025-10-25 06:35:56 -05:00
Jason Dove
545db4db9b update dependencies (#2565)
* logging cleanup

* update dependencies
2025-10-24 09:17:45 -05:00
Jason Dove
e590298b93 add deep scans for external collections (#2562) 2025-10-23 19:32:38 -05:00
Jason Dove
a47510fef3 add aac (latm) audio format (#2561)
* add aac (latm) audio format

* update changelog
2025-10-23 15:56:13 -05:00
Jason Dove
e089b12c2b trakt list fixes (#2560)
* show reset playout build failures

* fix scheduling trakt list playlists that contain shows
2025-10-23 11:15:55 -05:00
Jason Dove
82e0fcaec8 maintain source fps when using qsv (#2558) 2025-10-22 14:27:01 -05:00
Jason Dove
f7c699248c fix collection and network scanners (#2557) 2025-10-22 08:59:06 -05:00
Jason Dove
2d53063ce9 edit jellyfin and emby connection info (#2556) 2025-10-21 19:10:48 -05:00
Jason Dove
626048f9c3 change how scanner and main process communicate (#2555)
* report scanner progress using api

* process scanner search index updates through api

* update changelog

* update dependencies
2025-10-21 15:08:49 -05:00
Jason Dove
2ef2b0299a switch back from fmp4 to ts segments (#2554)
* restore pts offset calculation

* use ts segments again

* update changelog
2025-10-21 12:17:05 -05:00
Jason Dove
fcce53a3df fix a couple ui errors (#2551) 2025-10-20 10:54:43 -05:00
Jason Dove
d4353f6d42 add episode thumbnail to xmltv template (#2550) 2025-10-19 12:13:40 -05:00
Jason Dove
64ea413b6f fix nvidia 10 bit text subtitles and permanent watermark (#2549)
* fix nvidia 10 bit text subtitles and watermark

* update changelog
2025-10-19 11:18:15 -05:00
Jason Dove
d14ebf3522 properly track discontinuity sequences with fmp4 (#2548)
* properly track discontinuity sequences with fmp4

* update dependencies
2025-10-19 10:31:11 -05:00
Jason Dove
889904e70d fix management of fmp4 init segments (#2546) 2025-10-18 22:52:45 -05:00
Jason Dove
35e7922836 fix mpegts wrapper with fmp4 segmenter source (#2545) 2025-10-18 16:37:31 -05:00
Jason Dove
ffe15629cb consolidate streaming modes (#2544)
* consolidate segmenters

* let old segmenter mode query params continue to work
2025-10-18 10:54:30 -05:00
Jason Dove
ba5a027525 reduce playout log spam (#2543) 2025-10-18 08:59:17 -05:00
Jason Dove
a33ac4a048 fix qsv audio sync (#2542)
* fix qsv audio sync

* cast a wider net

* always reset qsv pts
2025-10-17 20:01:25 -05:00
Jason Dove
7ae028e2e9 fix trakt list sync (#2540) 2025-10-16 14:00:56 -05:00
Jason Dove
6404dee646 update dependencies (#2539) 2025-10-15 08:41:38 -05:00
Jason Dove
940d26419c fix buffer logging (#2535) 2025-10-14 15:55:27 -05:00
Jason Dove
9bae8e73bf slightly increase throttled readrate in segmenter (#2534) 2025-10-14 14:53:56 -05:00
Jason Dove
f41f4b19d4 wait for two initial segments in playback troubleshooting (#2532) 2025-10-14 11:45:11 -05:00
Jason Dove
917acf9683 fix hls segmenter fmp4 on windows (#2531)
* fix hls segmenter fmp4 on windows

* try to fix by using working directory
2025-10-14 10:29:08 -05:00
Jason Dove
da4687ac0f fix nvidia capabilities on windows (#2530) 2025-10-14 09:32:10 -05:00
Jason Dove
d1af6599f0 fix segmenter repeating content when stream first starts (#2529) 2025-10-14 08:53:22 -05:00
Jason Dove
4e43817f8e add graphics engine text halo (#2528) 2025-10-14 06:41:32 -05:00
Jason Dove
ebcd9a35a7 fix scaling image subs with vaapi (#2526) 2025-10-13 19:42:01 -05:00
Jason Dove
ea5956a268 improve stream startup (#2525) 2025-10-13 16:26:06 -05:00
Jason Dove
65ff1f5502 improve live stream reliability (#2524) 2025-10-13 15:41:48 -05:00
Jason Dove
5ef8b04119 fix intermittent watermark opacity (#2523) 2025-10-13 12:00:58 -05:00
Jason Dove
99837e808a fix speed parsing (#2522) 2025-10-13 11:49:39 -05:00
Jason Dove
7c2083d3f2 add playback troubleshooting speed indicator (#2521)
* more api fixes

* add playback troubleshooting speed indicator
2025-10-13 11:25:56 -05:00
Jason Dove
b851a7daba api changes for ffmpeg profiles (#2520) 2025-10-13 06:44:32 -05:00
Jason Dove
48e7c85f7b api changes to support etvcli (#2519) 2025-10-12 10:13:30 -05:00
Jason Dove
bf4182f115 add copy block button (#2518) 2025-10-11 13:35:22 -05:00
Jason Dove
fb9ca8953e add text element formatting options (#2517) 2025-10-11 12:45:20 -05:00
Jason Dove
48310e044b optimize first run (#2516)
* do not accept ui requests until database is ready

* add empty database to greatly speed up initial startup

* remove middleware
2025-10-11 09:05:31 -05:00
Jason Dove
144b3fe80b fix remote stream durations in playouts (#2515) 2025-10-11 08:30:24 -05:00
Jason Dove
4a754c4e6a add helper text for sequential schedule file (#2514) 2025-10-11 06:43:59 -05:00
Jason Dove
7059669023 add schedule file names to playouts table (#2513) 2025-10-10 21:55:22 -05:00
Jason Dove
c03f81a465 add block playout troubleshooting tool (#2512)
* rename yaml validation to sequential schedule validation

* some better exception handling

* add block playout troubleshooting page

* add paged block playout history

* add history details

* update changelog
2025-10-10 20:50:01 -05:00
Jason Dove
e3d07050bf graphics element template data improvements (#2511)
* add other video template data

* add path and related functions
2025-10-10 13:47:18 -05:00
Jason Dove
5a88bfc310 use old ffmpeg pipeline for single permanent watermark (#2510) 2025-10-10 10:14:29 -05:00
Jason Dove
dd92a65742 more nvidia capabilities safety (#2509) 2025-10-09 22:16:30 -05:00
Jason Dove
07ffa1642b fix nvidia troubleshooting on arm64 (#2508) 2025-10-09 22:07:07 -05:00
Jason Dove
6fc602323f catch proper exception (#2507) 2025-10-09 21:46:54 -05:00
Jason Dove
d5fd8e7be6 another attempt at fixing nvidia startup (#2506)
* another attempt at fixing nvidia startup

* update dependencies
2025-10-09 21:36:55 -05:00
Jason Dove
dba5485300 fix nvidia startup errors on arm64 (#2505) 2025-10-09 21:26:27 -05:00
894 changed files with 351159 additions and 10640 deletions

View File

@@ -3,11 +3,11 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2025.2.2.1",
"version": "2025.3.0.2",
"commands": [
"jb"
],
"rollForward": false
}
}
}
}

View File

@@ -25,6 +25,15 @@ on:
required: true
gh_token:
required: true
azure_client_id:
required: true
azure_tenant_id:
required: true
azure_subscription_id:
required: true
permissions:
id-token: write
contents: write
jobs:
build_and_upload_mac:
name: Mac Build & Upload
@@ -48,7 +57,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -72,8 +81,8 @@ jobs:
shell: bash
run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
@@ -163,7 +172,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -180,8 +189,8 @@ jobs:
# Build everything
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
mkdir "$release_name"
mv scanner/* "$release_name/"
mv main/* "$release_name/"
@@ -220,7 +229,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -232,8 +241,8 @@ jobs:
shell: bash
run: |
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Upload .NET Artifact
uses: actions/upload-artifact@v4
@@ -244,28 +253,10 @@ jobs:
main/
retention-days: 1
build_rust_windows:
name: Build rust for Windows
runs-on: windows-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Build Windows Launcher
shell: bash
run: cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
- name: Upload Rust Artifact
uses: actions/upload-artifact@v4
with:
name: rust-windows-build
path: ErsatzTV-Windows/target/release/ersatztv_windows.exe
retention-days: 1
package_and_upload_windows:
name: Package & Upload Windows
runs-on: ubuntu-latest
needs: [build_dotnet_windows, build_rust_windows]
runs-on: windows-latest
needs: build_dotnet_windows
steps:
- name: Download dotnet artifacts
uses: actions/download-artifact@v4
@@ -273,11 +264,32 @@ jobs:
name: dotnet-windows-build
path: dotnet-build
- name: Download rust artifacts
uses: actions/download-artifact@v4
- name: Azure login
uses: azure/login@v2
with:
name: rust-windows-build
path: rust-build
client-id: ${{ secrets.azure_client_id }}
tenant-id: ${{ secrets.azure_tenant_id }}
subscription-id: ${{ secrets.azure_subscription_id }}
enable-AzPSSession: true
- name: Sign dotnet artifacts
uses: azure/trusted-signing-action@v0
with:
endpoint: https://eus.codesigning.azure.net/
trusted-signing-account-name: ArtifactSigning
certificate-profile-name: ErsatzTV
files-folder: ${{ github.workspace }}/dotnet-build
files-folder-recurse: true
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Download rust launcher
uses: suisei-cn/actions-download-file@v1.3.0
with:
url: "https://github.com/ErsatzTV/ErsatzTV-Windows/releases/download/v1.0.0/ErsatzTV-Windows.exe"
target: rust-build/
- name: Download ffmpeg
uses: suisei-cn/actions-download-file@v1.3.0
@@ -299,11 +311,11 @@ jobs:
# dotnet shouldn't copy the resources here, but it does
rm -rf "$release_name/Resources"
mv rust-build/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
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"
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1

View File

@@ -46,6 +46,10 @@ jobs:
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version

View File

@@ -2,6 +2,31 @@
on:
pull_request:
jobs:
build_and_analyze:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore /p:EnableThreadingAnalyzers=true
build_and_test_windows:
runs-on: windows-latest
steps:
@@ -11,7 +36,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -27,11 +52,6 @@ jobs:
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
- name: Build Windows
run: |
cd ErsatzTV-Windows
cargo build --release --all-features
build_and_test_linux:
runs-on: ${{ matrix.os }}
strategy:
@@ -52,7 +72,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -80,7 +100,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear

View File

@@ -41,6 +41,9 @@ jobs:
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version

View File

@@ -5,6 +5,336 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [26.2.0] - 2026-02-02
### Added
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
- en-US can match sunday using `day_of_week = 0`
- fr-FR can match sunday using `day_of_week = 6`
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
- Add `Pad Mode` to ffmpeg profile. Options are:
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
- `Software` - force software padding
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
- Add API endpoint to clean artwork cache folder (on demand)
- POST `/api/maintenance/clean_artwork`
- Add health check to warn about unsupported empty (classic) schedules
- Add health check to warn about incompatible ffmpeg due to missing filters
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
- `ffmpeg@7` or `ffmpeg-full` should be used instead
- Add `Marathon Group By` option `Director`
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
- This is supported in classic schedules and sequential schedules
- Add FFmpeg Profile options:
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
- Add chapter `title` to filler expression
- This can be used to include or exclude chapters with specific (case-insensitive) titles
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
### Changed
- Disable automatic artwork database cleanup
- This will be re-enabled at some point in the future (after more testing)
- For now, the API should be used to clean as needed
- Classic Schedules: make multiple `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the collection
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the collection
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
### Fixed
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
- Graphics engine:
- Respect `z_index` (draw order) on all graphics element types
- Fix bug with `z_index` sorting
- Restore default UI font that was erroneously removed in v26.1.1
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
- Use configured searching log level on startup, instead of the default log level of `Information`
- MySql: fix searching for shows and seasons in schedule items editor
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
- Use other video artwork in XMLTV template
- Properly update (add or remove) artwork for all local media libraries when files have changed
- Sync Plex library name changes
- Sync Plex episode title, plot, year, date added, release date, episode number changes
- Sync Jellyfin and Emby library name and type changes
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
- Fix some sequential and scripted playout build failures when using playlists or marathons
- Fix erasing playout items and history so all related data is also erased
- This includes rerun history, unscheduled gaps, build status
- Fix indexing collections when using Elasticsearch backend
## [26.1.1] - 2026-01-08
### Fixed
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
### Changed
- Optimize database check for orphaned artwork
- Include web resources (CSS, JS) locally instead of relying on CDNs
## [26.1.0] - 2026-01-06
### Added
- Graphics Engine:
- Add `script` graphics element type
- Supported in playback troubleshooting and all scheduling types
- Supports arbitrary scripts or executables that output graphics to ETV via stdout
- 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
- All template data will also be passed as JSON to the stdin stream of the command
- Template supports:
- Script and arguments (`command` and `args`)
- Draw order (`z_index`)
- Timing (`start_seconds` and `duration_seconds`)
- Data format (`format`)
- `raw` format means full frames of BGRA data to stdout
- `packet` format means ETV graphics packets to stdout
- Add framerate template data
- `RFrameRate` - the real content framerate (or channel normalized framerate) as reported by ffmpeg, e.g. `30000/1001`
- `FrameRate` - the decimal representation of `RFrameRate`, e.g. `29.97002997`
- Add `Channel_StartTime` template data
- This indicates the time that the transcode session started for the current channel
- Add remote stream metadata
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
- Add `Download Media Sample` button to playback troubleshooting
- This button will extract up to 30 seconds of the media item and zip it
- Add `Target Loudness` (LUFS/LKFS) to ffmpeg profile when loudness normalization is enabled
- Default value is `-16`; some sources normalize to a quieter value, e.g. `-24`
- Add environment variables to help troubleshoot performance
- `ETV_SLOW_DB_MS` - milliseconds threshold for logging slow database queries (at DEBUG level)
- e.g. if this is set to `1000`, queries taking longer than 1 second will be logged
- `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level)
- This is currently limited to *Jellyfin*
- `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10
- `ETV_JF_ENABLE_STATS` - enables logging timing information related to Jellyfin show library scans
- Add `Select All` button to media pages by @Erotemic
### Fixed
- Fix startup on systems unsupported by NvEncSharp
- Fix detection of Plex Other Video libraries using `Plex Personal Media` agent
- If the library is already detected as a Movies library in ETV, synchronization must be disabled for the library to change it to an Other Videos library
- A warning will be logged when this scenario is detected
- Graphics Engine:
- Optimize graphics engine to generate element frames in parallel and to eliminate redundant frame copies
- Match graphics engine framerate with source content (or channel normalized) framerate
- Fix loading requested number of epg entries for motion graphics elements
- Fix bug with mirror channels where seemingly random content would be played every ~40 seconds
- Fix chronological sorting for Other Videos that have release date metadata
- Fix playout sorting after using channel number editor
- VAAPI: Only include `-sei a53_cc` flags when misc packed headers are supported by the encoder
- This should fix playback in some cases, e.g. AMD VAAPI h264 encoder
- AMD VAAPI:
- work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
- fix green padding when encoding h264 using main profile
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
- Fix playback of certain BT.2020 content
- Use playlist item count when using a playlist as filler (instead of a fixed count of 1 for each playlist item)
- NVIDIA:
- Fix stream failure with certain content that should decode in hardware but falls back to software
- Fix stream failure with content that changes color metadata mid-stream
- Fix stream failure when configured fallback filler collection is empty
- Fix high CPU when errors are displayed; errors will now work ahead before throttling to realtime, similar to primary content
- Fix startup error caused by duplicate smart collection names (and no longer allow duplicate smart collection names)
- Fix erroneous downgrade health check failure with some installations that use MariaDB
- Sequential schedules: fix `count` instruction validation to accept integer (constant) or string (expression)
- Fix multi-part episode grouping logic so that it does NOT require release date metadata for episodes within a single show
- When **Treat Collections As Shows** is enabled (i.e. for crossover episodes) release date metadata is required for proper grouping
- Fix *many* cases of duplicate names; enforce case-insensitive unique names at the db schema level
- Fix playback when using `ETV_BASE_URL` by @JamesDearlove
### Changed
- No longer round framerate to nearest integer when normalizing framerate
- Allow playlists to have no items included in EPG
- Change how fallback filler works
- Items will no longer loop; instead, a sequence of random items will be selected from the collection
- Items may still be cut as needed
- Hardware acceleration will now be used
- Items can "work ahead" (transcode faster than realtime) when less than 3 minutes in duration
- Optimize Jellyfin database fields and indexes
- Optimize Jellyfin show library scans by only requesting `People` (actors, directors, writers) when etags don't match
- This should significantly speed up periodic library scans, particularly against Jellyfin 10.11.x
- Lazy load media item images in UI
- Align alternate schedule and template handling (between classic schedules and block schedules)
- Both systems now support limiting to a date range
- This date range can be repeating (when year is not specified for start or end dates)
- This date range can be exact (when year is specified for start and end dates)
## [25.9.0] - 2025-11-29
### Added
- Show playout warnings count badge in left menu
- Graphics Engine:
- Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution)
- Add `MediaItem_Start` template data (DateTimeOffset)
- Add `MediaItem_Stop` template data (DateTimeOffset)
- Add `ScaledResolution` template data (the final size of the frame before padding)
- Add `place_within_source_content` (true/false) field to image graphics element
- Add `name` field to all graphics elements to display in the UI
- Classic and block schedules: add collection type `Search Query`
- This allows defining search queries directly on schedule items without creating smart collections beforehand
- As an example, this can be used to filter or combine existing smart collections
- Filter: `smart_collection:"sd movies" AND plot:"christmas"`
- Combine: `smart_collection:"old commercials" OR smart_collection:"nick promos"`
- Scripted schedules: add `custom_title` to `start_epg_group`
- Add MPEG-TS Script system
- This allows using something other than ffmpeg (e.g. streamlink) to concatenate segments back together when using MPEG-TS streaming mode
- Scripts live in config / scripts / mpegts
- Each script gets its own subfolder which contains an `mpegts.yml` definition and corresponding windows (batch) and linux (bash) scripts
- The global MPEG-TS script can be configured in **Settings** > **FFmpeg** > **Default MPEG-TS Script**
- Add `.avs` AviSynth Script support to all local libraries
- `.avs` was added as a valid extension, so they should behave the same any other video file
- There are two requirements for AviSynth Scripts to work:
- FFmpeg needs to be compiled with AviSynth support (not currently available in Docker)
- AviSynth itself needs to be installed
- Add `Troubleshoot` button to classic schedule list
- This generates JSON representing the entire schedule which can be shared when requested for troubleshooting
- Add **Settings** > **FFmpeg** > **Probe For Interlaced Frames**
- When enabled, this will probe *local content* for interlaced frames on demand (immediately before playback)
- This will be used as a more accurate check for interlaced content
- The result will be cached (only probed once and stored) in the database along with all other media item statistics (e.g. duration)
- This feature will currently ignore content that is not streamed from disk
- Add error/offline background customization
- Default error background is now named `_background.png`
- Error streams will prioritize using `background.png` if it exists
- Replacing this `background.png` file will allow custom error/offline backgrounds
- Add `Troubleshoot Playback` buttons on movie and episode detail pages
- Add song background and missing album art customization
- Default files start with an underscore; custom versions must remove the underscore
- Expose arbitrary EPG data to graphics engine via channel guide templates
- XML nodes using the `etv:` namespace will be passed to the graphics engine EPG template data
- For example, adding `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine
- All values parsed from XMLTV will be available as strings in the graphics engine (not numbers)
- All `etv:` nodes will be stripped from the XMLTV data when requested by a client
- Add channel troubleshooting button to channels list
- This will open the playback troubleshooting tool in "channel" mode
- This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout* starting at the entered date and time
- Block schedules: add copy template button to templates table
### Fixed
- Fix HLS Direct playback with Jellyfin 10.11
- Fix remote stream scripts (parsing issue with spaces and quotes)
- Fix block history being removed when it is still needed for mirror channel
- This caused playout build errors like "Unable to locate history for playout item"
- Fix crashes due to invalid smart collection searches, e.g. `smart_collection:"this collection does not exist"`
- Fix UI crash when editing block playout that has default deco
- Fix playback failure when seeking content with certain DTS audio (e.g. DTS-HD MA)
- Properly set explicit audio decoder on combined audio and video input file
- Fix building sequential schedules across a UTC offset change
- Fix block start time calculation across a UTC offset change
- Fix classic schedule start time calculation across a UTC offset change
- Fix XMLTV generation for channels using on-demand playout mode
- Fix some file not found songs missing from trash view
- Fix error/offline screen generation
- Fix subtitle title sync from Jellyfin libraries
- Deep scans will be required to update subtitle titles on existing media items
- Fix saving subtitle title changes to the database
- This fixes e.g. where stream selection would continue to use the original title
- This fix applies to all libraries (local and media server)
- Fix (3 year old) bug removing tags from local libraries when they are removed from NFO files (all content types)
- New scans will properly remove old tags; NFO files may need to be touched to force updating during a scan
- Fix bug where looping motion graphics wouldn't be displayed when seeking into second half of content
- Fix `content_total_duration` value in graphics engine opacity expressions
- This bug caused some graphics elements to display too early after first joining a channel
- Optimize database calls made for search index rebuilds and updates
- This should improve performance of library scans
- Add toggle to hide/show disabled channels in channel list
- Add disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list
- Graphics engine: fix subtitle path escaping and font loading
- Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs
- Work around sequential schedule validation limit (1000/hr by Newtonsoft.Json.Schema library)
- Playout builds now use JsonSchema.Net library which has no validation limit
- Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand
- Fix editing scripted and sequential playouts when using MySql
- Fix HLS Direct streams remaining open after client disconnect
- Always log scanner exit code when it is non-zero
### Changed
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them
- This mode maintains progress; progress can be reset by editing the playout and clicking `Erase Items and History`
- Use smaller batch size for search index updates (100, down from 1000)
- This should help newly scanned items appear in the UI more quickly
- Replace favicon and logo in background image used for error streams
- Block schedules:
- Auto scroll day view to block item time when adding and removing block items from template
- Allow keyboard selection of
- Block groups in block list
- Template groups in template list
- Block groups and blocks in template editor
- Replace template tree view with searchable table (like blocks)
- Upgrade to dotnet 10
## [25.8.0] - 2025-10-26
### Added
- Graphics engine:
- Add template data (like `MediaItem_Title`) for other video files
- Add `MediaItem_Path` for movies, episodes, music videos and other videos
- Add `get_directory_name` and `get_filename_without_extension` functions for path processing
- Add `text_align` property to text graphics elements (values: `left`, `right` and `center`)
- Add `MiddleCenter` value to `location` property on all graphics elements
- Positive and negative margins can be used to offset from center as desired
- Add `line_height` property to text element style definition
- This is a multiplier that defaults to 1.0 when unspecified
- Add `halo_color`, `halo_width` and `halo_blur` properties to text element style definition
- These can be used to "outline" text with the configured color (e.g. `#000000`), width (e.g. `10`) and amount of blur (e.g. `2`)
- Add `Block Playout Troubleshooting` tool to help investigate block playout history
- Add sequential schedule file and scripted schedule file names to playouts table
- Add empty (but already up-to-date) sqlite3 database to greatly speed up initial startup for fresh installs
- Add button to copy/clone block from blocks table
- Add playback speed to playback troubleshooting output
- Speed is relative to realtime (1.0x is realtime)
- Speeds < 0.9x will be colored red, between 0.9x and 1.1x colored yellow, and > 1.1x colored green
- Add episode thumbnail artwork URL to XMLTV template
- By default, poster will be added as image with type "poster" and thumbnail will be added as image with type "still"
- Poster will continue to be added as icon by default
- Add buttons to edit Jellyfin and Emby connection information in **Media Sources** > **Jellyfin** and **Media Sources** > **Emby**
- Add audio format `aac (latm)` for DVB-C compatibility; `aac` uses ADTS by default which is required in most cases
- Add deep scan option for external collections (Plex, Jellyfin, Emby)
- Jellyfin and Emby collection scans have always been deep scans
- Now, by default, they will be quick scans that trust Jellyfin and Emby's etags for detecting changes
- If a quick scan misses updating a collection, deep scans can be triggered manually
### Fixed
- Fix NVIDIA startup errors on arm64
- Fix remote stream durations in playouts created using block, sequential or scripted schedules
- Fix playback troubleshooting selecting a subtitle even with no subtitle stream selected in the UI
- Fix intermittent watermark opacity
- Improve reliability of live remote streams; they should transcode closer to realtime in most cases
- Dramatically improve stream startup time
- VAAPI: fix scaling image-based subtitles (e.g. dvdsub)
- VAAPI: fix overlaying picture subtitles with scaling behavior crop
- Fix HLS Segmenter (fmp4) on Windows
- Playback troubleshooting: wait for at least 2 initial segments (up to configured initial segment count) to reduce stalls
- Fix Trakt List sync
- Fix QSV audio sync
- Fix QSV capability detection on Linux using non-drm displays (e.g. wayland)
- Fix playlist filtering bug that made HLS Segmenter more likely to fail when streaming for multiple hours
- Fix NVIDIA overlaying text subtitles and permanent watermark on 10-bit content
- Fix UI error adding deco
- Fix UI error editing watermarks and graphics elements on blocks
- Fix showing playout build failure details when resetting a playout
- Fix scheduling auto-generated trakt list playlists that contain shows
- Fix playout builder getting stuck (forever) on block item with an empty collection
- Fix HLS Direct playback when using custom stream selector or preferred audio language/title
- Fix selecting embedded subtitles (text and picture) with HLS Direct
- Fix building scripted schedules across a UTC offset change
### Changed
- Do not use graphics engine for single, permanent watermark
- Rename `YAML Validation` tool to `Sequential Schedule Validation`
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
- Remove *experimental* `HLS Segmenter (fmp4)` streaming mode; this mode only worked properly in a browser, many clients did not like it
- Change how scanner process and main process communicate, which should improve reliability of search index updates when scanning
## [25.7.1] - 2025-10-09
### Added
- Add search field to filter blocks table
@@ -22,6 +352,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Use table instead of tree view on blocks page
- Use different release packaging system to workaround false positive from Windows Defender
## [25.7.0] - 2025-10-03
### Added
@@ -461,13 +792,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `random` will start at a random point in the content
- `2` (similar to before this change) will skip the first two items in the content
- YAML playout: make `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
- YAML playout: add `disable_watermarks` property to all content instructions
- This property defaults to `false` (meaning watermarks are allowed by default)
- Setting to `true` will prevent watermarks from ever appearing over the content
@@ -2824,7 +3155,12 @@ 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.7.1...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...HEAD
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
[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

View File

@@ -2,5 +2,6 @@
<PropertyGroup>
<InformationalVersion>develop</InformationalVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>
</Project>
</Project>

15
Directory.Build.targets Normal file
View File

@@ -0,0 +1,15 @@
<Project>
<PropertyGroup>
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.VisualStudio.Threading.Analyzers"
Version="17.14.15"
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -1,2 +0,0 @@
target/

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
[package]
name = "ersatztv_windows"
version = "0.1.0"
edition = "2021"
[dependencies]
tray-item = { git = "https://github.com/olback/tray-item-rs" }
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
process_path = "0.1.4"
[dependencies.windows]
version = "0.43.0"
features = [
"Win32_System_Console",
"Win32_Foundation"
]
[build-dependencies]
windres = "*"
static_vcruntime = "2.0"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,6 +0,0 @@
use windres::Build;
fn main() {
static_vcruntime::metabuild();
Build::new().compile("ersatztv_windows.rc").unwrap();
}

View File

@@ -1,2 +0,0 @@
id ICON "ersatztv.ico"
ersatztv-icon ICON "ersatztv.ico"

View File

@@ -1,115 +0,0 @@
#![windows_subsystem = "windows"]
use special_folder::SpecialFolder;
use std::env;
use std::fs;
use std::os::windows::process::CommandExt;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use windows::Win32::System::Console;
use {std::sync::mpsc, tray_item::TrayItem};
const CREATE_NO_WINDOW: u32 = 0x08000000;
enum Message {
Exit,
}
fn main() {
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
let (tx, rx) = mpsc::channel();
tray.add_menu_item("Launch Web UI", || {
let ui_port = env::var("ETV_UI_PORT")
.ok()
.and_then(|val| val.parse::<u16>().ok())
.unwrap_or(8409);
let _ = Command::new("cmd")
.creation_flags(CREATE_NO_WINDOW)
.arg("/C")
.arg("start")
.arg(format!("http://localhost:{}", ui_port))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
})
.unwrap();
tray.add_menu_item("Show Logs", || {
let path = SpecialFolder::LocalApplicationData
.get()
.unwrap()
.join("ersatztv")
.join("logs");
match path.to_str() {
None => {}
Some(folder) => {
fs::create_dir_all(folder).unwrap();
let _ = Command::new("explorer.exe")
.arg(folder)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
})
.unwrap();
tray.inner_mut().add_separator().unwrap();
tray.add_menu_item("Exit", move || {
tx.send(Message::Exit).unwrap();
})
.unwrap();
let path = process_path::get_executable_path();
let mut child: Option<Child> = None;
match path {
None => {}
Some(path) => {
let etv = path.parent().unwrap().join("ErsatzTV.exe");
if etv.exists() {
match etv.to_str() {
None => {}
Some(etv) => {
child = Some(
Command::new(etv)
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
}
}
}
}
}
loop {
match rx.recv() {
Ok(Message::Exit) => {
match child {
None => {}
Some(mut child) => {
unsafe {
if Console::AttachConsole(child.id()) == true
{
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
}
}
child.wait().unwrap();
}
}
break;
}
_ => {}
}
}
}

View File

@@ -1,30 +1,26 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Artists.Mapper;
namespace ErsatzTV.Application.Artists;
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
public class GetArtistByIdHandler(
IArtistRepository artistRepository,
ISearchRepository searchRepository,
ILanguageCodeService languageCodeService)
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchRepository _searchRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
{
_artistRepository = artistRepository;
_searchRepository = searchRepository;
}
public async Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken)
{
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
Option<Artist> maybeArtist = await artistRepository.GetArtist(request.ArtistId);
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist =>
{
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
List<string> mediaCodes = await searchRepository.GetLanguagesForArtist(artist);
List<string> languageCodes = languageCodeService.GetAllLanguageCodes(mediaCodes);
return ProjectToViewModel(artist, languageCodes);
},
() => Task.FromResult(Option<ArtistViewModel>.None));

View File

@@ -1,6 +1,6 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -12,19 +12,19 @@ namespace ErsatzTV.Application.Channels;
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly IFileSystem _fileSystem;
private readonly ISearchTargets _searchTargets;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
IFileSystem fileSystem,
ISearchTargets searchTargets)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_fileSystem = fileSystem;
_searchTargets = searchTargets;
}
@@ -44,7 +44,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile))
if (_fileSystem.File.Exists(cacheFile))
{
File.Delete(cacheFile);
}

View File

@@ -1,4 +1,5 @@
using System.Globalization;
using System.IO.Abstractions;
using System.Xml;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
@@ -26,6 +27,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelDataHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@@ -33,12 +35,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public RefreshChannelDataHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository,
ILogger<RefreshChannelDataHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_configElementRepository = configElementRepository;
_logger = logger;
@@ -69,9 +73,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
songTemplateFileName is null || otherVideoTemplateFileName is null ||
remoteStreamTemplateFileName is null)
{
return;
}
@@ -101,6 +107,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
@@ -126,6 +135,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -185,6 +198,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.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)
@@ -195,6 +212,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.AsSplitQuery()
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
@@ -235,6 +253,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@@ -260,6 +279,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@@ -283,6 +303,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@@ -312,6 +333,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
@@ -386,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
@@ -402,6 +425,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
@@ -457,6 +481,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
@@ -496,6 +521,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
@@ -519,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
@@ -580,6 +607,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
title,
templateContext,
otherVideoTemplate),
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
request,
templateRemoteStream,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
remoteStreamTemplate),
_ => Option<string>.None
};
@@ -667,6 +704,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
showMetadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
@@ -687,6 +725,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath,
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
EpisodeThumbnailUrl = thumbnailPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
@@ -843,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
@@ -857,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
OtherVideoPlot = metadata.Plot,
OtherVideoHasYear = metadata.Year.HasValue,
OtherVideoYear = metadata.Year,
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
OtherVideoArtworkUrl = artworkPath,
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
OtherVideoContentRating = metadata.ContentRating
@@ -872,18 +916,63 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return Option<string>.None;
}
private string GetMovieTemplateFileName()
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
RefreshChannelData request,
RemoteStream templateRemoteStream,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template remoteStreamTemplate)
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_movie.sbntxt");
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
RemoteStreamTitle = title,
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
RemoteStreamPlot = metadata.Plot,
RemoteStreamHasYear = metadata.Year.HasValue,
RemoteStreamYear = metadata.Year,
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
RemoteStreamArtworkUrl = artworkPath,
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
RemoteStreamContentRating = metadata.ContentRating
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await remoteStreamTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private string GetMovieTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"movie.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -897,16 +986,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetEpisodeTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "episode.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_episode.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"episode.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -920,16 +1005,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetMusicVideoTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "musicVideo.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_musicVideo.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"musicVideo.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -943,16 +1024,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetSongTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "song.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_song.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"song.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -966,16 +1043,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetOtherVideoTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "otherVideo.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_otherVideo.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"otherVideo.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -987,6 +1060,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return templateFileName;
}
private string GetRemoteStreamTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"remoteStream.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{
string artworkPath = artwork.Path;
@@ -1042,6 +1134,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
@@ -1090,7 +1184,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
var result = new List<PlayoutItem>();
if (_localFileSystem.FileExists(path))
if (_fileSystem.File.Exists(path))
{
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(path));

View File

@@ -1,4 +1,5 @@
using System.Data.Common;
using System.IO.Abstractions;
using System.Net;
using System.Xml;
using Dapper;
@@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Channels;
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelListHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@@ -26,11 +28,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
public RefreshChannelListHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ILogger<RefreshChannelListHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -44,13 +48,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
}
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate channel list without template file {File}; please restart ErsatzTV",

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data;
@@ -38,6 +39,14 @@ public class UpdateChannelNumbersHandler(
foreach (var channel in channelsToUpdate)
{
channel.Number = numberUpdates[channel.Id];
if (double.TryParse(channel.Number, CultureInfo.InvariantCulture, out double sortNumber))
{
channel.SortNumber = sortNumber;
}
else
{
return BaseError.New($"Failed to parse channel number {channel.Number}");
}
}
// save those changes

View File

@@ -81,8 +81,6 @@ 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

@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
public record GetAllChannels(bool ShowDisabled = true) : IRequest<List<ChannelViewModel>>;

View File

@@ -5,17 +5,14 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelResponseModel>> Handle(
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll(cancellationToken)).Flatten();
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(ProjectToResponseModel).ToList();
}
}

View File

@@ -9,7 +9,8 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
{
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
await channelRepository.GetAll(cancellationToken)
.Map(list => list.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
private static int GetPlayoutsCount(Channel channel)
{

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelByPlayoutId(int PlayoutId) : IRequest<Option<ChannelViewModel>>;

View File

@@ -0,0 +1,21 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelByPlayoutId, Option<ChannelViewModel>>
{
public async Task<Option<ChannelViewModel>> Handle(
GetChannelByPlayoutId request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.ThenInclude(c => c.Artwork)
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken)
.Map(p => ProjectToViewModel(p.Channel, 1));
}
}

View File

@@ -1,3 +1,5 @@
namespace ErsatzTV.Application.Channels;
using ErsatzTV.FFmpeg;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>;
namespace ErsatzTV.Application.Channels;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;

View File

@@ -1,29 +1,22 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Channels;
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>>
public class GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILogger<GetChannelFramerateHandler> _logger;
public GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
FFmpegProfile ffmpegProfile = await dbContext.Channels
.AsNoTracking()
@@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
if (!ffmpegProfile.NormalizeFramerate)
{
return Option<int>.None;
return Option<FrameRate>.None;
}
// TODO: expand to check everything in collection rather than what's scheduled?
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
@@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten()
.Map(mv => mv.RFrameRate)
.Map(mv => new FrameRate(mv.RFrameRate))
.ToList();
var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1)
{
// TODO: something more intelligent than minimum framerate?
int result = frameRates.Map(ParseFrameRate).Min();
if (result < 24)
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
if (validFrameRates.Count > 0)
{
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber,
distinct,
24,
result);
return 24;
distinct.Map(fr => fr.RFrameRate),
result.RFrameRate);
return result;
}
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
request.ChannelNumber,
distinct,
result);
return result;
distinct.Map(fr => fr.RFrameRate),
FrameRate.DefaultFrameRate.RFrameRate,
minFrameRate.RFrameRate);
return FrameRate.DefaultFrameRate;
}
if (distinct.Count != 0)
{
_logger.LogInformation(
logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0]);
distinct[0].RFrameRate);
}
else
{
_logger.LogInformation(
logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber);
}
}
catch (Exception ex)
{
_logger.LogWarning(
logger.LogWarning(
ex,
"Unexpected error checking frame rates on channel {ChannelNumber}",
request.ChannelNumber);
@@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
return None;
}
private int ParseFrameRate(string frameRate)
{
if (!int.TryParse(frameRate, out int fr))
{
string[] split = (frameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
fr = (int)Math.Round(left / (double)right);
}
else
{
fr = 24;
}
}
return fr;
}
}

View File

@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
@@ -9,27 +11,18 @@ using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
public partial class GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
ILocalFileSystem localFileSystem)
{
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, ChannelGuide>> Handle(
GetChannelGuide request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
@@ -37,13 +30,13 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile))
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!fileSystem.File.Exists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
@@ -51,7 +44,7 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
accessTokenUri += $"&amp;access_token={request.AccessToken}";
}
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
// TODO: is regex faster?
channelsFragment = channelsFragment
@@ -60,27 +53,54 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
var channelDataFragments = new Dictionary<string, string>();
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
{
if (fileName.Contains("channels"))
{
continue;
}
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
try
{
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
catch (FileNotFoundException)
{
// ignore this channel fragment
}
catch (IOException)
{
// ignore this channel fragment
}
}
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
{
await using var stream = fileSystem.FileStream.New(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
return await reader.ReadToEndAsync(cancellationToken);
}
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
private static partial Regex EtvTagRegex();
}

View File

@@ -4,16 +4,12 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
public class GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
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, cancellationToken)

View File

@@ -4,15 +4,11 @@ using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
{
private readonly IChannelRepository _channelRepository;
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
_channelRepository = channelRepository;
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll(cancellationToken)
channelRepository.GetAll(cancellationToken)
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(
request.Scheme,
@@ -38,14 +34,6 @@ 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);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.Configuration;
public record GetMpegTsScripts : IRequest<List<MpegTsScript>>;

View File

@@ -0,0 +1,14 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Application.Configuration;
public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService)
: IRequestHandler<GetMpegTsScripts, List<MpegTsScript>>
{
public async Task<List<MpegTsScript>> Handle(GetMpegTsScripts request, CancellationToken cancellationToken)
{
await mpegTsScriptService.RefreshScripts();
return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList();
}
}

View File

@@ -1,34 +1,43 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby;
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
{
private readonly IScannerProxyService _scannerProxyService;
public CallEmbyCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo,
ILogger<CallEmbyCollectionScannerHandler> logger) : base(
dbContextFactory,
configElementRepository,
runtimeInfo,
logger)
{
_scannerProxyService = scannerProxyService;
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
parameters => PerformScan(parameters, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -40,7 +49,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
});
}
protected override async Task<DateTimeOffset> GetLastScan(
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext,
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
@@ -49,7 +58,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
}
protected override bool ScanIsRequired(
@@ -67,20 +76,40 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
ScanParameters parameters,
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.EmbyCollections);
foreach (var scanId in maybeScanId)
{
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
try
{
var arguments = new List<string>
{
"scan-emby-collections",
request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan)
{
arguments.Add("--force");
if (request.ForceScan)
{
arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
return BaseError.New("Emby collections are already scanning");
}
}

View File

@@ -1,13 +1,15 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby;
@@ -15,14 +17,17 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
{
private readonly IScannerProxyService _scannerProxyService;
public CallEmbyLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo,
ILogger<CallEmbyLibraryScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{
_scannerProxyService = scannerProxyService;
}
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
@@ -38,9 +43,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
parameters => PerformScan(parameters, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -53,38 +58,58 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
ScanParameters parameters,
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
foreach (var scanId in maybeScanId)
{
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture)
};
try
{
var arguments = new List<string>
{
"scan-emby",
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan)
{
arguments.Add("--force");
if (request.ForceScan)
{
arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
}
protected override async Task<DateTimeOffset> GetLastScan(
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext,
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{
DateTime minDateTime = await dbContext.EmbyLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
Option<EmbyLibrary> maybeLibrary = await dbContext.EmbyLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
DateTime minDateTime = maybeLibrary.Match(
l => l.LastScan ?? SystemTime.MinValueUtc,
() => SystemTime.MaxValueUtc);
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
}
protected override bool ScanIsRequired(

View File

@@ -1,26 +1,30 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby;
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
{
private readonly IScannerProxyService _scannerProxyService;
public CallEmbyShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo,
ILogger<CallEmbyShowScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{
_scannerProxyService = scannerProxyService;
}
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
@@ -31,9 +35,9 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
SynchronizeEmbyShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
parameters => PerformScan(parameters, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -46,30 +50,44 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
ScanParameters parameters,
SynchronizeEmbyShowById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
foreach (var scanId in maybeScanId)
{
"scan-emby-show",
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture)
};
try
{
var arguments = new List<string>
{
"scan-emby-show",
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.DeepScan)
{
arguments.Add("--deep");
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
}
return await base.PerformScan(scanner, arguments, cancellationToken);
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
}
protected override Task<DateTimeOffset> GetLastScan(
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext,
SynchronizeEmbyShowById request,
CancellationToken cancellationToken) =>
Task.FromResult(DateTimeOffset.MinValue);
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
protected override bool ScanIsRequired(
DateTimeOffset lastScan,

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Application.Emby;
@@ -12,16 +13,19 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
private readonly IEmbyApiClient _embyApiClient;
private readonly IEmbySecretStore _embySecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public SaveEmbySecretsHandler(
IEmbySecretStore embySecretStore,
IEmbyApiClient embyApiClient,
IMediaSourceRepository mediaSourceRepository,
IMemoryCache memoryCache,
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
{
_embySecretStore = embySecretStore;
_embyApiClient = embyApiClient;
_mediaSourceRepository = mediaSourceRepository;
_memoryCache = memoryCache;
_channel = channel;
}
@@ -47,6 +51,7 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
parameters.Secrets.Address,
parameters.ServerInformation.ServerName,
parameters.ServerInformation.OperatingSystem);
_memoryCache.Remove(new GetEmbyConnectionParameters());
await _channel.WriteAsync(new SynchronizeEmbyMediaSources());
return Unit.Default;

View File

@@ -2,5 +2,6 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
: IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
@@ -11,18 +11,14 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<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="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
<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="WebMarkupMin.Core" Version="2.20.1" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
</ItemGroup>

View File

@@ -9,6 +9,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=emby_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpeg_005Ccommands/@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>

View File

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

View File

@@ -0,0 +1,45 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpeg;
public class RefreshFFmpegCapabilitiesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
ILocalStatisticsProvider localStatisticsProvider)
: IRequestHandler<RefreshFFmpegCapabilities>
{
public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken)
{
hardwareCapabilitiesFactory.ClearCache();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<string> maybeFFmpegPath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists);
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
.FilterT(File.Exists);
foreach (string ffprobePath in maybeFFprobePath)
{
Either<BaseError, MediaVersion> result = await localStatisticsProvider.GetStatistics(
ffprobePath,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs"));
hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight);
}
}
}
}

View File

@@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public record CreateFFmpegProfile(
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -14,6 +16,7 @@ public record CreateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -26,7 +29,9 @@ public record CreateFFmpegProfile(
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;

View File

@@ -44,37 +44,63 @@ public class CreateFFmpegProfileHandler :
CancellationToken cancellationToken) =>
(ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
.Apply((name, threadCount, resolutionId) =>
{
Name = name,
ThreadCount = threadCount,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
VideoFormat = request.VideoFormat,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
var hwAccel = request.NormalizeVideo
? request.HardwareAcceleration
: HardwareAccelerationKind.None;
// mpeg2video only supports 8-bit content
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: request.BitDepth,
return new FFmpegProfile
{
Name = name,
ThreadCount = threadCount,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
DeinterlaceVideo = request.DeinterlaceVideo
NormalizeAudio = request.NormalizeAudio,
NormalizeVideo = request.NormalizeVideo,
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
// only allow customization with VAAPI accel
PadMode = hwAccel switch
{
HardwareAccelerationKind.None => FilterMode.Software,
HardwareAccelerationKind.Vaapi => request.PadMode,
_ => FilterMode.HardwareIfPossible
},
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
// mpeg2video only supports 8-bit content
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: request.BitDepth,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo
};
});
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>

View File

@@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
int FFmpegProfileId,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -15,6 +17,7 @@ public record UpdateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -27,7 +30,9 @@ public record UpdateFFmpegProfile(
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;

View File

@@ -2,8 +2,6 @@
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;
@@ -11,23 +9,14 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
: IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
UpdateFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
}
@@ -38,16 +27,23 @@ public class
UpdateFFmpegProfile update,
CancellationToken cancellationToken)
{
var hwAccel = update.NormalizeVideo
? update.HardwareAcceleration
: HardwareAccelerationKind.None;
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.HardwareAcceleration = update.HardwareAcceleration;
p.NormalizeAudio = update.NormalizeAudio;
p.NormalizeVideo = update.NormalizeVideo;
p.HardwareAcceleration = hwAccel;
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.VideoFormat = update.VideoFormat;
p.PadMode = update.PadMode;
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
p.VideoProfile = update.VideoProfile;
p.VideoPreset = update.VideoPreset;
p.AllowBFrames = update.AllowBFrames;
@@ -64,16 +60,32 @@ public class
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
}
// only allow customization with VAAPI accel
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
{
p.PadMode = FilterMode.Software;
}
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
{
p.PadMode = FilterMode.HardwareIfPossible;
}
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.TonemapAlgorithm = update.TonemapAlgorithm;
p.AudioFormat = update.AudioFormat;
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
p.TargetLoudness = update.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? update.TargetLoudness
: null;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
// don't save invalid preset
@@ -89,7 +101,7 @@ public class
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
searchTargets.SearchTargetsChanged();
return new UpdateFFmpegProfileResult(p.Id);
}
@@ -98,7 +110,8 @@ public class
TvContext dbContext,
UpdateFFmpegProfile request,
CancellationToken cancellationToken) =>
(await FFmpegProfileMustExist(dbContext, request, cancellationToken), ValidateName(request),
(await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await ValidateName(dbContext, request),
ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
@@ -111,9 +124,25 @@ public class
.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) =>
updateFFmpegProfile.NotEmpty(x => x.Name)
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile)
{
if (updateFFmpegProfile.Name.Length > 50)
{
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
}
Option<FFmpegProfile> maybeExisting = await dbContext.FFmpegProfiles
.AsNoTracking()
.FirstOrDefaultAsync(ff =>
ff.Id != updateFFmpegProfile.FFmpegProfileId && ff.Name == updateFFmpegProfile.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"An ffmpeg profile named \"{updateFFmpegProfile.Name}\" already exists in the database")
: Success<BaseError, string>(updateFFmpegProfile.Name);
}
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);

View File

@@ -1,30 +1,21 @@
using System.Diagnostics;
using System.Globalization;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.FFmpeg;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.FFmpegProfiles;
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
public class UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_workerChannel = workerChannel;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateFFmpegSettings request,
CancellationToken cancellationToken) =>
@@ -44,7 +35,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{
if (!_localFileSystem.FileExists(path))
if (!fileSystem.File.Exists(path))
{
return BaseError.New($"{name} path does not exist");
}
@@ -71,32 +62,36 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request, CancellationToken cancellationToken)
{
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
await _configElementRepository.Upsert(
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),
cancellationToken);
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegSaveReports,
request.Settings.SaveReports.ToString(),
cancellationToken);
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegHlsDirectOutputFormat,
request.Settings.HlsDirectOutputFormat,
cancellationToken);
await configElementRepository.Upsert(
ConfigElementKey.FFmpegDefaultMpegTsScript,
request.Settings.DefaultMpegTsScript,
cancellationToken);
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
}
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegPreferredLanguageCode,
request.Settings.PreferredAudioLanguageCode,
cancellationToken);
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
request.Settings.UseEmbeddedSubtitles,
cancellationToken);
@@ -107,7 +102,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
request.Settings.ExtractEmbeddedSubtitles = false;
}
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
request.Settings.ExtractEmbeddedSubtitles,
cancellationToken);
@@ -115,48 +110,55 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
// queue extracting all embedded subtitles
if (request.Settings.ExtractEmbeddedSubtitles)
{
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
}
await configElementRepository.Upsert(
ConfigElementKey.FFmpegProbeForInterlacedFrames,
request.Settings.ProbeForInterlacedFrames,
cancellationToken);
if (request.Settings.GlobalWatermarkId is not null)
{
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalWatermarkId,
request.Settings.GlobalWatermarkId.Value,
cancellationToken);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
}
if (request.Settings.GlobalFallbackFillerId is not null)
{
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalFallbackFillerId,
request.Settings.GlobalFallbackFillerId.Value,
cancellationToken);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
}
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout,
cancellationToken);
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegWorkAheadSegmenters,
request.Settings.WorkAheadSegmenterLimit,
cancellationToken);
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegInitialSegmentCount,
request.Settings.InitialSegmentCount,
cancellationToken);
await workerChannel.WriteAsync(new RefreshFFmpegCapabilities(), cancellationToken);
return Unit.Default;
}
}

View File

@@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
int Id,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -15,6 +17,7 @@ public record FFmpegProfileViewModel(
int? QsvExtraHardwareFrames,
ResolutionViewModel Resolution,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -27,7 +30,9 @@ public record FFmpegProfileViewModel(
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo);

View File

@@ -10,6 +10,7 @@ public class FFmpegSettingsViewModel
public string PreferredAudioLanguageCode { get; set; }
public bool UseEmbeddedSubtitles { get; set; }
public bool ExtractEmbeddedSubtitles { get; set; }
public bool ProbeForInterlacedFrames { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public int? GlobalFallbackFillerId { get; set; }
@@ -17,4 +18,5 @@ public class FFmpegSettingsViewModel
public int WorkAheadSegmenterLimit { get; set; }
public int InitialSegmentCount { get; set; }
public OutputFormatKind HlsDirectOutputFormat { get; set; }
public string DefaultMpegTsScript { get; set; }
}

View File

@@ -10,6 +10,8 @@ internal static class Mapper
profile.Id,
profile.Name,
profile.ThreadCount,
profile.NormalizeAudio,
profile.NormalizeVideo,
profile.HardwareAcceleration,
profile.VaapiDisplay ?? "drm",
profile.VaapiDriver,
@@ -17,6 +19,7 @@ internal static class Mapper
profile.QsvExtraHardwareFrames,
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.ScalingBehavior,
profile.PadMode,
profile.VideoFormat,
profile.VideoProfile,
profile.VideoPreset ?? string.Empty,
@@ -29,9 +32,11 @@ internal static class Mapper
profile.AudioBitrate,
profile.AudioBufferSize,
profile.NormalizeLoudnessMode,
profile.TargetLoudness,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
@@ -47,19 +52,25 @@ internal static class Mapper
ffmpegProfile.Id,
ffmpegProfile.Name,
ffmpegProfile.ThreadCount,
(int)ffmpegProfile.HardwareAcceleration,
ffmpegProfile.HardwareAcceleration,
ffmpegProfile.VaapiDisplay,
(int)ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
ffmpegProfile.ResolutionId,
(int)ffmpegProfile.VideoFormat,
ffmpegProfile.QsvExtraHardwareFrames,
ffmpegProfile.Resolution.Name,
ffmpegProfile.ScalingBehavior,
ffmpegProfile.VideoFormat,
ffmpegProfile.VideoProfile,
ffmpegProfile.VideoPreset,
ffmpegProfile.AllowBFrames,
ffmpegProfile.BitDepth,
ffmpegProfile.VideoBitrate,
ffmpegProfile.VideoBufferSize,
(int)ffmpegProfile.TonemapAlgorithm,
(int)ffmpegProfile.AudioFormat,
ffmpegProfile.TonemapAlgorithm,
ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
(int)ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,

View File

@@ -2,4 +2,4 @@
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegProfileResponseModel>>;
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegFullProfileResponseModel>>;

View File

@@ -6,23 +6,18 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
GetAllFFmpegProfilesForApiHandler : IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegProfileResponseModel>>
public class GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegFullProfileResponseModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<FFmpegProfileResponseModel>> Handle(
public async Task<List<FFmpegFullProfileResponseModel>> Handle(
GetAllFFmpegProfilesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles
.AsNoTracking()
.Include(p => p.Resolution)
.ToListAsync(cancellationToken);
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
return ffmpegProfiles.Map(ProjectToFullResponseModel).ToList();
}
}

View File

@@ -5,18 +5,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
public class GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<FFmpegProfileViewModel>> Handle(
GetFFmpegProfileById 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)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)

View File

@@ -4,41 +4,59 @@ using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Application.FFmpegProfiles;
public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<FFmpegSettingsViewModel> Handle(
GetFFmpegSettings request,
CancellationToken cancellationToken)
{
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken);
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken);
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, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
await configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
Option<string> preferredAudioLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode, cancellationToken);
await configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPreferredLanguageCode,
cancellationToken);
Option<bool> useEmbeddedSubtitles =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken);
await configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
cancellationToken);
Option<bool> extractEmbeddedSubtitles =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken);
await configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
cancellationToken);
Option<bool> probeForInterlacedFrames =
await configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegProbeForInterlacedFrames,
cancellationToken);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
Option<int> fallbackFiller =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
await configElementRepository.GetValue<int>(
ConfigElementKey.FFmpegGlobalFallbackFillerId,
cancellationToken);
Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
Option<int> workAheadSegmenterLimit =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
Option<int> initialSegmentCount =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
Option<OutputFormatKind> outputFormatKind =
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
await configElementRepository.GetValue<OutputFormatKind>(
ConfigElementKey.FFmpegHlsDirectOutputFormat,
cancellationToken);
Option<string> defaultMpegTsScript =
await configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegDefaultMpegTsScript,
cancellationToken);
var result = new FFmpegSettingsViewModel
{
@@ -48,11 +66,13 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
SaveReports = await saveReports.IfNoneAsync(false),
UseEmbeddedSubtitles = await useEmbeddedSubtitles.IfNoneAsync(true),
ExtractEmbeddedSubtitles = await extractEmbeddedSubtitles.IfNoneAsync(false),
ProbeForInterlacedFrames = await probeForInterlacedFrames.IfNoneAsync(false),
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs)
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs),
DefaultMpegTsScript = await defaultMpegTsScript.IfNoneAsync("Default"),
};
foreach (int watermarkId in watermark)

View File

@@ -1,57 +1,67 @@
using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Filler;
public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
public class CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateFillerPresetHandler(IClient client, IDbContextFactory<TvContext> dbContextFactory)
{
_client = client;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, Unit>> Handle(CreateFillerPreset request, CancellationToken cancellationToken)
{
try
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request);
return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken));
}
private static async Task<Unit> Persist(
TvContext dbContext,
FillerPreset fillerPreset,
CancellationToken cancellationToken)
{
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
private static Task<Validation<BaseError, FillerPreset>> Validate(
TvContext dbContext,
CreateFillerPreset request) =>
ValidateName(dbContext, request).MapT(name => new FillerPreset
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Name = name,
FillerKind = request.FillerKind,
FillerMode = request.FillerMode,
Duration = request.Duration,
Count = request.Count,
PadToNearestMinute = request.PadToNearestMinute,
AllowWatermarks = request.AllowWatermarks,
CollectionType = request.CollectionType,
CollectionId = request.CollectionId,
MediaItemId = request.MediaItemId,
MultiCollectionId = request.MultiCollectionId,
SmartCollectionId = request.SmartCollectionId,
PlaylistId = request.PlaylistId,
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null,
UseChaptersAsMediaItems =
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems
});
var fillerPreset = new FillerPreset
{
Name = request.Name,
FillerKind = request.FillerKind,
FillerMode = request.FillerMode,
Duration = request.Duration,
Count = request.Count,
PadToNearestMinute = request.PadToNearestMinute,
AllowWatermarks = request.AllowWatermarks,
CollectionType = request.CollectionType,
CollectionId = request.CollectionId,
MediaItemId = request.MediaItemId,
MultiCollectionId = request.MultiCollectionId,
SmartCollectionId = request.SmartCollectionId,
PlaylistId = request.PlaylistId,
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null,
UseChaptersAsMediaItems =
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems
};
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateFillerPreset request)
{
Validation<BaseError, string> result1 = request.NotEmpty(fp => fp.Name)
.Bind(_ => request.NotLongerThan(50)(fp => fp.Name));
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
bool duplicateName = await dbContext.FillerPresets
.AnyAsync(fp => fp.Name == request.Name);
return Unit.Default;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.Message);
}
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("Filler preset name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => request.Name);
}
}

View File

@@ -6,27 +6,21 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Filler;
public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Either<BaseError, Unit>>
public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<UpdateFillerPreset, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
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,
cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
}
private static async Task<Unit> ApplyUpdateRequest(
TvContext dbContext,
FillerPreset existing,
UpdateFillerPreset request)
UpdateFillerPreset request,
CancellationToken cancellationToken)
{
existing.Name = request.Name;
existing.FillerKind = request.FillerKind;
@@ -45,16 +39,40 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
existing.UseChaptersAsMediaItems =
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
private static async Task<Validation<BaseError, FillerPreset>> Validate(
TvContext dbContext,
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
(await FillerPresetMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
dbContext.FillerPresets
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("FillerPreset does not exist"));
.Map(o => o.ToValidation<BaseError>("Filler preset does not exist"));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateFillerPreset request)
{
Validation<BaseError, string> result1 = request.NotEmpty(fp => fp.Name)
.Bind(_ => request.NotLongerThan(50)(fp => fp.Name));
bool duplicateName = await dbContext.FillerPresets
.AnyAsync(c => c.Id != request.Id && c.Name == request.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("Filler preset name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => request.Name);
}
}

View File

@@ -4,22 +4,18 @@ using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler;
public class GetPagedFillerPresetsHandler : IRequestHandler<GetPagedFillerPresets, PagedFillerPresetsViewModel>
public class GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPagedFillerPresets, PagedFillerPresetsViewModel>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<PagedFillerPresetsViewModel> Handle(
GetPagedFillerPresets request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.FillerPresets.CountAsync(cancellationToken);
List<FillerPresetViewModel> page = await dbContext.FillerPresets
.AsNoTracking()
.OrderBy(f => EF.Functions.Collate(f.Name, TvContext.CaseInsensitiveCollation))
.OrderBy(f => f.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)

View File

@@ -1,6 +1,8 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -9,7 +11,9 @@ namespace ErsatzTV.Application.Graphics;
public class RefreshGraphicsElementsHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IGraphicsElementLoader graphicsElementLoader,
ILogger<RefreshGraphicsElementsHandler> logger)
: IRequestHandler<RefreshGraphicsElements>
{
@@ -21,7 +25,11 @@ public class RefreshGraphicsElementsHandler(
List<GraphicsElement> allExisting = await dbContext.GraphicsElements
.ToListAsync(cancellationToken);
foreach (GraphicsElement existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path)))
var missing = allExisting
.Where(e => !fileSystem.File.Exists(e.Path) || (Path.GetExtension(e.Path) != ".yml" && Path.GetExtension(e.Path) != ".yaml"))
.ToList();
foreach (GraphicsElement existing in missing)
{
logger.LogWarning(
"Removing graphics element that references non-existing file {File}",
@@ -30,8 +38,13 @@ public class RefreshGraphicsElementsHandler(
dbContext.GraphicsElements.Remove(existing);
}
foreach (GraphicsElement existing in allExisting.Except(missing))
{
await TryRefreshName(existing, cancellationToken);
}
// add new text elements
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder)
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.yml", "*.yaml")
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
@@ -45,11 +58,13 @@ public class RefreshGraphicsElementsHandler(
Kind = GraphicsElementKind.Text
};
await TryRefreshName(graphicsElement, cancellationToken);
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new image elements
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder)
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder, "*.yml", "*.yaml")
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
@@ -63,11 +78,13 @@ public class RefreshGraphicsElementsHandler(
Kind = GraphicsElementKind.Image
};
await TryRefreshName(graphicsElement, cancellationToken);
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new motion elements
var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder)
var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder, "*.yml", "*.yaml")
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
@@ -81,11 +98,13 @@ public class RefreshGraphicsElementsHandler(
Kind = GraphicsElementKind.Motion
};
await TryRefreshName(graphicsElement, cancellationToken);
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new subtitle elements
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder)
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder, "*.yml", "*.yaml")
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
@@ -99,9 +118,41 @@ public class RefreshGraphicsElementsHandler(
Kind = GraphicsElementKind.Subtitle
};
await TryRefreshName(graphicsElement, cancellationToken);
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new script elements
var newScriptPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsScriptTemplatesFolder, "*.yml", "*.yaml")
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (string path in newScriptPaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Script
};
await TryRefreshName(graphicsElement, cancellationToken);
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task TryRefreshName(GraphicsElement graphicsElement, CancellationToken cancellationToken)
{
graphicsElement.Name = null;
Option<string> maybeName = await graphicsElementLoader.TryLoadName(graphicsElement.Path, cancellationToken);
foreach (string name in maybeName)
{
graphicsElement.Name = name;
}
}
}

View File

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

View File

@@ -7,13 +7,22 @@ public static class Mapper
public static GraphicsElementViewModel ProjectToViewModel(GraphicsElement graphicsElement)
{
string fileName = Path.GetFileName(graphicsElement.Path);
return graphicsElement.Kind switch
fileName = 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)
GraphicsElementKind.Text => $"text/{fileName}",
GraphicsElementKind.Image => $"image/{fileName}",
GraphicsElementKind.Subtitle => $"subtitle/{fileName}",
GraphicsElementKind.Motion => $"motion/{fileName}",
GraphicsElementKind.Script => $"script/{fileName}",
_ => graphicsElement.Path
};
string name = fileName;
if (!string.IsNullOrWhiteSpace(graphicsElement.Name))
{
name = $"{graphicsElement.Name} ({fileName})";
}
return new GraphicsElementViewModel(graphicsElement.Id, name, fileName);
}
}

View File

@@ -14,6 +14,6 @@ public class GetAllGraphicsElementsHandler(IDbContextFactory<TvContext> dbContex
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.GraphicsElements
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
.Map(list => list.Map(ProjectToViewModel).OrderBy(e => e.Name == e.FileName).ThenBy(e => e.Name).ToList());
}
}

View File

@@ -1,34 +1,43 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
{
private readonly IScannerProxyService _scannerProxyService;
public CallJellyfinCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo,
ILogger<CallJellyfinCollectionScannerHandler> logger) : base(
dbContextFactory,
configElementRepository,
runtimeInfo,
logger)
{
_scannerProxyService = scannerProxyService;
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
parameters => PerformScan(parameters, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -40,7 +49,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
});
}
protected override async Task<DateTimeOffset> GetLastScan(
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext,
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
@@ -49,7 +58,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
}
protected override bool ScanIsRequired(
@@ -67,20 +76,40 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
ScanParameters parameters,
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.JellyfinCollections);
foreach (var scanId in maybeScanId)
{
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
try
{
var arguments = new List<string>
{
"scan-jellyfin-collections",
request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId),
};
if (request.ForceScan)
{
arguments.Add("--force");
if (request.ForceScan)
{
arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
return BaseError.New("Jellyfin collections are already scanning");
}
}

View File

@@ -1,13 +1,15 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin;
@@ -15,14 +17,17 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
{
private readonly IScannerProxyService _scannerProxyService;
public CallJellyfinLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo,
ILogger<CallJellyfinLibraryScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{
_scannerProxyService = scannerProxyService;
}
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.
@@ -39,9 +44,9 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
parameters => PerformScan(parameters, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -54,38 +59,58 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
ScanParameters parameters,
ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
foreach (var scanId in maybeScanId)
{
"scan-jellyfin", request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture)
};
try
{
var arguments = new List<string>
{
"scan-jellyfin",
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan)
{
arguments.Add("--force");
if (request.ForceScan)
{
arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
}
protected override async Task<DateTimeOffset> GetLastScan(
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext,
ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken)
{
DateTime minDateTime = await dbContext.JellyfinLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
Option<JellyfinLibrary> maybeLibrary = await dbContext.JellyfinLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
DateTime minDateTime = maybeLibrary.Match(
l => l.LastScan ?? SystemTime.MinValueUtc,
() => SystemTime.MaxValueUtc);
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
}
protected override bool ScanIsRequired(

View File

@@ -1,26 +1,30 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>,
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>
{
private readonly IScannerProxyService _scannerProxyService;
public CallJellyfinShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo,
ILogger<CallJellyfinShowScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{
_scannerProxyService = scannerProxyService;
}
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle(
@@ -31,9 +35,9 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
parameters => PerformScan(parameters, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -46,30 +50,44 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
ScanParameters parameters,
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
foreach (var scanId in maybeScanId)
{
"scan-jellyfin-show",
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture)
};
try
{
var arguments = new List<string>
{
"scan-jellyfin-show",
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.DeepScan)
{
arguments.Add("--deep");
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
}
return await base.PerformScan(scanner, arguments, cancellationToken);
return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
}
protected override Task<DateTimeOffset> GetLastScan(
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext,
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken) =>
Task.FromResult(DateTimeOffset.MinValue);
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
protected override bool ScanIsRequired(
DateTimeOffset lastScan,

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Application.Jellyfin;
@@ -12,16 +13,19 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public SaveJellyfinSecretsHandler(
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinApiClient jellyfinApiClient,
IMediaSourceRepository mediaSourceRepository,
IMemoryCache memoryCache,
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
{
_jellyfinSecretStore = jellyfinSecretStore;
_jellyfinApiClient = jellyfinApiClient;
_mediaSourceRepository = mediaSourceRepository;
_memoryCache = memoryCache;
_channel = channel;
}
@@ -47,6 +51,7 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
parameters.Secrets.Address,
parameters.ServerInformation.ServerName,
parameters.ServerInformation.OperatingSystem);
_memoryCache.Remove(new GetJellyfinConnectionParameters());
await _channel.WriteAsync(new SynchronizeJellyfinMediaSources());
return Unit.Default;

View File

@@ -2,6 +2,6 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) :
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

View File

@@ -1,52 +1,31 @@
using System.Runtime.InteropServices;
using System.Threading.Channels;
using CliWrap;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Compact.Reader;
using Microsoft.Extensions.Logging;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace ErsatzTV.Application.Libraries;
public abstract class CallLibraryScannerHandler<TRequest>
public abstract class CallLibraryScannerHandler<TRequest>(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
IRuntimeInfo runtimeInfo,
ILogger logger)
{
private const int BatchSize = 100;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediator _mediator;
private readonly IRuntimeInfo _runtimeInfo;
private readonly List<int> _toReindex = [];
private readonly List<int> _toRemove = [];
private string _libraryName;
protected CallLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
{
_dbContextFactory = dbContextFactory;
_configElementRepository = configElementRepository;
_channel = channel;
_mediator = mediator;
_runtimeInfo = runtimeInfo;
}
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
protected async Task<Either<BaseError, string>> PerformScan(
string scanner,
ScanParameters parameters,
List<string> arguments,
CancellationToken cancellationToken)
{
@@ -57,38 +36,25 @@ public abstract class CallLibraryScannerHandler<TRequest>
await using CancellationTokenRegistration link =
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)));
CommandResult process = await Cli.Wrap(scanner)
CommandResult process = await Cli.Wrap(parameters.Scanner)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
.WithStandardOutputPipe(PipeTarget.ToDelegate(ProcessProgressOutput))
.WithStandardOutputPipe(PipeTarget.Null)
.ExecuteAsync(forcefulCts.Token, cancellationToken);
if (process.ExitCode != 0)
{
logger.LogWarning("ErsatzTV.Scanner exited with code {ExitCode}", process.ExitCode);
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
}
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();
}
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
return _libraryName ?? string.Empty;
return parameters.LibraryName;
}
private static void ProcessLogOutput(string s)
@@ -102,7 +68,7 @@ public abstract class CallLibraryScannerHandler<TRequest>
// writes in UTC
LogEvent logEvent = LogEventReader.ReadFromString(s);
ILogger log = Log.Logger;
Serilog.ILogger log = Log.Logger;
if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property))
{
log = log.ForContext(
@@ -125,76 +91,32 @@ public abstract class CallLibraryScannerHandler<TRequest>
}
}
private async Task ProcessProgressOutput(string s)
{
if (!string.IsNullOrWhiteSpace(s))
{
try
{
ScannerProgressUpdate progressUpdate = JsonConvert.DeserializeObject<ScannerProgressUpdate>(s);
if (progressUpdate != null)
{
if (!string.IsNullOrWhiteSpace(progressUpdate.LibraryName))
{
_libraryName = progressUpdate.LibraryName;
}
_toReindex.AddRange(progressUpdate.ItemsToReindex);
if (_toReindex.Count >= BatchSize)
{
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()));
_toReindex.Clear();
}
_toRemove.AddRange(progressUpdate.ItemsToRemove);
if (_toRemove.Count >= BatchSize)
{
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()));
_toRemove.Clear();
}
if (progressUpdate.PercentComplete is not null)
{
var progress = new LibraryScanProgress(
progressUpdate.LibraryId,
progressUpdate.PercentComplete.Value);
await _mediator.Publish(progress);
}
}
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Unable to process scanner progress update");
}
}
}
protected abstract Task<DateTimeOffset> GetLastScan(
protected abstract Task<Tuple<string, 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, CancellationToken cancellationToken)
protected async Task<Validation<BaseError, ScanParameters>> Validate(TRequest request, CancellationToken cancellationToken)
{
try
{
int libraryRefreshInterval = await _configElementRepository
int libraryRefreshInterval = await configElementRepository
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.IfNoneAsync(0);
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken);
(string libraryName, DateTimeOffset lastScan) = await GetLastScan(dbContext, request, cancellationToken);
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
{
return new ScanIsNotRequired();
}
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
string executable = runtimeInfo.IsOSPlatform(OSPlatform.Windows)
? "ErsatzTV.Scanner.exe"
: "ErsatzTV.Scanner";
@@ -211,7 +133,7 @@ public abstract class CallLibraryScannerHandler<TRequest>
string localFileName = Path.Combine(folderName, executable);
if (File.Exists(localFileName))
{
return localFileName;
return new ScanParameters(libraryName, localFileName);
}
}
@@ -222,4 +144,6 @@ public abstract class CallLibraryScannerHandler<TRequest>
return BaseError.New("Scan was canceled");
}
}
protected sealed record ScanParameters(string LibraryName, string Scanner);
}

View File

@@ -2,7 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,20 +15,23 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILanguageCodeService _languageCodeService;
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ICachingSearchRepository _searchRepository;
private readonly ISearchRepository _searchRepository;
public MoveLocalLibraryPathHandler(
ISearchIndex searchIndex,
ICachingSearchRepository searchRepository,
ISearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILanguageCodeService languageCodeService,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<MoveLocalLibraryPathHandler> logger)
{
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_languageCodeService = languageCodeService;
_dbContextFactory = dbContextFactory;
_logger = logger;
}
@@ -64,6 +67,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
await _searchIndex.UpdateItems(
_searchRepository,
_fallbackMetadataProvider,
_languageCodeService,
[mediaItem]);
}
}

View File

@@ -1,18 +1,174 @@
using ErsatzTV.Core;
using System.Globalization;
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Images;
using Humanizer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Maintenance;
public class DeleteOrphanedArtworkHandler : IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
public class DeleteOrphanedArtworkHandler(
IDbContextFactory<TvContext> dbContextFactory,
IArtworkRepository artworkRepository,
IFileSystem fileSystem,
ILogger<DeleteOrphanedArtworkHandler> logger)
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
{
private readonly IArtworkRepository _artworkRepository;
public async Task<Either<BaseError, Unit>> Handle(
DeleteOrphanedArtwork request,
CancellationToken cancellationToken)
{
try
{
await CleanUpDatabase();
await CleanUpFileSystem(cancellationToken);
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
_artworkRepository = artworkRepository;
return Unit.Default;
}
catch (Exception e)
{
return BaseError.New(e.Message);
}
}
public Task<Either<BaseError, Unit>>
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
_artworkRepository.GetOrphanedArtwork()
.Bind(_artworkRepository.Delete)
.Map(_ => Right<BaseError, Unit>(Unit.Default));
private async Task CleanUpDatabase()
{
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
if (ids.Count > 0)
{
await artworkRepository.Delete(ids);
}
}
private async Task CleanUpFileSystem(CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
System.Collections.Generic.HashSet<string> validFiles = [];
List<string> watermarks = await dbContext.ChannelWatermarks
.TagWithCallSite()
.AsNoTracking()
.Select(c => c.Image)
.ToListAsync(cancellationToken);
foreach (string watermark in watermarks.Where(w => !string.IsNullOrWhiteSpace(w)))
{
validFiles.Add(watermark);
}
var lastId = 0;
while (true)
{
List<MinimalArtwork> result = await dbContext.Artwork
.TagWithCallSite()
.AsNoTracking()
.Where(a => a.Id > lastId)
.OrderBy(a => a.Id)
.Take(1000)
.Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64))
.ToListAsync(cancellationToken);
if (result.Count == 0)
{
break;
}
foreach (MinimalArtwork artwork in result)
{
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
{
validFiles.Add(artwork.Path);
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
}
}
lastId = result.Last().Id;
}
logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count);
var deleted = 0;
long bytes = 0;
foreach (string file in fileSystem.Directory.EnumerateFiles(
FileSystemLayout.ArtworkCacheFolder,
"*.*",
SearchOption.AllDirectories))
{
string fileName = fileSystem.Path.GetFileName(file);
if (!validFiles.Contains(fileName))
{
try
{
bytes += fileSystem.FileInfo.New(file).Length;
fileSystem.File.Delete(file);
deleted++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not delete artwork file {File}", file);
}
}
}
logger.LogDebug(
"Deleted {Count} unused artwork cache files totaling {Size}",
deleted,
bytes.Bytes().Humanize(CultureInfo.CurrentCulture));
DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder);
}
private void DeleteEmptySubfolders(string path)
{
if (!fileSystem.Directory.Exists(path))
{
return;
}
foreach (string sub in fileSystem.Directory.GetDirectories(path))
{
DeleteEmptySubfolders(sub);
}
if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any())
{
try
{
// don't delete artwork cache folder or its direct children
if (path != FileSystemLayout.ArtworkCacheFolder)
{
var parent = fileSystem.Directory.GetParent(path);
if (parent?.FullName != FileSystemLayout.ArtworkCacheFolder)
{
fileSystem.Directory.Delete(path);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path);
}
}
}
private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64);
}

View File

@@ -140,7 +140,7 @@ internal static class Mapper
return new SongCardViewModel(
songMetadata.SongId,
songMetadata.Title,
string.Join(", ", songMetadata.Artists) + album,
string.Join(", ", songMetadata.Artists ?? []) + album,
songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None),
songMetadata.Song.State);
@@ -161,7 +161,7 @@ internal static class Mapper
remoteStreamMetadata.Title,
remoteStreamMetadata.OriginalTitle,
remoteStreamMetadata.SortTitle,
string.Empty, // TODO: thumbnail?
GetThumbnail(remoteStreamMetadata, None, None),
remoteStreamMetadata.RemoteStream.State);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>

View File

@@ -3,7 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
@@ -19,13 +19,14 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
public AddTraktListHandler(
ITraktApiClient traktApiClient,
ICachingSearchRepository searchRepository,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
ILanguageCodeService languageCodeService,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<AddTraktListHandler> logger,
IEntityLocker entityLocker)
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, languageCodeService, logger)
{
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;

View File

@@ -49,16 +49,15 @@ public class CreateCollectionHandler :
TvContext dbContext,
CreateCollection createCollection)
{
List<string> allNames = await dbContext.Collections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name)
.Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("Collection name must be unique");
bool duplicateName = await dbContext.Collections
.AnyAsync(c => c.Name == createCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("Collection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => createCollection.Name);
}

View File

@@ -92,16 +92,15 @@ public class CreateMultiCollectionHandler :
TvContext dbContext,
CreateMultiCollection createMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name)
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createMultiCollection.Name)
.Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique");
bool duplicateName = await dbContext.MultiCollections
.AnyAsync(c => c.Name == createMultiCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("MultiCollection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => createMultiCollection.Name);
}

View File

@@ -40,11 +40,10 @@ public class CreatePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory
return BaseError.New($"Playlist name \"{request.Name}\" is invalid");
}
Option<Playlist> maybeExisting = await dbContext.Playlists
.FirstOrDefaultAsync(r => r.PlaylistGroupId == request.PlaylistGroupId && r.Name == request.Name)
.Map(Optional);
bool duplicate = await dbContext.Playlists
.AnyAsync(r => r.PlaylistGroupId == request.PlaylistGroupId && r.Name == request.Name);
return maybeExisting.IsSome
return duplicate
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
: Success<BaseError, string>(request.Name);
}

View File

@@ -46,16 +46,15 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
TvContext dbContext,
CreateRerunCollection createCollection)
{
List<string> allNames = await dbContext.RerunCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name)
.Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("Rerun collection name must be unique");
bool duplicateName = await dbContext.RerunCollections
.AnyAsync(c => c.Name == createCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("Rerun collection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => createCollection.Name);
}

View File

@@ -59,17 +59,16 @@ public class CreateSmartCollectionHandler :
TvContext dbContext,
CreateSmartCollection createSmartCollection)
{
List<string> allNames = await dbContext.SmartCollections
.Map(c => c.Name)
.ToListAsync();
bool duplicateName = await dbContext.SmartCollections
.AnyAsync(c => c.Name == createSmartCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("SmartCollection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
Validation<BaseError, string> result1 = createSmartCollection.NotEmpty(c => c.Name)
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name)
.Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record CreateSmartCollectionResult(int SmartCollectionId) : EntityIdResult(SmartCollectionId);

View File

@@ -2,7 +2,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
@@ -16,22 +16,25 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILanguageCodeService _languageCodeService;
private readonly ISearchIndex _searchIndex;
private readonly ICachingSearchRepository _searchRepository;
private readonly ISearchRepository _searchRepository;
public DeleteTraktListHandler(
ITraktApiClient traktApiClient,
ICachingSearchRepository searchRepository,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
ILanguageCodeService languageCodeService,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<DeleteTraktListHandler> logger,
IEntityLocker entityLocker)
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, languageCodeService, logger)
{
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_fallbackMetadataProvider = fallbackMetadataProvider;
_languageCodeService = languageCodeService;
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;
}
@@ -65,6 +68,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
await _searchIndex.RebuildItems(
_searchRepository,
_fallbackMetadataProvider,
_languageCodeService,
mediaItemIds,
cancellationToken);
}

View File

@@ -2,7 +2,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
@@ -19,9 +19,10 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
public MatchTraktListItemsHandler(
ITraktApiClient traktApiClient,
ICachingSearchRepository searchRepository,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
ILanguageCodeService languageCodeService,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<MatchTraktListItemsHandler> logger,
IEntityLocker entityLocker) : base(
@@ -29,6 +30,7 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
searchRepository,
searchIndex,
fallbackMetadataProvider,
languageCodeService,
logger)
{
_dbContextFactory = dbContextFactory;

View File

@@ -1,5 +1,7 @@
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data) : IRequest<List<PlayoutItemPreviewViewModel>>;
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data)
: IRequest<Either<BaseError, List<PlayoutItemPreviewViewModel>>>;

View File

@@ -17,9 +17,9 @@ public class PreviewPlaylistPlayoutHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
IPlayoutBuilder playoutBuilder)
: IRequestHandler<PreviewPlaylistPlayout, List<PlayoutItemPreviewViewModel>>
: IRequestHandler<PreviewPlaylistPlayout, Either<BaseError, List<PlayoutItemPreviewViewModel>>>
{
public async Task<List<PlayoutItemPreviewViewModel>> Handle(
public async Task<Either<BaseError, List<PlayoutItemPreviewViewModel>>> Handle(
PreviewPlaylistPlayout request,
CancellationToken cancellationToken)
{
@@ -65,7 +65,7 @@ public class PreviewPlaylistPlayoutHandler(
PlayoutBuildMode.Reset,
cancellationToken);
return await buildResult.MatchAsync(
return await buildResult.MatchAsync<Either<BaseError, List<PlayoutItemPreviewViewModel>>>(
async result =>
{
var maxItems = 0;
@@ -121,7 +121,7 @@ public class PreviewPlaylistPlayoutHandler(
return onceThrough.OrderBy(i => i.StartOffset).Map(Scheduling.Mapper.ProjectToViewModel).ToList();
},
_ => []);
error => error);
}
private static ProgramScheduleItemFlood MapToScheduleItem(PreviewPlaylistPlayout request) =>

View File

@@ -1,7 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Trakt;
@@ -15,20 +15,23 @@ namespace ErsatzTV.Application.MediaCollections;
public abstract class TraktCommandBase
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILanguageCodeService _languageCodeService;
private readonly ILogger _logger;
private readonly ISearchIndex _searchIndex;
private readonly ICachingSearchRepository _searchRepository;
private readonly ISearchRepository _searchRepository;
protected TraktCommandBase(
ITraktApiClient traktApiClient,
ICachingSearchRepository searchRepository,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
ILanguageCodeService languageCodeService,
ILogger logger)
{
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_fallbackMetadataProvider = fallbackMetadataProvider;
_languageCodeService = languageCodeService;
_logger = logger;
TraktApiClient = traktApiClient;
@@ -195,7 +198,8 @@ public abstract class TraktCommandBase
Index = item.Rank,
PlaylistId = list.Playlist.Id,
Playlist = list.Playlist,
IncludeInProgramGuide = true
IncludeInProgramGuide = true,
PlaybackOrder = PlaybackOrder.Chronological
};
await dbContext.PlaylistItems.AddAsync(playlistItem, cancellationToken);
@@ -227,6 +231,7 @@ public abstract class TraktCommandBase
await _searchIndex.RebuildItems(
_searchRepository,
_fallbackMetadataProvider,
_languageCodeService,
ids.ToList(),
cancellationToken);
}

View File

@@ -70,7 +70,7 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
TvContext dbContext,
UpdateCollection request,
CancellationToken cancellationToken) =>
(await CollectionMustExist(dbContext, request, cancellationToken), ValidateName(request))
(await CollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
@@ -81,7 +81,20 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private static Validation<BaseError, string> ValidateName(UpdateCollection updateSimpleMediaCollection) =>
updateSimpleMediaCollection.NotEmpty(c => c.Name)
.Bind(_ => updateSimpleMediaCollection.NotLongerThan(50)(c => c.Name));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateCollection updateCollection)
{
Validation<BaseError, string> result1 = updateCollection.NotEmpty(c => c.Name)
.Bind(_ => updateCollection.NotLongerThan(50)(c => c.Name));
bool duplicateName = await dbContext.Collections
.AnyAsync(c => c.Id != updateCollection.CollectionId && c.Name == updateCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("Collection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => updateCollection.Name);
}
}

View File

@@ -155,17 +155,15 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
TvContext dbContext,
UpdateMultiCollection updateMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId)
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = updateMultiCollection.NotEmpty(c => c.Name)
.Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(updateMultiCollection.Name)
.Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique");
bool duplicateName = await dbContext.MultiCollections
.AnyAsync(c => c.Id != updateMultiCollection.MultiCollectionId && c.Name == updateMultiCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("MultiCollection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => updateMultiCollection.Name);
}

View File

@@ -54,10 +54,12 @@ public class UpdateRerunCollectionHandler(
return Unit.Default;
}
private static Task<Validation<BaseError, RerunCollection>> Validate(
private static async Task<Validation<BaseError, RerunCollection>> Validate(
TvContext dbContext,
UpdateRerunCollection request,
CancellationToken cancellationToken) => RerunCollectionMustExist(dbContext, request, cancellationToken);
CancellationToken cancellationToken) =>
(await RerunCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, RerunCollection>> RerunCollectionMustExist(
TvContext dbContext,
@@ -66,4 +68,21 @@ public class UpdateRerunCollectionHandler(
dbContext.RerunCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.RerunCollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Rerun collection does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateRerunCollection updateCollection)
{
Validation<BaseError, string> result1 = updateCollection.NotEmpty(c => c.Name)
.Bind(_ => updateCollection.NotLongerThan(50)(c => c.Name));
bool duplicateName = await dbContext.RerunCollections
.AnyAsync(c => c.Id != updateCollection.RerunCollectionId && c.Name == updateCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("Rerun collection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => updateCollection.Name);
}
}

View File

@@ -2,4 +2,5 @@
namespace ErsatzTV.Application.MediaCollections;
public record UpdateSmartCollection(int Id, string Name, string Query) : IRequest<Either<BaseError, Unit>>;
public record UpdateSmartCollection(int Id, string Name, string Query)
: IRequest<Either<BaseError, UpdateSmartCollectionResult>>;

View File

@@ -12,7 +12,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
public class
UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection,
Either<BaseError, UpdateSmartCollectionResult>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -34,7 +36,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
_smartCollectionCache = smartCollectionCache;
}
public async Task<Either<BaseError, Unit>> Handle(
public async Task<Either<BaseError, UpdateSmartCollectionResult>> Handle(
UpdateSmartCollection request,
CancellationToken cancellationToken)
{
@@ -43,7 +45,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
}
private async Task<Unit> ApplyUpdateRequest(
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
TvContext dbContext,
SmartCollection c,
UpdateSmartCollection request,
@@ -65,13 +67,14 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
}
}
return Unit.Default;
return new UpdateSmartCollectionResult(c.Id);
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
UpdateSmartCollection request,
CancellationToken cancellationToken) => SmartCollectionMustExist(dbContext, request, cancellationToken);
CancellationToken cancellationToken) => ValidateName(dbContext, request)
.BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken));
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
@@ -80,4 +83,21 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateSmartCollection updateCollection)
{
Validation<BaseError, string> result1 = updateCollection.NotEmpty(c => c.Name)
.Bind(_ => updateCollection.NotLongerThan(50)(c => c.Name));
bool duplicateName = await dbContext.SmartCollections
.AnyAsync(c => c.Id != updateCollection.Id && c.Name == updateCollection.Name);
Validation<BaseError, Unit> result2 = duplicateName
? Fail<BaseError, Unit>("SmartCollection name must be unique")
: Success<BaseError, Unit>(Unit.Default);
return (result1, result2).Apply((_, _) => updateCollection.Name);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record UpdateSmartCollectionResult(int SmartCollectionId) : EntityIdResult(SmartCollectionId);

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
@@ -23,6 +24,9 @@ internal static class Mapper
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
internal static SmartCollectionResponseModel ProjectToResponseModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
internal static RerunCollectionViewModel ProjectToViewModel(RerunCollection collection) =>
new(
collection.Id,

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.SmartCollections;
namespace ErsatzTV.Application.MediaCollections;
public record GetAllSmartCollectionsForApi : IRequest<List<SmartCollectionResponseModel>>;

View File

@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections;
public class GetAllSmartCollectionsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllSmartCollectionsForApi, List<SmartCollectionResponseModel>>
{
public async Task<List<SmartCollectionResponseModel>> Handle(
GetAllSmartCollectionsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<SmartCollection> ffmpegProfiles = await dbContext.SmartCollections
.AsNoTracking()
.ToListAsync(cancellationToken);
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
}
}

View File

@@ -19,13 +19,11 @@ public class GetPagedCollectionsHandler(IDbContextFactory<TvContext> dbContextFa
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = query.Where(s => EF.Functions.Like(
EF.Functions.Collate(s.Name, TvContext.CaseInsensitiveCollation),
$"%{request.Query}%"));
query = query.Where(c => EF.Functions.Like(c.Name, $"%{request.Query}%"));
}
List<MediaCollectionViewModel> page = await query
.OrderBy(c => EF.Functions.Collate(c.Name, TvContext.CaseInsensitiveCollation))
.OrderBy(c => c.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)

View File

@@ -19,13 +19,11 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = query.Where(s => EF.Functions.Like(
EF.Functions.Collate(s.Name, TvContext.CaseInsensitiveCollation),
$"%{request.Query}%"));
query = query.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"));
}
List<MultiCollectionViewModel> page = await query
.OrderBy(f => EF.Functions.Collate(f.Name, TvContext.CaseInsensitiveCollation))
.OrderBy(mc => mc.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.Include(mc => mc.MultiCollectionItems)

View File

@@ -19,13 +19,11 @@ public class GetPagedRerunCollectionsHandler(IDbContextFactory<TvContext> dbCont
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = query.Where(s => EF.Functions.Like(
EF.Functions.Collate(s.Name, TvContext.CaseInsensitiveCollation),
$"%{request.Query}%"));
query = query.Where(rc => EF.Functions.Like(rc.Name, $"%{request.Query}%"));
}
List<RerunCollectionViewModel> page = await query
.OrderBy(f => EF.Functions.Collate(f.Name, TvContext.CaseInsensitiveCollation))
.OrderBy(rc => rc.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)

View File

@@ -19,13 +19,11 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = query.Where(s => EF.Functions.Like(
EF.Functions.Collate(s.Name, TvContext.CaseInsensitiveCollation),
$"%{request.Query}%"));
query = query.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"));
}
List<SmartCollectionViewModel> page = await query
.OrderBy(s => EF.Functions.Collate(s.Name, TvContext.CaseInsensitiveCollation))
.OrderBy(s => s.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)

View File

@@ -4,22 +4,18 @@ using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections;
public class GetPagedTraktListsHandler : IRequestHandler<GetPagedTraktLists, PagedTraktListsViewModel>
public class GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPagedTraktLists, PagedTraktListsViewModel>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<PagedTraktListsViewModel> Handle(
GetPagedTraktLists request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.TraktLists.CountAsync(cancellationToken);
List<TraktListViewModel> page = await dbContext.TraktLists
.AsNoTracking()
.OrderBy(f => EF.Functions.Collate(f.Name, TvContext.CaseInsensitiveCollation))
.OrderBy(l => l.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.Include(l => l.Items)

View File

@@ -57,14 +57,6 @@ public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFacto
.ThenInclude(mm => mm.Artwork)
.ToListAsync(cancellationToken);
if (allItems.All(bi => !bi.IncludeInProgramGuide))
{
foreach (PlaylistItem bi in allItems)
{
bi.IncludeInProgramGuide = true;
}
}
return allItems.Map(Mapper.ProjectToViewModel).ToList();
}
}

View File

@@ -15,6 +15,7 @@ public record MediaItemInfo(
string DisplayAspectRatio,
string RFrameRate,
VideoScanKind VideoScanKind,
double? InterlacedRatio,
int Width,
int Height,
List<MediaItemInfoStream> Streams,

View File

@@ -147,6 +147,7 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
version.DisplayAspectRatio,
version.RFrameRate,
version.VideoScanKind,
version.InterlacedRatio,
version.Width,
version.Height,
allStreams,

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