Compare commits

...

80 Commits

Author SHA1 Message Date
Jason Dove
a0ea2e8910 update changelog for release v0.6.8-beta [no ci] 2022-10-05 11:09:03 -05:00
Jason Dove
734ca39cbd add guids to search index (#980) 2022-10-04 19:52:19 -05:00
Jason Dove
e0e5cfada5 fix numeric range search queries (#979) 2022-10-04 18:49:35 -05:00
Jason Dove
7e0c43bc46 update dependencies (#977) 2022-10-01 07:56:14 -05:00
Jason Dove
be1125a9ab properly sync updated file paths from plex (#976) 2022-09-30 20:41:32 -05:00
Jason Dove
d21c985a77 fix emby tag sync for movies and shows (#975) 2022-09-30 19:56:19 -05:00
Jason Dove
28f2b9b27e disable anamorphic edge case in scaling calculations (#971) 2022-09-26 15:11:05 -05:00
Jason Dove
9b185e19e9 fix other video search results when nfo metadata is used (#970)
* add debug no sync build config

* fix other video search results

* update dependencies
2022-09-25 22:27:34 -05:00
Jason Dove
27b923b462 qsv and vaapi scaling fixes (#966)
* add qsv device option to ffmpeg profile

* fix vaapi scaling

* cleanup
2022-09-18 21:02:09 -05:00
Jason Dove
357dfee050 nvidia and software mode scaling improvements (#965)
* convert to square pixels before software scaling

* convert to square pixels in nvidia scale filter

* more scaling fixes; position watermark within padded content

* fix image subtitle scaling

* fix qsv scaling

* update dependencies
2022-09-18 11:04:02 -05:00
Jason Dove
7f4004c228 fix qsv hevc encoder (#956)
* update dependencies

* fix typo in qsv hevc encoder param

* update changelog
2022-09-10 15:44:59 -05:00
Jason Dove
9b8dc0ed80 update changelog for release v0.6.7-beta [no ci] 2022-09-05 13:15:04 -05:00
Jason Dove
3cc1286271 include other videos (ungrouped) in shuffle in order (#953)
* include other videos (ungrouped) in shuffle in order

* fix id conflict
2022-09-05 09:20:11 -05:00
Jason Dove
df281758b7 properly fix infinite playout build loop (#952) 2022-09-05 08:41:39 -05:00
Jason Dove
25273c18c8 stop infinite playout building loop (#951) 2022-09-04 20:28:28 -05:00
Jason Dove
f1be945423 add qsv extra hardware frames setting (#950)
* wip add qsv extra_hw_frames setting

* fix ffmpeg profile editor

* update changelog
2022-09-04 18:07:03 -05:00
Jason Dove
9a4f772f53 fix image subtitle scaling (#949)
* properly scale image-based subtitles for nvidia and software

* fix vaapi image subtitle scaling

* fix qsv image subtitle scaling

* update changelog
2022-09-04 14:25:05 -05:00
Jason Dove
d669e8114b more scaling fixes (#948)
* remove force_original_aspect_ratio from scale_cuda

* remove force_original_aspect_ratio from scale_cuda

* fix qsv scaling

* fix qsv scaling on linux

* fix vaapi scaling edge cases

* update changelog
2022-09-03 20:28:18 -05:00
Jason Dove
3972e3603b add amf acceleration (#947) 2022-09-03 10:39:54 -05:00
dependabot[bot]
acc22fcb62 Bump MudBlazor from 6.0.14 to 6.0.15 (#945)
Bumps [MudBlazor](https://github.com/MudBlazor/MudBlazor) from 6.0.14 to 6.0.15.
- [Release notes](https://github.com/MudBlazor/MudBlazor/releases)
- [Changelog](https://github.com/MudBlazor/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/MudBlazor/MudBlazor/compare/v6.0.14...v6.0.15)

---
updated-dependencies:
- dependency-name: MudBlazor
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-01 19:41:34 -05:00
Jason Dove
2df360d7fb fix xmltv filler bug (#944) 2022-08-31 20:15:25 -05:00
Jason Dove
46331ed2c6 add preferred audio title feature (#943)
* use consistent edit/delete icons

* add preferred audio title feature

* update dependencies
2022-08-30 17:04:41 -05:00
Jason Dove
3aee3b0515 fix windows build 2022-08-26 09:07:46 -05:00
Jason Dove
72c45692b2 update dependencies (#937) 2022-08-26 08:52:59 -05:00
Jason Dove
8edf71ca55 downgrade libva, include vainfo in docker (#936) 2022-08-25 16:29:20 -05:00
Jason Dove
612b9e6524 fix scanner crash caused by invalid mtime (#934) 2022-08-20 19:56:37 -05:00
Jason Dove
7aff65f07b explicitly copy all audio streams with hls direct (#933)
* ensure audio streams are always copied with hls direct

* update changelog
2022-08-18 14:23:51 -05:00
Jason Dove
5d350fcfad update changelog for release v0.6.6-beta [no ci] 2022-08-17 20:40:50 -05:00
Jason Dove
5546ad204c upgrade to ffmpeg 5.1 (#931)
* use ffmpeg 5.1 on windows

* remove some debug logs

* use latest ffmpeg on arm

* use ffmpeg 5.1 base images

* update ffmpeg health check for 5.1

* update changelog
2022-08-17 14:57:23 -05:00
Jason Dove
d66efa0a1d prioritize container aspect ratio over stream aspect ratio (#930)
* prioritize container aspect ratio over stream aspect ratio

* use setdar filter
2022-08-16 19:57:38 -05:00
Jason Dove
36d3d38530 remove all use of setsar filter (#928) 2022-08-16 12:25:59 -05:00
Jason Dove
8e79141860 use multi-variant playlists for hls segmenter (#926)
* use multi-variant playlists for hls segmenter

* use lowercase mime type
2022-08-13 19:58:08 -05:00
Jason Dove
9b3545f7ca add some temporary debug logging (#925) 2022-08-13 19:19:47 -05:00
Jason Dove
56db20faa0 limit segmenter delay to 8s (#924)
* always return initial hls playlist after 8 seconds

* update dependencies

* make fluentvalidation happy
2022-08-12 19:48:24 -05:00
Jason Dove
b0bd4c9fed add ogg file formats to local song library scanner (#914)
* add ogg file formats to local song library scanner

* update dependencies
2022-08-04 16:01:17 -05:00
Jason Dove
ba079452e2 add dff and dsf to local song library scanner (#911) 2022-08-03 11:01:13 -05:00
Jason Dove
f0f2b3da4b update changelog for release v0.6.5-beta [no ci] 2022-08-02 07:36:45 -05:00
Jason Dove
866049543c fix db initializer (#907) 2022-07-31 12:30:33 -05:00
Jason Dove
40ed4b8b0e update changelog for release v0.6.4-beta [no ci] 2022-07-28 12:23:33 -05:00
Jason Dove
b43d08ca67 fix repeating schedules (#901) 2022-07-26 13:04:05 -05:00
Jason Dove
5e7e386108 fix search result filtering for episodes and other videos (#900) 2022-07-25 20:02:07 -05:00
Jason Dove
4176df9940 fix nvidia capabilities for second-gen maxwell (#899) 2022-07-24 12:23:30 -05:00
Jason Dove
de2ef959fe add 640x480 resolution (#898)
* update dependencies

* add 640x480 resolution
2022-07-24 08:17:27 -05:00
Jason Dove
b53cfebac1 fix bug with unsupported aac channel layouts (#893)
* fix bug with unsupported aac channel layouts

* update dependencies
2022-07-14 10:52:25 -05:00
Jason Dove
6895b9cc6b fix search repo caching bug (#886)
* add failing test

* fix search repo bug

* update dependencies
2022-07-10 15:32:06 -05:00
Jason Dove
c60d6e46f1 fix changelog [no ci] 2022-07-04 15:23:26 -05:00
Jason Dove
c66d190174 update changelog for release v0.6.3-beta [no ci] 2022-07-04 15:20:53 -05:00
Jason Dove
5e8da591be update dependencies (#883)
* fix database initialization

* update dependencies
2022-07-02 20:42:07 -05:00
Jason Dove
9c02a6738b fix missing trashed episodes (#881)
* fix episodes missing from trash

* cleanup
2022-06-29 15:01:49 -05:00
Jason Dove
5ed0184bca add minimum log level setting (#877) 2022-06-27 10:29:04 -05:00
Jason Dove
ae64ca4a93 fix arm images by using ls55 (#876) 2022-06-26 17:41:39 -05:00
Jason Dove
c47099895e include item state in search index duplicate filter (#875) 2022-06-26 13:34:54 -05:00
Jason Dove
a2529febba use brew for gon 2022-06-26 08:30:32 -05:00
Jason Dove
521e0ba8b3 get a new build 2022-06-26 06:46:30 -05:00
Jason Dove
ee0efac9be only publish docs when docs are updated 2022-06-26 06:21:27 -05:00
Jason Dove
bfe7635489 work around github actions issue on mac (#874) 2022-06-25 19:30:21 -05:00
Jason Dove
aa1735f024 fix song and other video search index (#873) 2022-06-25 18:13:39 -05:00
Jason Dove
8deae983c7 add some startup log messages (#872) 2022-06-25 13:03:37 -05:00
Jason Dove
f349646703 apply plex episode metadata updates (#871)
* update more plex episode metadata

* update dependencies
2022-06-22 19:41:05 -05:00
Jason Dove
5003e80500 maintain stream continuity after playout reset (#868)
* maintain stream continuity after playout reset

* maintain continuity after error streams
2022-06-18 21:38:25 -05:00
Jason Dove
940d9cd6b5 update changelog for release v0.6.2-beta [no ci] 2022-06-18 13:46:45 -05:00
Jason Dove
197c166789 fix jellyfin admin id selection (#867) 2022-06-17 18:25:22 -05:00
Jason Dove
d114db091e use proper nvidia accel output format for 10-bit content (#865) 2022-06-17 11:33:10 -05:00
Jason Dove
3204da8e43 adjust nvidia capabilities (#864)
* adjust nvidia capabilities logic

* fallback to software encoding for 10-bit h264

* cleanup

* more tweaks
2022-06-17 10:50:36 -05:00
Jason Dove
100eb14408 fix epg sorting (#863)
* fix epg sorting

* update dependencies
2022-06-17 08:44:26 -05:00
Jason Dove
025017ace5 regularly delete old segments (#856)
* regularly delete old segments

* cleanup
2022-06-15 21:12:07 -05:00
Jason Dove
6a690c7c10 add more filler logging (#854) 2022-06-15 10:21:05 -05:00
Jason Dove
dd7f77751c detect nvidia capabilities (#853)
* fallback to software codecs for old nvidia cards

* update dependencies
2022-06-14 19:44:34 -05:00
Jason Dove
0c13b8ef1a force amd64 for arm32v7 sdk build layer (#843) 2022-06-12 13:54:55 -05:00
Jason Dove
c6ca58ab97 build arm32v7 docker image (#842)
* build arm32v7 docker image

* fix
2022-06-12 13:42:27 -05:00
Jason Dove
0846fc1d96 update workflow dependencies (#841) 2022-06-11 13:40:47 -05:00
Jason Dove
e41dd68ee0 fix automatic playout building (#840) 2022-06-11 13:11:04 -05:00
Jason Dove
0a92996da8 fix repeating content (#838)
* fix repeating content

* update dependencies
2022-06-08 10:37:56 -05:00
Jason Dove
082bc6145c update changelog for release v0.6.1-beta [no ci] 2022-06-03 15:05:06 -05:00
Jason Dove
bf3f16451b music video credits tweaks (#834)
* fix song subtitles

* always use generated subtitles

* file not found/unavailable fixes
2022-06-03 14:44:52 -05:00
Jake
3cb37003cb UI rewrite - ffmpeg profiles (#823)
* ffmpeg profile functionality, sweetalert2

* add new files

* cleanup controller; remove unused classes

* apply formatting

* cleanup core project

* don't use bom

* whitespace

* remove generated css

* remove generated js/map

* Remove attempted linter fix, channels button, watermarks page. Fixed handlerror.

* Changed deleted confirmation to toast.

* Localized strings for language change. Modified Action icons to buttons and left default sizes. Changed Cancel to No where Yes is an option

* lint

Co-authored-by: Jason Dove <jason@jasondove.me>
2022-06-03 06:28:32 -05:00
Jason Dove
9acfd2cd06 fix plex server identification (#833) 2022-06-03 05:53:41 -05:00
dependabot[bot]
3242e7ebb8 Bump HtmlSanitizer from 7.1.488 to 7.1.509 (#830)
Bumps [HtmlSanitizer](https://github.com/mganss/HtmlSanitizer) from 7.1.488 to 7.1.509.
- [Release notes](https://github.com/mganss/HtmlSanitizer/releases)
- [Commits](https://github.com/mganss/HtmlSanitizer/compare/v7.1.488...v7.1.509)

---
updated-dependencies:
- dependency-name: HtmlSanitizer
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-02 21:21:17 -05:00
Jason Dove
7644d628e7 generate music video credits (#832) 2022-06-02 20:50:55 -05:00
Jason Dove
b4f19e6de4 fix jellyfin tv paging (#831) 2022-06-02 12:16:59 -05:00
279 changed files with 31335 additions and 882 deletions

View File

@@ -1,6 +1,6 @@
[*]
charset=utf-8-bom
charset=utf-8
end_of_line=lf
trim_trailing_whitespace=false
insert_final_newline=false

View File

@@ -41,18 +41,18 @@ jobs:
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '14'
@@ -108,15 +108,16 @@ jobs:
--icon "ErsatzTV.app" 200 190 \
--hide-extension "ErsatzTV.app" \
--app-drop-link 600 185 \
--skip-jenkins \
"ErsatzTV.dmg" \
"ErsatzTV.app/"
- name: Notarize
shell: bash
run: |
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
unzip -o -q gon.zip
./gon -log-level=debug -log-json ./gon.json
brew tap mitchellh/gon
brew install mitchellh/gon/gon
gon -log-level=debug -log-json ./gon.json
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
@@ -167,17 +168,17 @@ jobs:
target: win-x64
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '14'
@@ -197,7 +198,7 @@ jobs:
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.0/ffmpeg-5.0-full_build.7z"
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.1/ffmpeg-5.1-full_build.7z"
target: ffmpeg/
- name: Build

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Extract Docker Tag

View File

@@ -39,32 +39,36 @@ jobs:
path: 'vaapi/'
suffix: '-vaapi'
qemu: false
- name: arm32v7
path: 'arm32v7/'
suffix: '-arm'
qemu: true
- name: arm64
path: 'arm64/'
suffix: '-arm64'
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
if: ${{ matrix.qemu == true }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
id: docker-buildx
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
@@ -75,10 +79,10 @@ jobs:
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name != 'arm64' }}
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
@@ -91,3 +95,18 @@ jobs:
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm64' }}
- name: Build and push
uses: docker/build-push-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm/v7'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm32v7' }}

View File

@@ -3,13 +3,16 @@ on:
push:
branches:
- main
paths:
- docs/**
- mkdocs.yml
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master

View File

@@ -10,10 +10,10 @@ jobs:
os: [ windows-latest, ubuntu-latest, macos-latest ]
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Extract Docker Tag

View File

@@ -7,10 +7,10 @@ jobs:
steps:
# Checkout the current repo
- name: Checkout current repository
uses: actions/checkout@v2
uses: actions/checkout@v3
# Setup NodeJS version 14
- name: Setup NodeJS V14.x.x
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '14'
# CD into the current client directory and lint and build the client

View File

@@ -5,6 +5,118 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.6.8-beta] - 2022-10-05
### Fixed
- Fix typo introduced in `0.6.7-beta` that stopped QSV HEVC encoder from working
- Fix scaling logic for `Nvidia` acceleration and software mode
- Attempt to position watermarks within content (not over added black padding)
- Fix search results for `Other Videos` when NFO metadata is used
- Properly synchronize tags from Emby movies and shows
- Properly sync updated file paths from Plex
- Fix numeric range search queries (e.g. `minutes:[5 TO 10]`, `minutes:[* TO 3]`)
### Added
- Add `QSV Device` option to ffmpeg profile on linux
- Add guids to search index (e.g. `imdb:tt000000`, `tvdb:12345`)
## [0.6.7-beta] - 2022-09-05
### Fixed
- When all audio streams are selected with `HLS Direct`, explicitly copy them without transcoding
- This only happens when the channel does not have a `Preferred Audio Language`
- Fix scanner crash caused by invalid mtime
- `VAAPI`: Downgrade libva from 2.15 to 2.14
- Fix bug with XMLTV that caused some filler to display with primary content details
- Multiple fixes for content scaling with `Nvidia`, `Qsv` and `Vaapi` accelerations
- Properly scale image-based subtitles
- Fix bug where a schedule containing a single item (fixed start and flood) would never finish building a playout
- Logic was also added to detect infinite playout build loops in the future and stop them
- Fix bug where `Other Videos` wouldn't be included in scheduling mode `Shuffle In Order`
### Added
- Add `Preferred Audio Title` feature
- Preference can be configured in channel settings and overridden on schedule items
- When a title is specified, audio streams that contain that title (case-insensitive search) will be prioritized
- This can be helpful for creating channels that use commentary tracks
- External tooling exists to easily update title/name metadata if your audio streams don't already have this metadata
- Add `Amf` hardware acceleration option for AMD GPUs on Windows
- Add `QSV Extra Hardware Frames` parameter for tuning QSV acceleration
- Performance may improve on some systems after doubling or halving the default value of `64`
## [0.6.6-beta] - 2022-08-17
### Fixed
- Use MIME Type `application/x-mpegurl` for all playlists instead of `application/vnd.apple.mpegurl`
- Replace `setsar` filter with `setdar` filter
- `setsar` caused issues scaling between two different aspect ratios
- For example, some 4:3 content would appear stretched when scaled to a 16:9 resolution
- `setdar` is now only used when aspect ratios match
- Prioritize aspect ratio from container when video stream contains conflicting aspect ratio
- This is usually caused by bad authoring, but the change should improve scaling behavior for edge cases
### Added
- Support DSD audio file formats (DFF and DSF) in local song libraries
- Support OGG audio file formats (OGG, OPUS, OGA, OGX, SPX) in local song libraries
### Changed
- Always return playlist after a maximum of 8 seconds while starting up an HLS Segmenter session
- Use multi-variant playlists instead of redirects for HLS Segmenter sessions
- Upgrade ffmpeg from 5.0 to 5.1 in most docker images (not ARM variants)
- Upgrading from 5.0 to 5.1 is also recommended for other installations (Windows, Linux)
## [0.6.5-beta] - 2022-08-02
### Fixed
- Fix database initializer; fresh installs with v0.6.4-beta are missing some config data and should upgrade
## [0.6.4-beta] - 2022-07-28
### Fixed
- Fix subtitle stream selection when subtitle language is different than audio language
- Fix bug with unsupported AAC channel layouts
- Fix NVIDIA second-gen maxwell capabilities detection
- Return distinct search results for episodes and other videos that have the same title
- For example, two other videos both named `Trailer` would previously have displayed as one item in search results
- Fix schedules that would begin to repeat the same content in the same order after a couple of days
### Added
- Add `640x480` resolution
## [0.6.3-beta] - 2022-07-04
### Fixed
- Maintain stream continuity when playout is rebuilt for a channel that is actively being streamed
- Properly apply changes to episode title, sort title, outline and plot from Plex
- Fix search index for other videos and songs
- In previous versions, some libraries would incorrectly display only one item
- Properly display old versions of renamed items in trash
### Added
- Add `Minimum Log Level` option to `Settings` page
- Other methods of configuring the log level will no longer work
## [0.6.2-beta] - 2022-06-18
### Fixed
- Fix content repeating for up to a minute near the top of every hour
- Check whether hardware-accelerated hevc codecs are supported by the NVIDIA card
- Software codecs will be used if they are unsupported by the NVIDIA card
- Fix sorting of channel contents in EPG
- Fix Jellyfin admin user id sync
- Ignore disabled admins and admins who do not have access to all libraries
### Added
- Add 32-bit `arm` docker tags (`develop-arm` and `latest-arm`)
### Changed
- Regularly delete old segments from transcode folder while content is actively transcoding
- This should help reduce required disk space
- To further minimize required disk space, set `Work-Ahead HLS Segmenter Limit` to `0` in `Settings`
## [0.6.1-beta] - 2022-06-03
### Fixed
- Fix Jellyfin show library paging
- Properly locate and identify multiple Plex servers
- Properly restore `Unavailable`/`File Not Found` items when they are located on disk
### Added
- Add basic music video credits subtitle generation
- This can be enabled in channel settings
## [0.6.0-beta] - 2022-06-01
### Fixed
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
@@ -1225,7 +1337,15 @@ 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/jasongdove/ErsatzTV/compare/v0.6.0-beta...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.8-beta...HEAD
[0.6.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.7-beta...v0.6.8-beta
[0.6.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.6-beta...v0.6.7-beta
[0.6.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.5-beta...v0.6.6-beta
[0.6.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.4-beta...v0.6.5-beta
[0.6.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.3-beta...v0.6.4-beta
[0.6.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...v0.6.3-beta
[0.6.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.1-beta...v0.6.2-beta
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
@@ -1322,4 +1442,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
[0.0.5-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
[0.0.4-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
[0.0.3-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
[0.0.1-prealpha]: https://github.com/jasongdove/ErsatzTV/releases/tag/v0.0.1-prealpha
[0.0.1-prealpha]: https://github.com/jasongdove/ErsatzTV/releases/tag/v0.0.1-prealpha

View File

@@ -17,7 +17,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="CliWrap" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
@@ -30,4 +30,4 @@
</Compile>
</ItemGroup>
</Project>
</Project>

View File

@@ -11,9 +11,11 @@ public record ChannelViewModel(
int FFmpegProfileId,
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
int PlayoutCount,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode);
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);

View File

@@ -12,8 +12,10 @@ public record CreateChannel
int FFmpegProfileId,
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;

View File

@@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
return await validation.Apply(c => PersistChannel(dbContext, c));
}
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
@@ -71,8 +71,10 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredAudioLanguageCode = preferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode
};
foreach (int id in watermarkId)

View File

@@ -13,8 +13,10 @@ public record UpdateChannel
int FFmpegProfileId,
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;

View File

@@ -31,7 +31,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
@@ -42,8 +42,10 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredAudioTitle = update.PreferredAudioTitle;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
c.SubtitleMode = update.SubtitleMode;
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo))

View File

@@ -15,12 +15,14 @@ internal static class Mapper
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode);
channel.SubtitleMode,
channel.MusicVideoCreditsMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;

View File

@@ -0,0 +1,32 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Serilog.Core;
namespace ErsatzTV.Application.Configuration;
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly LoggingLevelSwitch _loggingLevelSwitch;
public UpdateGeneralSettingsHandler(
LoggingLevelSwitch loggingLevelSwitch,
IConfigElementRepository configElementRepository)
{
_loggingLevelSwitch = loggingLevelSwitch;
_configElementRepository = configElementRepository;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateGeneralSettings request,
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
{
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.MinimumLogLevel);
_loggingLevelSwitch.MinimumLevel = generalSettings.MinimumLogLevel;
return Unit.Default;
}
}

View File

@@ -0,0 +1,8 @@
using Serilog.Events;
namespace ErsatzTV.Application.Configuration;
public class GeneralSettingsViewModel
{
public LogEventLevel MinimumLogLevel { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;

View File

@@ -0,0 +1,24 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Serilog.Events;
namespace ErsatzTV.Application.Configuration;
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
{
Option<LogEventLevel> maybeLogLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
return new GeneralSettingsViewModel
{
MinimumLogLevel = await maybeLogLevel.IfNoneAsync(LogEventLevel.Information)
};
}
}

View File

@@ -7,12 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.5.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="MediatR" Version="11.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -10,6 +10,7 @@ public record CreateFFmpegProfile(
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,

View File

@@ -20,7 +20,7 @@ public class CreateFFmpegProfileHandler :
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
}
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
@@ -44,6 +44,7 @@ public class CreateFFmpegProfileHandler :
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
VideoFormat = request.VideoFormat,
VideoBitrate = request.VideoBitrate,

View File

@@ -15,7 +15,7 @@ public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegP
public async Task<FFmpegProfileViewModel> Handle(NewFFmpegProfile request, CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int defaultResolutionId = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId)

View File

@@ -11,6 +11,7 @@ public record UpdateFFmpegProfile(
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,

View File

@@ -20,7 +20,7 @@ public class
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, p => ApplyUpdateRequest(dbContext, p, request));
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
@@ -33,6 +33,7 @@ public class
p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.VideoFormat = update.VideoFormat;
p.VideoBitrate = update.VideoBitrate;

View File

@@ -11,6 +11,7 @@ public record FFmpegProfileViewModel(
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
ResolutionViewModel Resolution,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,

View File

@@ -14,6 +14,7 @@ internal static class Mapper
profile.HardwareAcceleration,
profile.VaapiDriver,
profile.VaapiDevice,
profile.QsvExtraHardwareFrames,
Project(profile.Resolution),
profile.VideoFormat,
profile.VideoBitrate,
@@ -35,6 +36,27 @@ internal static class Mapper
ffmpegProfile.VideoFormat.ToString().ToLowerInvariant(),
ffmpegProfile.AudioFormat.ToString().ToLowerInvariant());
internal static FFmpegFullProfileResponseModel ProjectToFullResponseModel(FFmpegProfile ffmpegProfile) =>
new(
ffmpegProfile.Id,
ffmpegProfile.Name,
ffmpegProfile.ThreadCount,
(int)ffmpegProfile.HardwareAcceleration,
(int)ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
ffmpegProfile.ResolutionId,
(int)ffmpegProfile.VideoFormat,
ffmpegProfile.VideoBitrate,
ffmpegProfile.VideoBufferSize,
(int)ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudness,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
}

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetFFmpegFullProfileByIdForApi(int Id) : IRequest<Option<FFmpegFullProfileResponseModel>>;

View File

@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
GetFFmpegProfileByIdForApiHandler : IRequestHandler<GetFFmpegFullProfileByIdForApi,
Option<FFmpegFullProfileResponseModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFFmpegProfileByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<FFmpegFullProfileResponseModel>> Handle(
GetFFmpegFullProfileByIdForApi request,
CancellationToken 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)
.MapT(ProjectToFullResponseModel);
}
}

View File

@@ -69,7 +69,7 @@ public class
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
Command process = _ffmpegProcessService.ResizeImage(
Command process = await _ffmpegProcessService.ResizeImage(
ffmpegPath,
originalPath,
withExtension,

View File

@@ -1,7 +1,8 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.Libraries;
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ICachingSearchRepository _searchRepository;
public MoveLocalLibraryPathHandler(
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<MoveLocalLibraryPathHandler> logger)
{
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_dbContextFactory = dbContextFactory;
_logger = logger;
}
@@ -35,7 +39,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => MovePath(dbContext, parameters));
return await validation.Apply(parameters => MovePath(dbContext, parameters));
}
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
@@ -57,7 +61,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
foreach (MediaItem mediaItem in maybeMediaItem)
{
_logger.LogInformation("Moving item at {Path}", await GetPath(dbContext, mediaItem));
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
await _searchIndex.UpdateItems(
_searchRepository,
_fallbackMetadataProvider,
new List<MediaItem> { mediaItem });
}
}
}

View File

@@ -1,7 +1,8 @@
using System.Text.RegularExpressions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
@@ -17,12 +18,13 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
public AddTraktListHandler(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<AddTraktListHandler> logger,
IEntityLocker entityLocker)
: base(traktApiClient, searchRepository, searchIndex, logger)
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
{
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;

View File

@@ -1,7 +1,8 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
@@ -14,20 +15,23 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ICachingSearchRepository _searchRepository;
public DeleteTraktListHandler(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<DeleteTraktListHandler> logger,
IEntityLocker entityLocker)
: base(traktApiClient, searchRepository, searchIndex, logger)
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
{
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_fallbackMetadataProvider = fallbackMetadataProvider;
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;
}
@@ -38,8 +42,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
{
try
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
}
@@ -56,7 +59,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
dbContext.TraktLists.Remove(traktList);
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchIndex.RebuildItems(_searchRepository, mediaItemIds);
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, mediaItemIds);
}
_searchIndex.Commit();

View File

@@ -1,7 +1,8 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
@@ -18,11 +19,17 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
public MatchTraktListItemsHandler(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<MatchTraktListItemsHandler> logger,
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
IEntityLocker entityLocker) : base(
traktApiClient,
searchRepository,
searchIndex,
fallbackMetadataProvider,
logger)
{
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;

View File

@@ -1,6 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Trakt;
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.MediaCollections;
public abstract class TraktCommandBase
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ICachingSearchRepository _searchRepository;
protected TraktCommandBase(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
ILogger logger)
{
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_fallbackMetadataProvider = fallbackMetadataProvider;
_logger = logger;
TraktApiClient = traktApiClient;
}
@@ -158,7 +162,7 @@ public abstract class TraktCommandBase
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchIndex.RebuildItems(_searchRepository, ids.ToList());
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids.ToList());
}
_searchIndex.Commit();

View File

@@ -5,6 +5,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, playout => ApplyUpdateRequest(dbContext, request, playout));
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, BuildPlayout request, Playout playout)
@@ -45,7 +46,12 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
try
{
await _playoutBuilder.Build(playout, request.Mode);
if (await dbContext.SaveChangesAsync() > 0)
// let any active segmenter processes know that the playout has been modified
// and therefore the segmenter may need to seek into the next item instead of
// starting at the beginning (if already working ahead)
bool hasChanges = await dbContext.SaveChangesAsync() > 0;
if (request.Mode != PlayoutBuildMode.Continue && hasChanges)
{
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}

View File

@@ -26,6 +26,7 @@ public record AddProgramScheduleItem(
int? FallbackFillerId,
int? WatermarkId,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>,
IProgramScheduleItemRequest;

View File

@@ -24,6 +24,7 @@ public interface IProgramScheduleItemRequest
int? FallbackFillerId { get; }
int? WatermarkId { get; }
string PreferredAudioLanguageCode { get; }
string PreferredAudioTitle { get; }
string PreferredSubtitleLanguageCode { get; }
ChannelSubtitleMode? SubtitleMode { get; }
}

View File

@@ -180,6 +180,7 @@ public abstract class ProgramScheduleItemCommandBase
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId,
PreferredAudioLanguageCode = item.PreferredAudioLanguageCode,
PreferredAudioTitle = item.PreferredAudioTitle,
PreferredSubtitleLanguageCode = item.PreferredSubtitleLanguageCode,
SubtitleMode = item.SubtitleMode
},
@@ -203,6 +204,7 @@ public abstract class ProgramScheduleItemCommandBase
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId,
PreferredAudioLanguageCode = item.PreferredAudioLanguageCode,
PreferredAudioTitle = item.PreferredAudioTitle,
PreferredSubtitleLanguageCode = item.PreferredSubtitleLanguageCode,
SubtitleMode = item.SubtitleMode
},
@@ -227,6 +229,7 @@ public abstract class ProgramScheduleItemCommandBase
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId,
PreferredAudioLanguageCode = item.PreferredAudioLanguageCode,
PreferredAudioTitle = item.PreferredAudioTitle,
PreferredSubtitleLanguageCode = item.PreferredSubtitleLanguageCode,
SubtitleMode = item.SubtitleMode
},
@@ -252,6 +255,7 @@ public abstract class ProgramScheduleItemCommandBase
FallbackFillerId = item.FallbackFillerId,
WatermarkId = item.WatermarkId,
PreferredAudioLanguageCode = item.PreferredAudioLanguageCode,
PreferredAudioTitle = item.PreferredAudioTitle,
PreferredSubtitleLanguageCode = item.PreferredSubtitleLanguageCode,
SubtitleMode = item.SubtitleMode
},

View File

@@ -26,6 +26,7 @@ public record ReplaceProgramScheduleItem(
int? FallbackFillerId,
int? WatermarkId,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode) : IProgramScheduleItemRequest;

View File

@@ -63,6 +63,7 @@ internal static class Mapper
? Watermarks.Mapper.ProjectToViewModel(duration.Watermark)
: null,
duration.PreferredAudioLanguageCode,
duration.PreferredAudioTitle,
duration.PreferredSubtitleLanguageCode,
duration.SubtitleMode),
ProgramScheduleItemFlood flood =>
@@ -110,6 +111,7 @@ internal static class Mapper
? Watermarks.Mapper.ProjectToViewModel(flood.Watermark)
: null,
flood.PreferredAudioLanguageCode,
flood.PreferredAudioTitle,
flood.PreferredSubtitleLanguageCode,
flood.SubtitleMode),
ProgramScheduleItemMultiple multiple =>
@@ -158,6 +160,7 @@ internal static class Mapper
? Watermarks.Mapper.ProjectToViewModel(multiple.Watermark)
: null,
multiple.PreferredAudioLanguageCode,
multiple.PreferredAudioTitle,
multiple.PreferredSubtitleLanguageCode,
multiple.SubtitleMode),
ProgramScheduleItemOne one =>
@@ -205,6 +208,7 @@ internal static class Mapper
? Watermarks.Mapper.ProjectToViewModel(one.Watermark)
: null,
one.PreferredAudioLanguageCode,
one.PreferredAudioTitle,
one.PreferredSubtitleLanguageCode,
one.SubtitleMode),
_ => throw new NotSupportedException(

View File

@@ -30,6 +30,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark,
string preferredAudioLanguageCode,
string preferredAudioTitle,
string preferredSubtitleLanguageCode,
ChannelSubtitleMode? subtitleMode) : base(
id,
@@ -52,6 +53,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
fallbackFiller,
watermark,
preferredAudioLanguageCode,
preferredAudioTitle,
preferredSubtitleLanguageCode,
subtitleMode)
{

View File

@@ -28,6 +28,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark,
string preferredAudioLanguageCode,
string preferredAudioTitle,
string preferredSubtitleLanguageCode,
ChannelSubtitleMode? subtitleMode) : base(
id,
@@ -50,6 +51,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
fallbackFiller,
watermark,
preferredAudioLanguageCode,
preferredAudioTitle,
preferredSubtitleLanguageCode,
subtitleMode)
{

View File

@@ -29,6 +29,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark,
string preferredAudioLanguageCode,
string preferredAudioTitle,
string preferredSubtitleLanguageCode,
ChannelSubtitleMode? subtitleMode) : base(
id,
@@ -51,6 +52,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
fallbackFiller,
watermark,
preferredAudioLanguageCode,
preferredAudioTitle,
preferredSubtitleLanguageCode,
subtitleMode) =>
Count = count;

View File

@@ -28,6 +28,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
FillerPresetViewModel fallbackFiller,
WatermarkViewModel watermark,
string preferredAudioLanguageCode,
string preferredAudioTitle,
string preferredSubtitleLanguageCode,
ChannelSubtitleMode? subtitleMode) : base(
id,
@@ -50,6 +51,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
fallbackFiller,
watermark,
preferredAudioLanguageCode,
preferredAudioTitle,
preferredSubtitleLanguageCode,
subtitleMode)
{

View File

@@ -27,6 +27,7 @@ public abstract record ProgramScheduleItemViewModel(
FillerPresetViewModel FallbackFiller,
WatermarkViewModel Watermark,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode)
{

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using Humanizer;
using Microsoft.Extensions.Logging;
@@ -12,16 +13,18 @@ namespace ErsatzTV.Application.Search;
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Unit>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ICachingSearchRepository _searchRepository;
public RebuildSearchIndexHandler(
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
IFallbackMetadataProvider fallbackMetadataProvider,
ILogger<RebuildSearchIndexHandler> logger)
{
_searchIndex = searchIndex;
@@ -29,14 +32,19 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
_searchRepository = searchRepository;
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_fallbackMetadataProvider = fallbackMetadataProvider;
}
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
{
_logger.LogInformation("Initializing search index");
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
await _searchIndex.Initialize(_localFileSystem, _configElementRepository);
_logger.LogInformation("Done initializing search index");
if (!indexFolderExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
_searchIndex.Version)
@@ -44,7 +52,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
_logger.LogInformation("Migrating search index to version {Version}", _searchIndex.Version);
var sw = Stopwatch.StartNew();
await _searchIndex.Rebuild(_searchRepository);
await _searchIndex.Rebuild(_searchRepository, _fallbackMetadataProvider);
await _configElementRepository.Upsert(ConfigElementKey.SearchIndexVersion, _searchIndex.Version);
sw.Stop();

View File

@@ -3,10 +3,14 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
@@ -14,7 +18,9 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes, TelevisionEpisodeCardResultsViewModel>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
@@ -27,7 +33,9 @@ public class
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService)
IEmbyPathReplacementService embyPathReplacementService,
IFallbackMetadataProvider fallbackMetadataProvider,
IDbContextFactory<TvContext> dbContextFactory)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
@@ -35,6 +43,8 @@ public class
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
_fallbackMetadataProvider = fallbackMetadataProvider;
_dbContextFactory = dbContextFactory;
}
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
@@ -52,8 +62,40 @@ public class
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<EpisodeMetadata> episodes = await _televisionRepository
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList());
var episodeIds = searchResult.Items.Map(i => i.Id).ToList();
List<EpisodeMetadata> episodes = await _televisionRepository.GetEpisodesForCards(episodeIds);
// try to load fallback metadata for episodes that have none
// this handles an edge case of trashed items with no saved metadata
var missingEpisodes = episodeIds.Except(episodes.Map(e => e.EpisodeId)).ToList();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
foreach (int missingEpisodeId in missingEpisodes)
{
Option<Episode> maybeEpisode = await dbContext.Episodes
.AsNoTracking()
.Include(e => e.MediaVersions)
.ThenInclude(e => e.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.SelectOneAsync(e => e.Id, e => e.Id == missingEpisodeId);
foreach (Episode episode in maybeEpisode)
{
foreach (EpisodeMetadata headMetadata in _fallbackMetadataProvider.GetFallbackMetadata(episode)
.HeadOrNone())
{
headMetadata.Episode = episode;
episode.EpisodeMetadata = new List<EpisodeMetadata> { headMetadata };
episodes.Add(headMetadata);
}
}
}
var items = new List<TelevisionEpisodeCardViewModel>();

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using System.Diagnostics;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
@@ -78,22 +79,47 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
IHlsSessionWorker worker,
CancellationToken cancellationToken)
{
while (!File.Exists(playlistFileName))
var sw = Stopwatch.StartNew();
try
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
var segmentCount = 0;
while (segmentCount < initialSegmentCount)
{
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
Option<TrimPlaylistResult> maybeResult = await worker.TrimPlaylist(now, cancellationToken);
foreach (TrimPlaylistResult result in maybeResult)
_logger.LogDebug("Waiting for playlist to exist");
while (!File.Exists(playlistFileName))
{
segmentCount = result.SegmentCount;
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
_logger.LogDebug("Playlist exists");
var segmentCount = 0;
var lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
{
if (segmentCount != lastSegmentCount)
{
lastSegmentCount = segmentCount;
_logger.LogDebug(
"Segment count {SegmentCount} of {InitialSegmentCount}",
segmentCount,
initialSegmentCount);
}
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
Option<TrimPlaylistResult> maybeResult = await worker.TrimPlaylist(now, cancellationToken);
foreach (TrimPlaylistResult result in maybeResult)
{
segmentCount = result.SegmentCount;
}
}
}
finally
{
sw.Stop();
_logger.LogDebug("WaitForPlaylistSegments took {Duration}", sw.Elapsed);
}
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using System.Diagnostics;
using System.Text;
using System.Timers;
using Bugsnag;
using CliWrap;
@@ -27,7 +28,10 @@ public class HlsSessionWorker : IHlsSessionWorker
private readonly object _sync = new();
private string _channelNumber;
private bool _firstProcess;
private bool _hasWrittenSegments;
private DateTimeOffset _lastAccess;
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
private bool _seekNextItem;
private Option<int> _targetFramerate;
private Timer _timer;
private DateTimeOffset _transcodedUntil;
@@ -61,19 +65,38 @@ public class HlsSessionWorker : IHlsSessionWorker
DateTimeOffset filterBefore,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
await Slim.WaitAsync(cancellationToken);
try
{
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
return maybeLines.Map(input => _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input));
foreach (string[] input in maybeLines)
{
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
{
DeleteOldSegments(trimResult);
_lastDelete = DateTimeOffset.Now;
}
return trimResult;
}
return None;
}
finally
{
Slim.Release();
sw.Stop();
// _logger.LogDebug("TrimPlaylist took {Duration}", sw.Elapsed);
}
}
public void PlayoutUpdated() => _firstProcess = true;
public void PlayoutUpdated()
{
_firstProcess = true;
_seekNextItem = true;
}
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
{
@@ -190,7 +213,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, _firstProcess, cancellationToken);
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, cancellationToken);
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
var request = new GetPlayoutItemProcessByChannelNumber(
@@ -237,6 +260,13 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
_transcodedUntil = processModel.Until;
_firstProcess = false;
if (_seekNextItem)
{
_firstProcess = true;
_seekNextItem = false;
}
_hasWrittenSegments = true;
return true;
}
else
@@ -281,6 +311,14 @@ public class HlsSessionWorker : IHlsSessionWorker
if (commandResult.ExitCode == 0)
{
_firstProcess = false;
if (_seekNextItem)
{
_firstProcess = true;
_seekNextItem = false;
}
_hasWrittenSegments = true;
return true;
}
}
@@ -334,33 +372,7 @@ public class HlsSessionWorker : IHlsSessionWorker
lines);
await WritePlaylist(trimResult.Playlist, cancellationToken);
// delete old segments
var allSegments = Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
"live*.ts")
.Map(
file =>
{
string fileName = Path.GetFileName(file);
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
return new Segment(file, sequenceNumber);
})
.ToList();
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
// if (toDelete.Count > 0)
// {
// _logger.LogDebug(
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
// toDelete.Map(s => s.SequenceNumber).Min(),
// toDelete.Map(s => s.SequenceNumber).Max(),
// trimResult.Sequence);
// }
foreach (Segment segment in toDelete)
{
File.Delete(segment.File);
}
DeleteOldSegments(trimResult);
PlaylistStart = trimResult.PlaylistStart;
}
@@ -371,10 +383,40 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
private void DeleteOldSegments(TrimPlaylistResult trimResult)
{
// delete old segments
var allSegments = Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
"live*.ts")
.Map(
file =>
{
string fileName = Path.GetFileName(file);
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
return new Segment(file, sequenceNumber);
})
.ToList();
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
if (toDelete.Count > 0)
{
// _logger.LogDebug(
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
// toDelete.Map(s => s.SequenceNumber).Min(),
// toDelete.Map(s => s.SequenceNumber).Max(),
// trimResult.Sequence);
}
foreach (Segment segment in toDelete)
{
File.Delete(segment.File);
}
}
private async Task<long> GetPtsOffset(
IMediator mediator,
string channelNumber,
bool firstProcess,
CancellationToken cancellationToken)
{
await Slim.WaitAsync(cancellationToken);
@@ -382,8 +424,8 @@ public class HlsSessionWorker : IHlsSessionWorker
{
long result = 0;
// the first process always starts at zero
if (firstProcess)
// if we haven't yet written any segments, start at zero
if (!_hasWrittenSegments)
{
return result;
}

View File

@@ -30,7 +30,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Command process = _ffmpegProcessService.ConcatChannel(
Command process = await _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,

View File

@@ -33,7 +33,8 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(process, request.MaybeDuration, request.Until);
}

View File

@@ -27,6 +27,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISongVideoGenerator _songVideoGenerator;
private readonly ITelevisionRepository _televisionRepository;
@@ -42,6 +43,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ISongVideoGenerator songVideoGenerator,
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
@@ -54,6 +56,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_songVideoGenerator = songVideoGenerator;
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
_logger = logger;
}
@@ -96,6 +99,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(mv => mv.ArtistMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Subtitles)
.Include(i => i.MediaItem)
@@ -155,7 +161,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
List<Subtitle> subtitles = GetSubtitles(playoutItemWithPath);
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
Command process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
@@ -168,6 +174,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
audioPath,
subtitles,
playoutItemWithPath.PlayoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode,
playoutItemWithPath.PlayoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle,
playoutItemWithPath.PlayoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode,
playoutItemWithPath.PlayoutItem.SubtitleMode ?? channel.SubtitleMode,
playoutItemWithPath.PlayoutItem.StartOffset,
@@ -177,6 +184,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
request.HlsRealtime,
playoutItemWithPath.PlayoutItem.FillerKind,
playoutItemWithPath.PlayoutItem.InPoint,
@@ -223,7 +231,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish);
case PlayoutItemDoesNotExistOnDisk:
@@ -235,7 +244,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish);
default:
@@ -247,7 +257,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice);
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish);
}
@@ -256,22 +267,22 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
}
private static List<Subtitle> GetSubtitles(PlayoutItemWithPath playoutItemWithPath)
private async Task<List<Subtitle>> GetSubtitles(
PlayoutItemWithPath playoutItemWithPath,
Channel channel)
{
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
{
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.IfNoneAsync(new List<Subtitle>()),
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
.IfNoneAsync(new List<Subtitle>()),
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNone(new List<Subtitle>()),
.IfNoneAsync(new List<Subtitle>()),
_ => new List<Subtitle>()
};
@@ -309,6 +320,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return allSubtitles;
}
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
{
var subtitles = new List<Subtitle>();
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
if (musicVideoCredits)
{
subtitles.AddRange(
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
}
else
{
subtitles.AddRange(
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles)
.IfNoneAsync(new List<Subtitle>()));
}
return subtitles;
}
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
TvContext dbContext,
Channel channel,

View File

@@ -2,7 +2,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.FFmpeg.Runtime;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;

View File

@@ -7,30 +7,31 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.5.0" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.18.1" />
<PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,13 +10,17 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Runtime;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Serilog;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
namespace ErsatzTV.Core.Tests.FFmpeg;
@@ -165,6 +169,11 @@ public class TranscodingTests
HardwareAccelerationKind.VideoToolbox
};
public static HardwareAccelerationKind[] AmfAcceleration =
{
HardwareAccelerationKind.Amf
};
public static HardwareAccelerationKind[] QsvAcceleration =
{
HardwareAccelerationKind.Qsv
@@ -189,11 +198,11 @@ public class TranscodingTests
[ValueSource(typeof(TestData), nameof(TestData.VideoFormats))]
FFmpegProfileVideoFormat profileVideoFormat,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))]
HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.QsvAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.AmfAcceleration))] HardwareAccelerationKind profileAcceleration)
{
if (inputFormat.Encoder is "mpeg1video" or "msmpeg4v2" or "msmpeg4v3")
{
@@ -302,6 +311,11 @@ public class TranscodingTests
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<ITempFilePool>().Object,
new FakeNvidiaCapabilitiesFactory(),
// new HardwareCapabilitiesFactory(
// new MemoryCache(new MemoryCacheOptions()),
// LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
new RuntimeInfo(),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
var v = new MediaVersion
@@ -476,6 +490,7 @@ public class TranscodingTests
subtitles,
string.Empty,
string.Empty,
string.Empty,
subtitleMode,
now,
now + TimeSpan.FromSeconds(5),
@@ -484,6 +499,7 @@ public class TranscodingTests
channelWatermark,
VaapiDriver.Default,
"/dev/dri/renderD128",
Option<int>.None,
false,
FillerKind.None,
TimeSpan.Zero,
@@ -562,19 +578,26 @@ public class TranscodingTests
MediaVersion version,
StreamingMode streamingMode,
string channelNumber,
string preferredAudioLanguage) =>
string preferredAudioLanguage,
string preferredAudioTitle) =>
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
public Task<Option<Domain.Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Domain.Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode) =>
subtitles.HeadOrNone().AsTask();
}
private class FakeNvidiaCapabilitiesFactory : IHardwareCapabilitiesFactory
{
public Task<IHardwareCapabilities> GetHardwareCapabilities(
string ffmpegPath,
HardwareAccelerationMode hardwareAccelerationMode) =>
Task.FromResult<IHardwareCapabilities>(new NvidiaHardwareCapabilities(61, string.Empty));
}
private static string ExecutableName(string baseName) =>
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
}

View File

@@ -1,7 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Tests.Fakes;
@@ -42,8 +41,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
throw new NotSupportedException();
public Task<Either<BaseError, MediaItemScanResult<Show>>>
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
public Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(int libraryPathId, ShowMetadata metadata) =>
throw new NotSupportedException();
public Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber) =>
@@ -74,36 +72,11 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
PlexLibrary library,
PlexShow item) =>
public Task<bool> UpdateTitles(EpisodeMetadata metadata, string title, string sortTitle) =>
throw new NotSupportedException();
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
throw new NotSupportedException();
public Task<bool> UpdateOutline(EpisodeMetadata metadata, string outline) => throw new NotSupportedException();
public Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> GetOrAddPlexEpisode(
PlexLibrary library,
PlexEpisode item) =>
throw new NotSupportedException();
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
throw new NotSupportedException();
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
throw new NotSupportedException();
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexEpisode episode, string etag) => throw new NotSupportedException();
public Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) =>
throw new NotSupportedException();
public Task<bool> UpdatePlot(EpisodeMetadata metadata, string plot) => throw new NotSupportedException();
}

View File

@@ -1,7 +1,7 @@
using System.Runtime.InteropServices;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Core.Jellyfin;
using FluentAssertions;
using Microsoft.Extensions.Logging;
@@ -229,6 +229,38 @@ public class JellyfinPathReplacementServiceTests
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinLinux_To_EtvLinux_UncPath()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task Should_Not_Throw_For_Null_JellyfinPath()
{

View File

@@ -40,6 +40,13 @@ public class FallbackMetadataProviderTests
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
1,
2)]
[TestCase("Awesome Show - s01.e02.mkv", 1, 2)]
[TestCase("Awesome Show - S01.E02.mkv", 1, 2)]
[TestCase("Awesome Show - s01_e02.mkv", 1, 2)]
[TestCase("Awesome Show - S01_E02.mkv", 1, 2)]
[TestCase("Awesome Show - s01xe02.mkv", 1, 2)]
[TestCase("Awesome Show - S01XE02.mkv", 1, 2)]
[TestCase("Awesome Show - 1x02.mkv", 1, 2)]
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, int season, int episode)
{
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(

View File

@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Tests.Fakes;
@@ -622,7 +623,8 @@ public class MovieFolderScannerTests
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ICachingSearchRepository>().Object,
new Mock<IFallbackMetadataProvider>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
@@ -642,7 +644,8 @@ public class MovieFolderScannerTests
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ICachingSearchRepository>().Object,
new Mock<IFallbackMetadataProvider>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,

View File

@@ -1,7 +1,7 @@
using System.Runtime.InteropServices;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Core.Plex;
using FluentAssertions;
using Microsoft.Extensions.Logging;

View File

@@ -515,6 +515,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
@@ -523,6 +524,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemOne
{
Id = 2,
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
@@ -605,6 +607,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
@@ -613,6 +616,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemOne
{
Id = 2,
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
@@ -742,6 +746,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
@@ -750,6 +755,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemMultiple
{
Id = 2,
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
@@ -839,6 +845,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
@@ -847,6 +854,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemOne
{
Id = 2,
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
@@ -934,6 +942,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
@@ -942,6 +951,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemOne
{
Id = 2,
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
@@ -1040,6 +1050,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
@@ -1048,6 +1059,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemDuration
{
Id = 2,
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
@@ -1141,6 +1153,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = multipleCollection,
CollectionId = multipleCollection.Id,
@@ -1150,6 +1163,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemDuration
{
Id = 2,
Index = 2,
Collection = dynamicCollection,
CollectionId = dynamicCollection.Id,
@@ -2235,7 +2249,7 @@ public class PlayoutBuilderTests
DateTimeOffset start2 = HoursAfterMidnight(0);
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
Playout result2 = await builder.Build(playout, PlayoutBuildMode.Continue, start2, finish2);
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
@@ -2244,6 +2258,57 @@ public class PlayoutBuilderTests
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
}
[Test]
public async Task ShuffleFlood_Should_MaintainRandomSeed_MultipleDays()
{
var mediaItems = new List<MediaItem>();
for (int i = 1; i <= 25; i++)
{
mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i)));
}
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Shuffle);
DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5);
DateTimeOffset finish = start + TimeSpan.FromDays(2);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
result.Items.Count.Should().Be(53);
result.ProgramScheduleAnchors.Count.Should().Be(2);
result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue();
PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors
.OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue)
.First();
lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0);
lastCheckpoint.EnumeratorState.Index.Should().Be(3);
// we need to mess up the ordering to trigger the problematic behavior
// this simulates the way the rows are loaded with EF
PlayoutProgramScheduleAnchor oldest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).Last();
PlayoutProgramScheduleAnchor newest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).First();
result.ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>
{
oldest,
newest
};
int firstSeedValue = lastCheckpoint.EnumeratorState.Seed;
DateTimeOffset start2 = start.AddHours(1);
DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2);
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
PlayoutProgramScheduleAnchor continueAnchor =
result2.ProgramScheduleAnchors.First(x => x.AnchorDate is null);
int secondSeedValue = continueAnchor.EnumeratorState.Seed;
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
firstSeedValue.Should().Be(secondSeedValue);
}
[Test]
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
{
@@ -2278,6 +2343,7 @@ public class PlayoutBuilderTests
{
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
@@ -2286,6 +2352,7 @@ public class PlayoutBuilderTests
},
new ProgramScheduleItemOne
{
Id = 2,
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
@@ -2571,6 +2638,7 @@ public class PlayoutBuilderTests
private static ProgramScheduleItem Flood(Collection mediaCollection, PlaybackOrder playbackOrder) =>
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = mediaCollection,
CollectionId = mediaCollection.Id,

View File

@@ -85,6 +85,98 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
}
[Test]
public void Should_Schedule_Single_Item_Fixed_Start_Flood()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
var scheduleItem = new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = TimeSpan.Zero,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
CustomTitle = "CustomTitle"
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var sortedScheduleItems = new List<ProgramScheduleItem>
{
scheduleItem
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
sortedScheduleItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
scheduleItem,
HardStop(scheduleItemsEnumerator));
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(6));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); // one guide group here because of custom title
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeTrue();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
enumerator.State.Index.Should().Be(0);
playoutItems.Count.Should().Be(6);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].CustomTitle.Should().Be("CustomTitle");
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(1);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].CustomTitle.Should().Be("CustomTitle");
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(1);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].CustomTitle.Should().Be("CustomTitle");
playoutItems[3].MediaItemId.Should().Be(2);
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime.AddHours(3));
playoutItems[3].GuideGroup.Should().Be(1);
playoutItems[3].FillerKind.Should().Be(FillerKind.None);
playoutItems[3].CustomTitle.Should().Be("CustomTitle");
playoutItems[4].MediaItemId.Should().Be(1);
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime.AddHours(4));
playoutItems[4].GuideGroup.Should().Be(1);
playoutItems[4].FillerKind.Should().Be(FillerKind.None);
playoutItems[4].CustomTitle.Should().Be("CustomTitle");
playoutItems[5].MediaItemId.Should().Be(2);
playoutItems[5].StartOffset.Should().Be(startState.CurrentTime.AddHours(5));
playoutItems[5].GuideGroup.Should().Be(1);
playoutItems[5].FillerKind.Should().Be(FillerKind.None);
playoutItems[5].CustomTitle.Should().Be("CustomTitle");
}
[Test]
public void Should_Fill_Exactly_To_Next_Schedule_Item_Flood()

View File

@@ -0,0 +1,244 @@
using Dapper;
using Destructurama;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt.UnsafeValueAccess;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Serilog;
using Serilog.Events;
using Serilog.Extensions.Logging;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
[Explicit]
public class ScheduleIntegrationTests
{
public ScheduleIntegrationTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
.WriteTo.Console()
.Destructure.UsingAttributes()
.CreateLogger();
}
[Test]
public async Task Test()
{
string dbFileName = Path.GetTempFileName() + ".sqlite3";
IServiceCollection services = new ServiceCollection()
.AddLogging();
var connectionString = $"Data Source={dbFileName};foreign keys=true;";
services.AddDbContext<TvContext>(
options => options.UseSqlite(
connectionString,
o =>
{
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
o.MigrationsAssembly("ErsatzTV.Infrastructure");
}),
ServiceLifetime.Scoped,
ServiceLifetime.Singleton);
services.AddDbContextFactory<TvContext>(
options => options.UseSqlite(
connectionString,
o =>
{
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
o.MigrationsAssembly("ErsatzTV.Infrastructure");
}));
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
SqlMapper.AddTypeHandler(new GuidHandler());
SqlMapper.AddTypeHandler(new TimeSpanHandler());
services.AddSingleton((Func<IServiceProvider, ILoggerFactory>)(_ => new SerilogLoggerFactory()));
ServiceProvider provider = services.BuildServiceProvider();
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
logger.LogInformation("Database is at {File}", dbFileName);
await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None);
await dbContext.Database.MigrateAsync(CancellationToken.None);
await DbInitializer.Initialize(dbContext, CancellationToken.None);
var path = new LibraryPath
{
Path = "Test LibraryPath"
};
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await dbContext.Libraries.AddAsync(library);
await dbContext.SaveChangesAsync();
var movies = new List<Movie>();
for (var i = 1; i < 25; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(55) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Movie {i}",
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await dbContext.Movies.AddRangeAsync(movies);
await dbContext.SaveChangesAsync();
var collection = new Collection
{
Name = "Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await dbContext.Collections.AddAsync(collection);
await dbContext.SaveChangesAsync();
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Collection = collection,
CollectionId = collection.Id,
PlayoutDuration = TimeSpan.FromHours(1),
TailMode = TailMode.None, // immediately continue
PlaybackOrder = PlaybackOrder.Shuffle
}
};
int playoutId = await AddTestData(dbContext, scheduleItems);
DateTimeOffset start = new DateTimeOffset(2022, 7, 26, 8, 0, 5, TimeSpan.FromHours(-5));
DateTimeOffset finish = start.AddDays(2);
var builder = new PlayoutBuilder(
new ConfigElementRepository(factory),
new MediaCollectionRepository(new Mock<ISearchIndex>().Object, factory),
new TelevisionRepository(factory),
new ArtistRepository(factory),
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
for (var i = 0; i <= (24 * 4); i++)
{
await using TvContext context = await factory.CreateDbContextAsync();
Option<Playout> maybePlayout = await GetPlayout(context, playoutId);
Playout playout = maybePlayout.ValueUnsafe();
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
await context.SaveChangesAsync();
}
}
private static async Task<int> AddTestData(TvContext dbContext, List<ProgramScheduleItem> scheduleItems)
{
var ffmpegProfile = new FFmpegProfile
{
Name = "Test FFmpeg Profile"
};
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
await dbContext.SaveChangesAsync();
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
{
Name = "Test Channel",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await dbContext.Channels.AddAsync(channel);
await dbContext.SaveChangesAsync();
var schedule = new ProgramSchedule
{
Name = "Test Schedule",
Items = scheduleItems
};
await dbContext.ProgramSchedules.AddAsync(schedule);
await dbContext.SaveChangesAsync();
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id
};
await dbContext.Playouts.AddAsync(playout);
await dbContext.SaveChangesAsync();
return playout.Id;
}
private static async Task<Option<Playout>> GetPlayout(TvContext dbContext, int playoutId)
{
return await dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.Items)
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.MediaItem)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PreRollFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MidRollFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PostRollFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.TailFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.FallbackFiller)
.SelectOneAsync(p => p.Id, p => p.Id == playoutId);
}
}

View File

@@ -0,0 +1,21 @@
namespace ErsatzTV.Core.Api.FFmpegProfiles;
public record FFmpegFullProfileResponseModel(
int Id,
string Name,
int ThreadCount,
int HardwareAcceleration,
int VaapiDriver,
string VaapiDevice,
int ResolutionId,
int VideoFormat,
int VideoBitrate,
int VideoBufferSize,
int AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool? DeinterlaceVideo);

View File

@@ -23,6 +23,8 @@ public class Channel
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelMusicVideoCreditsMode
{
None = 0,
GenerateSubtitles = 1
}

View File

@@ -6,6 +6,7 @@ public class ConfigElementKey
public string Key { get; }
public static ConfigElementKey MinimumLogLevel => new("log.minimum_level");
public static ConfigElementKey FFmpegPath => new("ffmpeg.ffmpeg_path");
public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path");
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");

View File

@@ -10,6 +10,7 @@ public record FFmpegProfile
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int? QsvExtraHardwareFrames { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
@@ -42,6 +43,7 @@ public record FFmpegProfile
AudioSampleRate = 48,
DeinterlaceVideo = true,
NormalizeFramerate = false,
HardwareAcceleration = HardwareAccelerationKind.None
HardwareAcceleration = HardwareAccelerationKind.None,
QsvExtraHardwareFrames = 64
};
}

View File

@@ -6,5 +6,6 @@ public enum HardwareAccelerationKind
Qsv = 1,
Nvenc = 2,
Vaapi = 3,
VideoToolbox = 4
VideoToolbox = 4,
Amf = 5
}

View File

@@ -3,5 +3,7 @@
public enum SubtitleKind
{
Embedded = 0,
Sidecar = 1
Sidecar = 1,
Generated = 99
}

View File

@@ -24,6 +24,7 @@ public class PlayoutItem
public int? WatermarkId { get; set; }
public bool DisableWatermarks { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode? SubtitleMode { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();

View File

@@ -35,6 +35,7 @@ public abstract class ProgramScheduleItem
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode? SubtitleMode { get; set; }
}

View File

@@ -1,6 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using Microsoft.Extensions.Logging;
@@ -10,21 +12,24 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
{
private readonly IEmbyApiClient _embyApiClient;
private readonly IEmbyCollectionRepository _embyCollectionRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<EmbyCollectionScanner> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ICachingSearchRepository _searchRepository;
public EmbyCollectionScanner(
IEmbyCollectionRepository embyCollectionRepository,
IEmbyApiClient embyApiClient,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
ILogger<EmbyCollectionScanner> logger)
{
_embyCollectionRepository = embyCollectionRepository;
_embyApiClient = embyApiClient;
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_fallbackMetadataProvider = fallbackMetadataProvider;
_logger = logger;
}
@@ -107,7 +112,7 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, changedIds);
_searchIndex.Commit();
}
catch (Exception ex)

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using MediatR;
@@ -25,7 +26,8 @@ public class EmbyMovieLibraryScanner :
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IEmbyMovieRepository embyMovieRepository,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
@@ -38,6 +40,7 @@ public class EmbyMovieLibraryScanner :
mediator,
searchIndex,
searchRepository,
fallbackMetadataProvider,
logger)
{
_embyApiClient = embyApiClient;

View File

@@ -2,7 +2,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.FFmpeg.Runtime;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Emby;

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using MediatR;
@@ -24,7 +25,8 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
IMediaSourceRepository mediaSourceRepository,
IEmbyTelevisionRepository televisionRepository,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
@@ -37,6 +39,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
localFileSystem,
searchRepository,
searchIndex,
fallbackMetadataProvider,
mediator,
logger)
{

View File

@@ -7,23 +7,24 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
<PackageReference Include="Flurl" Version="3.0.6" />
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
<PackageReference Include="LanguageExt.Transformers" Version="4.2.9" />
<PackageReference Include="MediatR" Version="11.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -169,6 +169,7 @@ public class FFmpegComplexFilterBuilder
_videoDecoder.Contains("cuvid")),
HardwareAccelerationKind.Qsv => !isSong,
HardwareAccelerationKind.VideoToolbox => false,
HardwareAccelerationKind.Amf => false,
_ => false
};
@@ -200,6 +201,7 @@ public class FFmpegComplexFilterBuilder
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None &&
acceleration != HardwareAccelerationKind.VideoToolbox &&
acceleration != HardwareAccelerationKind.Amf &&
!isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome);

View File

@@ -3,9 +3,11 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
@@ -16,6 +18,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
private readonly IRuntimeInfo _runtimeInfo;
private readonly ILogger<FFmpegLibraryProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
private readonly ITempFilePool _tempFilePool;
@@ -25,12 +29,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FFmpegPlaybackSettingsCalculator playbackSettingsCalculator,
IFFmpegStreamSelector ffmpegStreamSelector,
ITempFilePool tempFilePool,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
IRuntimeInfo runtimeInfo,
ILogger<FFmpegLibraryProcessService> logger)
{
_ffmpegProcessService = ffmpegProcessService;
_playbackSettingsCalculator = playbackSettingsCalculator;
_ffmpegStreamSelector = ffmpegStreamSelector;
_tempFilePool = tempFilePool;
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
_runtimeInfo = runtimeInfo;
_logger = logger;
}
@@ -45,6 +53,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string audioPath,
List<Subtitle> subtitles,
string preferredAudioLanguage,
string preferredAudioTitle,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode,
DateTimeOffset start,
@@ -54,6 +63,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<ChannelWatermark> globalWatermark,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames,
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
@@ -68,13 +78,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
audioVersion,
channel.StreamingMode,
channel.Number,
preferredAudioLanguage);
preferredAudioLanguage,
preferredAudioTitle);
Option<Subtitle> maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream(
videoVersion,
subtitles,
channel.StreamingMode,
channel.Number,
channel,
preferredSubtitleLanguage,
subtitleMode);
@@ -138,6 +147,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoStream.Codec,
AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger),
new FrameSize(videoVersion.Width, videoVersion.Height),
videoVersion.SampleAspectRatio,
videoVersion.DisplayAspectRatio,
videoVersion.RFrameRate,
videoPath != audioPath); // still image when paths are different
@@ -204,9 +215,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false, // TODO: fallback filler needs to loop
videoFormat,
desiredPixelFormat,
await playbackSettings.ScaledSize.Map(ss => new FrameSize(ss.Width, ss.Height))
.IfNoneAsync(new FrameSize(videoVersion.Width, videoVersion.Height)),
ffmpegVideoStream.SquarePixelFrameSize(
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)),
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height),
false,
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
@@ -216,6 +228,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var ffmpegState = new FFmpegState(
saveReports,
hwAccel,
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
playbackSettings.StreamSeek,
@@ -228,11 +241,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsPlaylistPath,
hlsSegmentTemplate,
ptsOffset,
playbackSettings.ThreadCount);
playbackSettings.ThreadCount,
qsvExtraHardwareFrames);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
var pipelineBuilder = new PipelineBuilder(
_runtimeInfo,
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
videoInputFile,
audioInputFile,
watermarkInputFile,
@@ -254,7 +270,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
bool hlsRealtime,
long ptsOffset,
VaapiDriver vaapiDriver,
string vaapiDevice)
string vaapiDevice,
Option<int> qsvExtraHardwareFrames)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateErrorSettings(
channel.StreamingMode,
@@ -298,6 +315,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
new PixelFormatYuv420P(),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
false,
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
@@ -325,6 +343,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
VideoFormat.GeneratedImage,
new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
new FrameSize(videoVersion.Width, videoVersion.Height),
videoVersion.SampleAspectRatio,
videoVersion.DisplayAspectRatio,
None,
true);
@@ -335,6 +355,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var ffmpegState = new FFmpegState(
false,
hwAccel,
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
playbackSettings.StreamSeek,
@@ -347,7 +368,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsPlaylistPath,
hlsSegmentTemplate,
ptsOffset,
Option<int>.None);
Option<int>.None,
qsvExtraHardwareFrames);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@@ -361,6 +383,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState);
var pipelineBuilder = new PipelineBuilder(
_runtimeInfo,
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
videoInputFile,
audioInputFile,
None,
@@ -374,7 +398,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline);
}
public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
public async Task<Command> ConcatChannel(
string ffmpegPath,
bool saveReports,
Channel channel,
string scheme,
string host)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
@@ -383,6 +412,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
resolution);
var pipelineBuilder = new PipelineBuilder(
_runtimeInfo,
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
None,
None,
None,
@@ -401,13 +432,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host);
public Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
{
var videoInputFile = new VideoInputFile(
inputFile,
new List<VideoStream> { new(0, string.Empty, None, FrameSize.Unknown, None, true) });
new List<VideoStream> { new(0, string.Empty, None, FrameSize.Unknown, string.Empty, string.Empty, None, true) });
var pipelineBuilder = new PipelineBuilder(
_runtimeInfo,
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
videoInputFile,
None,
None,
@@ -484,6 +517,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
"unknown",
new PixelFormatUnknown(),
new FrameSize(1, 1),
string.Empty,
string.Empty,
Option<string>.None,
!options.IsAnimated)
},
@@ -588,7 +623,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
}
private static Option<string> VaapiDeviceName(HardwareAccelerationMode accelerationMode, string vaapiDevice) =>
accelerationMode == HardwareAccelerationMode.Vaapi ? vaapiDevice : Option<string>.None;
accelerationMode == HardwareAccelerationMode.Vaapi ||
OperatingSystem.IsLinux() && accelerationMode == HardwareAccelerationMode.Qsv
? vaapiDevice
: Option<string>.None;
private static string GetVideoFormat(FFmpegPlaybackSettings playbackSettings) =>
playbackSettings.VideoFormat switch
@@ -607,6 +645,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
HardwareAccelerationKind.Qsv => HardwareAccelerationMode.Qsv,
HardwareAccelerationKind.Vaapi => HardwareAccelerationMode.Vaapi,
HardwareAccelerationKind.VideoToolbox => HardwareAccelerationMode.VideoToolbox,
HardwareAccelerationKind.Amf => HardwareAccelerationMode.Amf,
_ => HardwareAccelerationMode.None
};
}

View File

@@ -148,14 +148,11 @@ public class FFmpegPlaybackSettingsCalculator
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
audioStream.IfSome(
stream =>
{
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
foreach (MediaStream _ in audioStream)
{
// this can be optimized out later, depending on the audio codec
result.AudioChannels = ffmpegProfile.AudioChannels;
}
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint;

View File

@@ -28,13 +28,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
MediaVersion version,
StreamingMode streamingMode,
string channelNumber,
string preferredAudioLanguage)
string preferredAudioLanguage,
string preferredAudioTitle)
{
if (streamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(preferredAudioLanguage))
string.IsNullOrWhiteSpace(preferredAudioLanguage) && string.IsNullOrWhiteSpace(preferredAudioTitle))
{
_logger.LogDebug(
"Channel {Number} is HLS Direct with no preferred audio language; using all audio streams",
"Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams",
channelNumber);
return None;
}
@@ -71,34 +72,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
if (correctLanguage.Any())
{
_logger.LogDebug(
"Found {Count} audio streams with preferred audio language code(s) {Code}; selecting stream with most channels",
"Found {Count} audio streams with preferred audio language code(s) {Code}",
correctLanguage.Count,
allCodes);
return correctLanguage.OrderByDescending(s => s.Channels).Head();
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty);
}
_logger.LogDebug(
"Unable to find audio stream with preferred audio language code(s) {Code}; selecting stream with most channels",
"Unable to find audio stream with preferred audio language code(s) {Code}",
allCodes);
return audioStreams.OrderByDescending(s => s.Channels).HeadOrNone();
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty);
}
public async Task<Option<Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode)
{
if (channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles &&
subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle)
{
_logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number);
return Optional(generatedSubtitle);
}
if (subtitleMode == ChannelSubtitleMode.None)
{
return None;
}
if (streamingMode == StreamingMode.HttpLiveStreamingDirect &&
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(preferredSubtitleLanguage))
{
// _logger.LogDebug(
@@ -110,7 +116,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channelNumber);
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number);
}
else
{
@@ -152,10 +158,40 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
_logger.LogDebug(
"Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}",
channelNumber,
channel.Number,
subtitleMode,
preferredSubtitleLanguage);
return None;
}
private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title)
{
// return correctLanguage.OrderByDescending(s => s.Channels).Head();
if (string.IsNullOrWhiteSpace(title))
{
_logger.LogDebug("No audio title has been specified; selecting stream with most channels");
return streams.OrderByDescending(s => s.Channels).Head();
}
// prioritize matching titles
var matchingTitle = streams
.Filter(ms => (ms.Title ?? string.Empty).Contains(title, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matchingTitle.Any())
{
_logger.LogDebug(
"Found {Count} audio streams with preferred title {Title}; selecting stream with most channels",
matchingTitle.Count,
title);
return matchingTitle.OrderByDescending(s => s.Channels).Head();
}
_logger.LogDebug(
"Unable to find audio stream with preferred title {Title}; selecting stream with most channels",
title);
return streams.OrderByDescending(s => s.Channels).Head();
}
}

View File

@@ -0,0 +1,84 @@
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.FFmpeg;
public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator
{
private readonly ITempFilePool _tempFilePool;
public MusicVideoCreditsGenerator(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
public async Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile)
{
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
var fontSize = (int)Math.Round(ffmpegProfile.Resolution.Height / 20.0);
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
var verticalMargin =
(int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * ffmpegProfile.Resolution.Height);
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata)
{
var sb = new StringBuilder();
string artist = string.Empty;
foreach (ArtistMetadata artistMetadata in Optional(metadata.MusicVideo?.Artist?.ArtistMetadata).Flatten())
{
artist = artistMetadata.Title;
}
if (!string.IsNullOrWhiteSpace(artist))
{
sb.Append(artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"\\N\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.Append($"\\N{metadata.Album}");
}
string subtitles = await new SubtitleBuilder(_tempFilePool)
.WithResolution(ffmpegProfile.Resolution)
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H444444")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
.WithMarginV(verticalMargin)
.WithBorderStyle(1)
.WithShadow(3)
.WithFormattedContent(sb.ToString())
.WithStartEnd(TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(16))
.WithFade(true)
.BuildFile();
return new Subtitle
{
Codec = "ass",
Default = true,
Forced = true,
IsExtracted = false,
SubtitleKind = SubtitleKind.Generated,
Path = subtitles,
SDH = false
};
}
return None;
}
}

View File

@@ -9,6 +9,8 @@ public class SubtitleBuilder
private Option<int> _alignment;
private Option<int> _borderStyle;
private string _content;
private Option<TimeSpan> _end;
private bool _fade;
private Option<string> _fontName;
private Option<int> _fontSize;
private int _marginLeft;
@@ -18,6 +20,7 @@ public class SubtitleBuilder
private Option<string> _primaryColor;
private Option<IDisplaySize> _resolution = None;
private Option<int> _shadow;
private Option<TimeSpan> _start;
public SubtitleBuilder(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
@@ -93,6 +96,19 @@ public class SubtitleBuilder
return this;
}
public SubtitleBuilder WithStartEnd(TimeSpan start, TimeSpan end)
{
_start = start;
_end = end;
return this;
}
public SubtitleBuilder WithFade(bool fade)
{
_fade = fade;
return this;
}
public async Task<string> BuildFile()
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
@@ -116,15 +132,24 @@ public class SubtitleBuilder
sb.AppendLine(
$"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
var start = "0:00:00.00";
foreach (TimeSpan startTime in _start)
{
start = $"{(int)startTime.TotalHours:00}:{startTime.ToString(@"mm\:ss\.ff")}";
}
var end = "99:99:99.99";
foreach (TimeSpan endTime in _end)
{
end = $"{(int)endTime.TotalHours:00}:{endTime.ToString(@"mm\:ss\.ff")}";
}
string fade = _fade ? @"{\fad(1200, 1200)}" : string.Empty;
sb.AppendLine("[Events]");
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
sb.AppendLine(
$"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
if (!string.IsNullOrWhiteSpace(_content))
{
sb.AppendLine(_content);
}
@$"Dialogue: 0,{start},{end},Default,,{_marginLeft},{_marginRight},{_marginV},,{fade}{_content}");
await File.WriteAllTextAsync(fileName, sb.ToString());

View File

@@ -19,6 +19,7 @@ public interface IFFmpegProcessService
string audioPath,
List<Subtitle> subtitles,
string preferredAudioLanguage,
string preferredAudioTitle,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode,
DateTimeOffset start,
@@ -28,6 +29,7 @@ public interface IFFmpegProcessService
Option<ChannelWatermark> globalWatermark,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames,
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
@@ -44,13 +46,14 @@ public interface IFFmpegProcessService
bool hlsRealtime,
long ptsOffset,
VaapiDriver vaapiDriver,
string vaapiDevice);
string vaapiDevice,
Option<int> qsvExtraHardwareFrames);
Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile);

View File

@@ -10,13 +10,12 @@ public interface IFFmpegStreamSelector
MediaVersion version,
StreamingMode streamingMode,
string channelNumber,
string preferredAudioLanguage);
string preferredAudioLanguage,
string preferredAudioTitle);
Task<Option<Subtitle>> SelectSubtitleStream(
MediaVersion version,
List<Subtitle> subtitles,
StreamingMode streamingMode,
string channelNumber,
Channel channel,
string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode);
}

View File

@@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IMusicVideoCreditsGenerator
{
Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile);
}

View File

@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Interfaces.Repositories.Caching;
public interface ICachingSearchRepository : ISearchRepository
{
}

View File

@@ -19,6 +19,8 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
Task<Unit> SetEtag(TSeason season, string etag);
Task<Unit> SetEtag(TEpisode episode, string etag);
Task<bool> FlagNormal(TLibrary library, TEpisode episode);
Task<bool> FlagNormal(TLibrary library, TSeason season);
Task<bool> FlagNormal(TLibrary library, TShow show);
Task<List<int>> FlagFileNotFoundShows(TLibrary library, List<string> showItemIds);
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);

View File

@@ -22,12 +22,7 @@ public interface ITelevisionRepository
Task<int> GetEpisodeCount(int seasonId);
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
int libraryPathId,
string showFolder,
ShowMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(int libraryPathId, ShowMetadata metadata);
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
@@ -42,5 +37,7 @@ public interface ITelevisionRepository
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
Task<bool> UpdateTitles(EpisodeMetadata metadata, string title, string sortTitle);
Task<bool> UpdateOutline(EpisodeMetadata metadata, string outline);
Task<bool> UpdatePlot(EpisodeMetadata metadata, string plot);
}

View File

@@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Search;
namespace ErsatzTV.Core.Interfaces.Search;
@@ -9,9 +10,18 @@ public interface ISearchIndex : IDisposable
{
public int Version { get; }
Task<bool> Initialize(ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository);
Task<Unit> Rebuild(ISearchRepository searchRepository);
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> Rebuild(ICachingSearchRepository searchRepository, IFallbackMetadataProvider fallbackMetadataProvider);
Task<Unit> RebuildItems(
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
List<int> itemIds);
Task<Unit> UpdateItems(
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
void Commit();

View File

@@ -76,8 +76,10 @@ public class ChannelGuide
}
}
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(kvp => kvp.Key.Number))
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(
kvp => decimal.Parse(kvp.Key.Number)))
{
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
@@ -89,7 +91,7 @@ public class ChannelGuide
{
PlayoutItem startItem = sorted[i];
int j = i;
while (j + 1 < sorted.Count && sorted[j].FillerKind != FillerKind.None)
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
@@ -97,7 +99,7 @@ public class ChannelGuide
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = i;
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup)
{
finishIndex++;

View File

@@ -1,6 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using Microsoft.Extensions.Logging;
@@ -8,23 +10,26 @@ namespace ErsatzTV.Core.Jellyfin;
public class JellyfinCollectionScanner : IJellyfinCollectionScanner
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinCollectionRepository _jellyfinCollectionRepository;
private readonly ILogger<JellyfinCollectionScanner> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ICachingSearchRepository _searchRepository;
public JellyfinCollectionScanner(
IJellyfinCollectionRepository jellyfinCollectionRepository,
IJellyfinApiClient jellyfinApiClient,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
ISearchIndex searchIndex,
IFallbackMetadataProvider fallbackMetadataProvider,
ILogger<JellyfinCollectionScanner> logger)
{
_jellyfinCollectionRepository = jellyfinCollectionRepository;
_jellyfinApiClient = jellyfinApiClient;
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_fallbackMetadataProvider = fallbackMetadataProvider;
_logger = logger;
}
@@ -116,7 +121,7 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, changedIds);
_searchIndex.Commit();
}
catch (Exception ex)

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using MediatR;
@@ -24,7 +25,8 @@ public class JellyfinMovieLibraryScanner :
ISearchIndex searchIndex,
IMediator mediator,
IJellyfinMovieRepository jellyfinMovieRepository,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem,
@@ -38,6 +40,7 @@ public class JellyfinMovieLibraryScanner :
mediator,
searchIndex,
searchRepository,
fallbackMetadataProvider,
logger)
{
_jellyfinApiClient = jellyfinApiClient;

View File

@@ -2,7 +2,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.FFmpeg.Runtime;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Jellyfin;

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using MediatR;
@@ -25,7 +26,8 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
IMediaSourceRepository mediaSourceRepository,
IJellyfinTelevisionRepository televisionRepository,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
IJellyfinPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
@@ -38,6 +40,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
localFileSystem,
searchRepository,
searchIndex,
fallbackMetadataProvider,
mediator,
logger)
{

View File

@@ -161,8 +161,14 @@ public class FallbackMetadataProvider : IFallbackMetadataProvider
try
{
const string PATTERN = @"[sS]\d+[eE]([e\-\d{1,2}]+)";
const string PATTERN = @"[sS]\d+[\._xX]?[eE]([e\-\d{1,2}]+)";
const string PATTERN_2 = @"\d+[\._xX]([e\-\d{1,2}]+)";
MatchCollection matches = Regex.Matches(fileName, PATTERN);
if (matches.Count == 0)
{
matches = Regex.Matches(fileName, PATTERN_2);
}
if (matches.Count > 0)
{
foreach (Match match in matches)

View File

@@ -33,8 +33,17 @@ public class LocalFileSystem : ILocalFileSystem
return Unit.Default;
}
public DateTime GetLastWriteTime(string path) =>
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => SystemTime.MinValueUtc);
public DateTime GetLastWriteTime(string path)
{
try
{
return File.GetLastWriteTimeUtc(path);
}
catch
{
return SystemTime.MinValueUtc;
}
}
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
Directory.Exists(libraryPath.Path);

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