Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2ceeddee | ||
|
|
c7aad4689e | ||
|
|
c0c37d8987 | ||
|
|
45627ede82 | ||
|
|
8c9bd680a4 | ||
|
|
46e2fbf0f7 | ||
|
|
9ad0c4a393 | ||
|
|
bbfc9e720a | ||
|
|
e58bb9af21 | ||
|
|
aa6d8eae4c | ||
|
|
f1e97b94a7 | ||
|
|
5034941a79 | ||
|
|
0d301df5e8 | ||
|
|
d26ae336cb | ||
|
|
875069b927 | ||
|
|
fd86cb55f9 | ||
|
|
0c30c47ba9 | ||
|
|
08cbf59527 | ||
|
|
a91de68a5c | ||
|
|
3e3bfbd5f5 | ||
|
|
31b07305ef | ||
|
|
49adcf7c37 | ||
|
|
c0b8ff1a06 | ||
|
|
c6d538e012 | ||
|
|
3dbde17f68 | ||
|
|
794d209941 | ||
|
|
7b9197d48d | ||
|
|
2ad6547349 | ||
|
|
4fa11b6943 | ||
|
|
440d9f708e | ||
|
|
4d469ec8fd | ||
|
|
a77a2d56ae | ||
|
|
240a329526 | ||
|
|
45e7d61676 | ||
|
|
93811876e0 | ||
|
|
607d9b0662 | ||
|
|
f47134d2d0 | ||
|
|
ae13db981d | ||
|
|
b7cc8499a3 | ||
|
|
36147b9e9c | ||
|
|
bf8c821012 | ||
|
|
a0f5d8d5d5 | ||
|
|
f1072b70c7 | ||
|
|
e10b28bc0b | ||
|
|
cd2bb0f2e0 | ||
|
|
e80f687612 | ||
|
|
317ca1967c | ||
|
|
b86f45844c | ||
|
|
353f029452 | ||
|
|
1754e7d5fb | ||
|
|
f96be8f99f | ||
|
|
08ceb53b2b | ||
|
|
3d81f760ee | ||
|
|
4ce87feac1 | ||
|
|
f217ba185b | ||
|
|
e925bd6913 | ||
|
|
3f4c9e063b | ||
|
|
7f361d1ea9 | ||
|
|
35d24ffea6 | ||
|
|
a2d023ee69 | ||
|
|
36f44f14bb | ||
|
|
ccb917d0df | ||
|
|
343a4619a6 | ||
|
|
e167c9318c | ||
|
|
de230f92db | ||
|
|
974020a98f | ||
|
|
da957c9377 | ||
|
|
b72d150775 | ||
|
|
b0b7bd17b3 | ||
|
|
1f2f04f3bd | ||
|
|
5bc90bb245 | ||
|
|
f73a32ec13 | ||
|
|
748ed1cf71 | ||
|
|
f2deaa6f7a | ||
|
|
3698fa5b7d | ||
|
|
dc92cb4ac3 | ||
|
|
69410b1a9b | ||
|
|
4aee03e066 | ||
|
|
e16d6c67f1 | ||
|
|
5d8877975d | ||
|
|
367305d960 | ||
|
|
aa08ad5765 | ||
|
|
40c6c504fe | ||
|
|
933b6530e4 | ||
|
|
885330f8c5 | ||
|
|
effb96a2c2 | ||
|
|
cc521326d9 | ||
|
|
5c42609527 | ||
|
|
a96ef328a5 | ||
|
|
89bb3759de | ||
|
|
12f2583c96 | ||
|
|
7c82ecdfff | ||
|
|
38343e3ea2 | ||
|
|
bcd2ea7db3 | ||
|
|
80f6e468eb | ||
|
|
474e647d6d | ||
|
|
daff1c6533 | ||
|
|
14d2dd0c3a | ||
|
|
c606319030 | ||
|
|
1b72b8491c | ||
|
|
9fea25a77d | ||
|
|
74b049b6e3 | ||
|
|
b2caf8ee8d | ||
|
|
b582b4cbf7 | ||
|
|
0af81ad839 | ||
|
|
2f0cd1eb6c | ||
|
|
e4f1a93db0 | ||
|
|
6562d616fb | ||
|
|
d8122edad6 | ||
|
|
99b8c56a31 | ||
|
|
09858df654 | ||
|
|
038286c92b | ||
|
|
8575ab5c32 | ||
|
|
8b768a2990 | ||
|
|
f9e4c4d386 | ||
|
|
a1f9b86fc1 | ||
|
|
5dc20ebd1b | ||
|
|
d30e8b4102 | ||
|
|
c14f373f23 | ||
|
|
a90fe26d50 | ||
|
|
7a263ddaed | ||
|
|
3e0a9aae1e | ||
|
|
72dc401829 | ||
|
|
85e25ca6ea | ||
|
|
9c23b03758 | ||
|
|
e12888ebee | ||
|
|
468ff087d4 | ||
|
|
54606c76f9 | ||
|
|
6bd49ffcec | ||
|
|
c524bc0d7d | ||
|
|
42bcadf936 | ||
|
|
1f31beab5b | ||
|
|
b45c22092d | ||
|
|
7bd8cefe2e | ||
|
|
f101d0b366 | ||
|
|
73aabdabda | ||
|
|
bcea96d53a | ||
|
|
d7952e4cfa | ||
|
|
758399e339 | ||
|
|
6c635a4be9 | ||
|
|
9d637cdd54 | ||
|
|
cc287ffc6e | ||
|
|
371659c5c5 | ||
|
|
7afb1866ad | ||
|
|
7bc1dd63fe | ||
|
|
076b8a7188 | ||
|
|
ec0d8ea6ac | ||
|
|
e40d192aea | ||
|
|
bd7fd8984c | ||
|
|
2682912f5a | ||
|
|
505e135482 | ||
|
|
fdf1e70e0d | ||
|
|
5c51710e2f | ||
|
|
3cb84c2491 | ||
|
|
21f4439aa4 | ||
|
|
d88e721d2f | ||
|
|
b6d509b9cd | ||
|
|
6603500132 | ||
|
|
48b1aa3e64 | ||
|
|
42b35f7aae | ||
|
|
8b18f2a304 | ||
|
|
1e0bba0dc6 | ||
|
|
3984bc7dbe | ||
|
|
e0977fa65b | ||
|
|
d9c668c7f6 | ||
|
|
dcea6d474f | ||
|
|
ba37c6dabe | ||
|
|
d652372f78 | ||
|
|
e2d8dee8cd | ||
|
|
d93c404607 | ||
|
|
bc400de94c | ||
|
|
b9a73226a8 | ||
|
|
d0505cd5c5 | ||
|
|
d9cdbc72de | ||
|
|
2b0079fedc | ||
|
|
9d2cff53c5 | ||
|
|
ac361b3165 | ||
|
|
dd9317e3e8 | ||
|
|
7530c592ff | ||
|
|
132466b3d3 | ||
|
|
d709cc9f21 | ||
|
|
5083e748ed | ||
|
|
053b3cd1d7 | ||
|
|
c3c7ff2669 | ||
|
|
e6824cf251 | ||
|
|
d87561d140 | ||
|
|
f79fa9a50a | ||
|
|
629b3d7d9f | ||
|
|
453737a521 | ||
|
|
dd38ba19ea | ||
|
|
8e2a15296f | ||
|
|
d2cbfcb79a | ||
|
|
89133255d3 | ||
|
|
c6245bae0c | ||
|
|
2912e71c10 | ||
|
|
9e54d42e5f | ||
|
|
63f342e6a7 | ||
|
|
5a7c59d602 | ||
|
|
4822ba5486 | ||
|
|
b24617fe7c | ||
|
|
5045a411b1 | ||
|
|
425fb34317 | ||
|
|
e5ef9be09c | ||
|
|
e9338b534b | ||
|
|
191e694545 | ||
|
|
727a978689 | ||
|
|
7a133d46da | ||
|
|
b1fbf651a2 | ||
|
|
0dbdcc3674 |
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: ersatztv
|
||||
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
|
||||
---
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
|
||||
```
|
||||
|
||||
### Read Endpoints (GET)
|
||||
```
|
||||
/api/channels # List channels
|
||||
/api/collections # List collections
|
||||
/api/schedules # List schedules
|
||||
/api/playouts # List playouts
|
||||
/api/shows # List shows
|
||||
/api/movies # List movies
|
||||
/api/artists # List artists
|
||||
/api/search # Search items
|
||||
/api/ffmpeg/profiles # FFmpeg profiles
|
||||
/api/watermarks # Watermarks
|
||||
/iptv/channels.m3u # M3U playlist (for Jellyfin)
|
||||
/iptv/xmltv.xml # XMLTV guide data
|
||||
```
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
POST /api/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
POST /api/libraries/{id}/scan-show \
|
||||
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
|
||||
|
||||
# Reset channel playout (rebuilds schedule)
|
||||
POST /api/channels/{channelNumber}/playout/reset
|
||||
```
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
# Read queries (safe while running, WAL mode)
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
|
||||
# Write queries — stop container first
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
docker start ersatztv
|
||||
```
|
||||
|
||||
### Key Queries
|
||||
```sql
|
||||
-- List channels
|
||||
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
|
||||
|
||||
-- List collections with item counts
|
||||
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
|
||||
|
||||
-- List schedules
|
||||
SELECT Id, Name FROM ProgramSchedule;
|
||||
|
||||
-- Playout (channel-schedule links)
|
||||
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
|
||||
|
||||
-- Media counts
|
||||
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
|
||||
|
||||
-- Jellyfin source
|
||||
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
|
||||
|
||||
-- Library sync status
|
||||
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
|
||||
```
|
||||
|
||||
### Channel Setup Workflow (DB)
|
||||
|
||||
**Show-specific channel** (single TV show, shuffled):
|
||||
```sql
|
||||
-- 1. Schedule
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
|
||||
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3. Channel
|
||||
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
|
||||
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
|
||||
-- 4. Playout
|
||||
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
|
||||
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
|
||||
```
|
||||
|
||||
**Collection-based channel** (multiple shows, shuffled):
|
||||
```sql
|
||||
-- 1. Collection + items (MediaItemId = Show.Id)
|
||||
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
|
||||
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
|
||||
-- 3-4. Channel + Playout same as show-specific
|
||||
```
|
||||
|
||||
After creating: `POST /api/channels/{number}/playout/reset`
|
||||
|
||||
## Volume Mounts (matches Jellyfin)
|
||||
|
||||
| Host Path | Container Path |
|
||||
|-----------|---------------|
|
||||
| `~/downloadswarm/ersatztv` | `/config` |
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
|
||||
| `/mnt/episodes` | `/data/episodes` (ro) |
|
||||
| `/mnt/media/movies` | `/data/movies` (ro) |
|
||||
| `/mnt/media/standup` | `/data/standup` (ro) |
|
||||
| `/mnt/media/music_videos` | `/data/music` (ro) |
|
||||
|
||||
## FFmpeg & Hardware
|
||||
|
||||
- QSV (Intel Quick Sync) hardware acceleration
|
||||
- Resolution: 1920x1080, H264, AAC stereo
|
||||
- Device: `/dev/dri` passed through
|
||||
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
|
||||
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
|
||||
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
|
||||
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
|
||||
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
|
||||
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
|
||||
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
|
||||
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
|
||||
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: jellyfin
|
||||
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
|
||||
---
|
||||
|
||||
# Jellyfin Management
|
||||
|
||||
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
|
||||
API Token: `978033be716d46678a5d3c54ae0e0ff9`
|
||||
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
|
||||
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
|
||||
|
||||
## Access Pattern
|
||||
|
||||
```bash
|
||||
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
|
||||
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Host Path | Container Path | Content |
|
||||
|-----------|---------------|---------|
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
|
||||
| `/mnt/episodes` | `/data/episodes` | More episodes |
|
||||
| `/mnt/media/movies` | `/data/movies` | Movies |
|
||||
| `/mnt/media/standup` | `/data/standup` | Standup |
|
||||
| `/mnt/media/music_videos` | `/data/music` | Music videos |
|
||||
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### System
|
||||
```
|
||||
GET /System/Info # Server info, version
|
||||
GET /System/Info/Public # Public info (no auth needed)
|
||||
POST /System/Restart # Restart server
|
||||
```
|
||||
|
||||
### Items (Search & Browse)
|
||||
```bash
|
||||
# Search items
|
||||
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
|
||||
|
||||
# Get item details
|
||||
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
|
||||
|
||||
# Get all movies
|
||||
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
|
||||
|
||||
# Get series
|
||||
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
|
||||
|
||||
# Get episodes for a series
|
||||
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
|
||||
|
||||
# Filter by library (parentId)
|
||||
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
|
||||
```
|
||||
|
||||
### Libraries
|
||||
```
|
||||
GET /Library/VirtualFolders # List all libraries
|
||||
POST /Library/Refresh # Trigger full library scan
|
||||
POST /Items/{id}/Refresh # Refresh single item metadata
|
||||
```
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
# Test stream URL
|
||||
GET /Videos/{itemId}/stream?static=true
|
||||
|
||||
# Get playback info
|
||||
GET /Items/{itemId}/PlaybackInfo
|
||||
```
|
||||
|
||||
### Users
|
||||
```
|
||||
GET /Users # List users
|
||||
GET /Users/{userId} # User details
|
||||
```
|
||||
|
||||
## Library IDs
|
||||
|
||||
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
|
||||
|
||||
## Live TV
|
||||
|
||||
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
|
||||
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
|
||||
- Configured in Jellyfin Admin > Live TV
|
||||
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
|
||||
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
|
||||
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
|
||||
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
|
||||
- Music videos are typed as "Movie" in Jellyfin
|
||||
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
|
||||
- Items return 404 on stream if source volume is unmounted
|
||||
- Jellyfin preserves item IDs across restarts unless files are renamed
|
||||
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
|
||||
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
|
||||
@@ -3,11 +3,11 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.2.3",
|
||||
"version": "2025.3.0.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
name: Build ErsatzTV Image
|
||||
|
||||
# Builds the fork's own amd64 image and pushes it to the Gitea container registry.
|
||||
# pull_request -> test job only (no image build/push)
|
||||
# push to main -> :latest + :<short-sha> (test image; does NOT touch prod)
|
||||
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
|
||||
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
|
||||
#
|
||||
# Runner + registry provisioned in server-management#172. The Gitea registry is
|
||||
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
|
||||
# inherit the host daemon's insecure-registries setting).
|
||||
#
|
||||
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
|
||||
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Single runner on jazz: serialize all runs so the push-main-then-tag release
|
||||
# flow can't collide on the shared :buildcache tag or the smoke container.
|
||||
concurrency:
|
||||
group: ersatztv-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
IMAGE: 192.168.1.95:3000/timothy/ersatztv
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version and tags
|
||||
id: meta
|
||||
run: |
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
INFO_VERSION="${VERSION}"
|
||||
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
|
||||
else
|
||||
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
|
||||
INFO_VERSION="${DESC#v}-${SHORT}"
|
||||
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
|
||||
fi
|
||||
echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<__EOT__"
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
echo "__EOT__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "INFO_VERSION=${INFO_VERSION}"
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."192.168.1.95:3000"]
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
# only publish from main or a v* tag; other refs (e.g. branch dispatch) build only
|
||||
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
provenance: false
|
||||
build-args: |
|
||||
INFO_VERSION=${{ steps.meta.outputs.info_version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
- name: Smoke test (HTTP readiness)
|
||||
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
docker run -d --name "$NAME" --memory 2g \
|
||||
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
|
||||
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
|
||||
"$IMG"
|
||||
# probe ErsatzTV's web server from inside the container (image ships python3)
|
||||
cat > probe.py <<'PY'
|
||||
import urllib.request, urllib.error, sys
|
||||
try:
|
||||
urllib.request.urlopen("http://localhost:8409/", timeout=3)
|
||||
except urllib.error.HTTPError:
|
||||
pass # any HTTP status means the server is serving
|
||||
except Exception:
|
||||
sys.exit(1) # not listening yet
|
||||
PY
|
||||
ok=0
|
||||
for _ in $(seq 1 60); do
|
||||
if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then
|
||||
echo "Container exited early"; break
|
||||
fi
|
||||
if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then
|
||||
ok=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "===== container logs (tail) ====="
|
||||
docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
echo "================================="
|
||||
if [ "$ok" != "1" ]; then
|
||||
echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409"
|
||||
exit 1
|
||||
fi
|
||||
echo "Smoke test passed: ErsatzTV served HTTP on :8409"
|
||||
@@ -1,2 +0,0 @@
|
||||
github: jasongdove
|
||||
custom: "https://www.paypal.me/jasongdove"
|
||||
@@ -3,9 +3,9 @@ contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://features.ersatztv.org
|
||||
about: Features
|
||||
- name: Discord
|
||||
url: https://discord.ersatztv.org
|
||||
about: Chat
|
||||
- name: Contact
|
||||
url: https://ersatztv.org/contact
|
||||
about: Chat Options
|
||||
- name: Community
|
||||
url: https://discuss.ersatztv.org
|
||||
about: Forum
|
||||
|
||||
@@ -12,7 +12,7 @@ body:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
|
||||
options:
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://discord.ersatztv.org) first to troubleshoot with volunteers before creating a report.
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/nvidia"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/vaapi"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
@@ -1,324 +0,0 @@
|
||||
name: Build Artifacts
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag'
|
||||
required: true
|
||||
type: string
|
||||
release_version:
|
||||
description: 'Release version number (e.g. v0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
info_version:
|
||||
description: 'Informational version number (e.g. 0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64:
|
||||
required: true
|
||||
apple_developer_certificate_password:
|
||||
required: true
|
||||
ac_username:
|
||||
required: true
|
||||
ac_password:
|
||||
required: true
|
||||
gh_token:
|
||||
required: true
|
||||
jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-14
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target}}"
|
||||
|
||||
- name: Import Code-Signing Certificates
|
||||
uses: Apple-Actions/import-codesign-certs@v2
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
|
||||
p12-password: ${{ secrets.apple_developer_certificate_password }}
|
||||
|
||||
- name: Calculate Release Name
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
brew install coreutils
|
||||
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
|
||||
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
|
||||
scripts/macOS/bundle.sh
|
||||
|
||||
- name: Sign
|
||||
shell: bash
|
||||
run: scripts/macOS/sign.sh
|
||||
|
||||
- name: Create DMG
|
||||
shell: bash
|
||||
run: |
|
||||
brew install create-dmg
|
||||
create-dmg \
|
||||
--volname "ErsatzTV" \
|
||||
--volicon "artwork/ErsatzTV.icns" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "ErsatzTV.app" 200 190 \
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
--no-internet-enable \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
|
||||
xcrun stapler staple ErsatzTV.dmg
|
||||
|
||||
- name: Cleanup
|
||||
shell: bash
|
||||
run: |
|
||||
mv ErsatzTV.dmg "${{ env.RELEASE_NAME }}.dmg"
|
||||
rm -r publish
|
||||
rm -r ErsatzTV.app
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*${{ matrix.target }}.dmg"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.dmg"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
build_and_upload_linux:
|
||||
name: Build & Upload Linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-musl-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: ubuntu-24.04-arm
|
||||
kind: linux
|
||||
target: linux-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
# Define some variables for things we need
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*${{ matrix.target }}.tar.gz"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.tar.gz"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
build_dotnet_windows:
|
||||
name: Build dotnet for Windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "win-x64"
|
||||
|
||||
- name: Build dotnet projects
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Upload .NET Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: |
|
||||
scanner/
|
||||
main/
|
||||
retention-days: 1
|
||||
|
||||
build_rust_windows:
|
||||
name: Build rust for Windows
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Windows Launcher
|
||||
shell: bash
|
||||
run: cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
|
||||
- name: Upload Rust Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rust-windows-build
|
||||
path: ErsatzTV-Windows/target/release/ersatztv_windows.exe
|
||||
retention-days: 1
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_dotnet_windows, build_rust_windows]
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Download rust artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rust-windows-build
|
||||
path: rust-build
|
||||
|
||||
- name: Download ffmpeg
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
id: downloadffmpeg
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
mkdir "$release_name"
|
||||
|
||||
mv dotnet-build/scanner/* "$release_name/"
|
||||
mv dotnet-build/main/* "$release_name/"
|
||||
|
||||
# dotnet shouldn't copy the resources here, but it does
|
||||
rm -rf "$release_name/Resources"
|
||||
|
||||
mv rust-build/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*win-x64.zip"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.zip"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2}-${short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag}-${short}"
|
||||
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: develop
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: develop
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ github.sha }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
@@ -1,141 +0,0 @@
|
||||
name: Build & Publish to Docker Hub
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base_version:
|
||||
description: 'Base version (latest or develop)'
|
||||
required: true
|
||||
type: string
|
||||
info_version:
|
||||
description: 'Informational version number (e.g. 0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
tag_version:
|
||||
description: 'Docker tag version (e.g. v0.3.7)'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
docker_hub_username:
|
||||
required: true
|
||||
docker_hub_access_token:
|
||||
required: true
|
||||
jobs:
|
||||
build_images:
|
||||
name: Build ${{ matrix.name }} image
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: amd64
|
||||
os: ubuntu-latest
|
||||
path: ''
|
||||
suffix: '-amd64'
|
||||
platform: 'linux/amd64'
|
||||
- name: arm32v7
|
||||
os: ubuntu-latest
|
||||
path: 'arm32v7/'
|
||||
suffix: '-arm'
|
||||
platform: 'linux/arm/v7'
|
||||
- name: arm64
|
||||
os: ubuntu-24.04-arm
|
||||
path: 'arm64/'
|
||||
suffix: '-arm64'
|
||||
platform: 'linux/arm64'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
outputs: |
|
||||
type=image,name=jasongdove/ersatztv,name-canonical=true,push-by-digest=true
|
||||
type=image,name=ghcr.io/ersatztv/ersatztv,name-canonical=true,push-by-digest=true
|
||||
|
||||
- name: Save digest to artifact
|
||||
run: echo ${{ steps.build.outputs.digest }} > digest.txt
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.name }}
|
||||
path: digest.txt
|
||||
|
||||
merge_manifests:
|
||||
name: Merge Manifests
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_images
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download all digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: digests/
|
||||
|
||||
- name: Read digests from artifacts
|
||||
id: digests
|
||||
run: |
|
||||
AMD64_HASH=$(cat digests/digest-amd64/digest.txt)
|
||||
ARM32V7_HASH=$(cat digests/digest-arm32v7/digest.txt)
|
||||
ARM64_HASH=$(cat digests/digest-arm64/digest.txt)
|
||||
|
||||
DOCKER_HUB_DIGESTS="jasongdove/ersatztv@${AMD64_HASH} jasongdove/ersatztv@${ARM64_HASH} jasongdove/ersatztv@${ARM32V7_HASH}"
|
||||
GHCR_DIGESTS="ghcr.io/ersatztv/ersatztv@${AMD64_HASH} ghcr.io/ersatztv/ersatztv@${ARM64_HASH} ghcr.io/ersatztv/ersatztv@${ARM32V7_HASH}"
|
||||
|
||||
echo "docker_hub_digests=${DOCKER_HUB_DIGESTS}" >> $GITHUB_OUTPUT
|
||||
echo "ghcr_digests=${GHCR_DIGESTS}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
docker manifest create jasongdove/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.docker_hub_digests }}
|
||||
docker manifest push jasongdove/ersatztv:${{ inputs.base_version }}
|
||||
docker manifest create jasongdove/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.docker_hub_digests }}
|
||||
docker manifest push jasongdove/ersatztv:${{ inputs.tag_version }}
|
||||
|
||||
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.ghcr_digests }}
|
||||
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
|
||||
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.ghcr_digests }}
|
||||
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
|
||||
@@ -1,27 +0,0 @@
|
||||
name: 'Close stale issues'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
|
||||
stale-issue-label: 'stale'
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
@@ -1,98 +0,0 @@
|
||||
name: Pull Request
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build_and_test_windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
cd ErsatzTV-Windows
|
||||
cargo build --release --all-features
|
||||
build_and_test_linux:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
target: linux-musl-x64
|
||||
- os: ubuntu-latest
|
||||
target: linux-arm
|
||||
- os: ubuntu-24.04-arm
|
||||
target: linux-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -p:RestoreEnablePackagePruning=true -r "${{ matrix.target }}"
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build ErsatzTV/ErsatzTV.csproj --runtime "${{ matrix.target }}" --configuration Release --no-restore && dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "ARTIFACTS_VERSION=${tag}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
docker_tag: ${{ env.DOCKER_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: latest
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ needs.calculate_version.outputs.docker_tag }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
@@ -3,6 +3,9 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docker-mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-docker"
|
||||
],
|
||||
"env": {
|
||||
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
|
||||
}
|
||||
},
|
||||
"ssh-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=192.168.1.99",
|
||||
"--user=timothy"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"gitea": {
|
||||
"command": "gitea-mcp-server",
|
||||
"args": [
|
||||
"-t", "stdio",
|
||||
"-host", "http://192.168.1.95:3000",
|
||||
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"csharp-lsp": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"run",
|
||||
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
|
||||
"-c", "Release"
|
||||
],
|
||||
"env": {
|
||||
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
},
|
||||
"nuget": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"dnx",
|
||||
"NuGet.Mcp.Server",
|
||||
"--source", "https://api.nuget.org/v3/index.json",
|
||||
"--yes"
|
||||
],
|
||||
"env": {
|
||||
"DOTNET_ROOT": "/usr/local/share/dotnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+324
-8
@@ -4,6 +4,317 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
- Remove BugSnag error reporting integration
|
||||
- Remove developer's personal Trakt API key
|
||||
- Users who want to continue to use Trakt must create an API app and set the `Client ID` as the environment variable `TRAKT__CLIENTID`
|
||||
|
||||
### Fixed
|
||||
- Support adding trakt lists using `app.trakt.tv` domain (instead of just `trakt.tv`)
|
||||
|
||||
## [26.3.0] - 2026-02-24
|
||||
### Added
|
||||
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
|
||||
- Log messages will include media item id, channel number and transcoding speed
|
||||
- Add UI language setting to **Settings** > **UI**
|
||||
- A small number of translations have been added for `Português (Brasil)` and `Polski`
|
||||
- Translation contributions are always welcome!
|
||||
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
|
||||
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
|
||||
- Block schedule info includes block, block item, playback order, random seed, collection index
|
||||
- E.g. items with the same random seed are part of the same shuffle
|
||||
- Add channel setting `Slug Seconds`
|
||||
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
|
||||
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
|
||||
- If this feature turns out to be popular, methods to correct the drift may be investigated
|
||||
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
|
||||
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
|
||||
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
|
||||
|
||||
### Changed
|
||||
- Move dark/light mode toggle to **Settings** > **UI**
|
||||
- Use latest (non-deprecated) authorization method with Jellyfin API
|
||||
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
|
||||
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
|
||||
|
||||
### Fixed
|
||||
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
|
||||
- Fix Trakt list sync
|
||||
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
|
||||
- This only applies to content that *might* be problematic (using a heuristic)
|
||||
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
|
||||
- Graphics engine: fix stream seek value used throughout graphics engine
|
||||
- This should fix loading EPG data when used with chapters/mid-roll
|
||||
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
|
||||
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
|
||||
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
|
||||
|
||||
## [26.2.0] - 2026-02-02
|
||||
### Added
|
||||
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
|
||||
- en-US can match sunday using `day_of_week = 0`
|
||||
- fr-FR can match sunday using `day_of_week = 6`
|
||||
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
|
||||
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
|
||||
- Add `Pad Mode` to ffmpeg profile. Options are:
|
||||
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
|
||||
- `Software` - force software padding
|
||||
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
|
||||
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
|
||||
- Add API endpoint to clean artwork cache folder (on demand)
|
||||
- POST `/api/maintenance/clean_artwork`
|
||||
- Add health check to warn about unsupported empty (classic) schedules
|
||||
- Add health check to warn about incompatible ffmpeg due to missing filters
|
||||
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
|
||||
- `ffmpeg@7` or `ffmpeg-full` should be used instead
|
||||
- Add `Marathon Group By` option `Director`
|
||||
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
|
||||
- This is supported in classic schedules and sequential schedules
|
||||
- Add FFmpeg Profile options:
|
||||
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
|
||||
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
|
||||
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
|
||||
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
|
||||
- Add chapter `title` to filler expression
|
||||
- This can be used to include or exclude chapters with specific (case-insensitive) titles
|
||||
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
|
||||
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
|
||||
|
||||
### Changed
|
||||
- Disable automatic artwork database cleanup
|
||||
- This will be re-enabled at some point in the future (after more testing)
|
||||
- For now, the API should be used to clean as needed
|
||||
- Classic Schedules: make multiple `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the collection
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the collection
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
|
||||
### Fixed
|
||||
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
|
||||
- Graphics engine:
|
||||
- Respect `z_index` (draw order) on all graphics element types
|
||||
- Fix bug with `z_index` sorting
|
||||
- Restore default UI font that was erroneously removed in v26.1.1
|
||||
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
|
||||
- Use configured searching log level on startup, instead of the default log level of `Information`
|
||||
- MySql: fix searching for shows and seasons in schedule items editor
|
||||
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
|
||||
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
|
||||
- Use other video artwork in XMLTV template
|
||||
- Properly update (add or remove) artwork for all local media libraries when files have changed
|
||||
- Sync Plex library name changes
|
||||
- Sync Plex episode title, plot, year, date added, release date, episode number changes
|
||||
- Sync Jellyfin and Emby library name and type changes
|
||||
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
|
||||
- Fix some sequential and scripted playout build failures when using playlists or marathons
|
||||
- Fix erasing playout items and history so all related data is also erased
|
||||
- This includes rerun history, unscheduled gaps, build status
|
||||
- Fix indexing collections when using Elasticsearch backend
|
||||
|
||||
## [26.1.1] - 2026-01-08
|
||||
### Fixed
|
||||
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
|
||||
|
||||
### Changed
|
||||
- Optimize database check for orphaned artwork
|
||||
- Include web resources (CSS, JS) locally instead of relying on CDNs
|
||||
|
||||
## [26.1.0] - 2026-01-06
|
||||
### Added
|
||||
- Graphics Engine:
|
||||
- Add `script` graphics element type
|
||||
- Supported in playback troubleshooting and all scheduling types
|
||||
- Supports arbitrary scripts or executables that output graphics to ETV via stdout
|
||||
- Supports EPG and Media Item replacement in entire template
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- All template data will also be passed as JSON to the stdin stream of the command
|
||||
- Template supports:
|
||||
- Script and arguments (`command` and `args`)
|
||||
- Draw order (`z_index`)
|
||||
- Timing (`start_seconds` and `duration_seconds`)
|
||||
- Data format (`format`)
|
||||
- `raw` format means full frames of BGRA data to stdout
|
||||
- `packet` format means ETV graphics packets to stdout
|
||||
- Add framerate template data
|
||||
- `RFrameRate` - the real content framerate (or channel normalized framerate) as reported by ffmpeg, e.g. `30000/1001`
|
||||
- `FrameRate` - the decimal representation of `RFrameRate`, e.g. `29.97002997`
|
||||
- Add `Channel_StartTime` template data
|
||||
- This indicates the time that the transcode session started for the current channel
|
||||
- Add remote stream metadata
|
||||
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
|
||||
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
|
||||
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
|
||||
- Add `Download Media Sample` button to playback troubleshooting
|
||||
- This button will extract up to 30 seconds of the media item and zip it
|
||||
- Add `Target Loudness` (LUFS/LKFS) to ffmpeg profile when loudness normalization is enabled
|
||||
- Default value is `-16`; some sources normalize to a quieter value, e.g. `-24`
|
||||
- Add environment variables to help troubleshoot performance
|
||||
- `ETV_SLOW_DB_MS` - milliseconds threshold for logging slow database queries (at DEBUG level)
|
||||
- e.g. if this is set to `1000`, queries taking longer than 1 second will be logged
|
||||
- `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level)
|
||||
- This is currently limited to *Jellyfin*
|
||||
- `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10
|
||||
- `ETV_JF_ENABLE_STATS` - enables logging timing information related to Jellyfin show library scans
|
||||
- Add `Select All` button to media pages by @Erotemic
|
||||
|
||||
### Fixed
|
||||
- Fix startup on systems unsupported by NvEncSharp
|
||||
- Fix detection of Plex Other Video libraries using `Plex Personal Media` agent
|
||||
- If the library is already detected as a Movies library in ETV, synchronization must be disabled for the library to change it to an Other Videos library
|
||||
- A warning will be logged when this scenario is detected
|
||||
- Graphics Engine:
|
||||
- Optimize graphics engine to generate element frames in parallel and to eliminate redundant frame copies
|
||||
- Match graphics engine framerate with source content (or channel normalized) framerate
|
||||
- Fix loading requested number of epg entries for motion graphics elements
|
||||
- Fix bug with mirror channels where seemingly random content would be played every ~40 seconds
|
||||
- Fix chronological sorting for Other Videos that have release date metadata
|
||||
- Fix playout sorting after using channel number editor
|
||||
- VAAPI: Only include `-sei a53_cc` flags when misc packed headers are supported by the encoder
|
||||
- This should fix playback in some cases, e.g. AMD VAAPI h264 encoder
|
||||
- AMD VAAPI:
|
||||
- work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
|
||||
- fix green padding when encoding h264 using main profile
|
||||
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
|
||||
- Fix playback of certain BT.2020 content
|
||||
- Use playlist item count when using a playlist as filler (instead of a fixed count of 1 for each playlist item)
|
||||
- NVIDIA:
|
||||
- Fix stream failure with certain content that should decode in hardware but falls back to software
|
||||
- Fix stream failure with content that changes color metadata mid-stream
|
||||
- Fix stream failure when configured fallback filler collection is empty
|
||||
- Fix high CPU when errors are displayed; errors will now work ahead before throttling to realtime, similar to primary content
|
||||
- Fix startup error caused by duplicate smart collection names (and no longer allow duplicate smart collection names)
|
||||
- Fix erroneous downgrade health check failure with some installations that use MariaDB
|
||||
- Sequential schedules: fix `count` instruction validation to accept integer (constant) or string (expression)
|
||||
- Fix multi-part episode grouping logic so that it does NOT require release date metadata for episodes within a single show
|
||||
- When **Treat Collections As Shows** is enabled (i.e. for crossover episodes) release date metadata is required for proper grouping
|
||||
- Fix *many* cases of duplicate names; enforce case-insensitive unique names at the db schema level
|
||||
- Fix playback when using `ETV_BASE_URL` by @JamesDearlove
|
||||
|
||||
### Changed
|
||||
- No longer round framerate to nearest integer when normalizing framerate
|
||||
- Allow playlists to have no items included in EPG
|
||||
- Change how fallback filler works
|
||||
- Items will no longer loop; instead, a sequence of random items will be selected from the collection
|
||||
- Items may still be cut as needed
|
||||
- Hardware acceleration will now be used
|
||||
- Items can "work ahead" (transcode faster than realtime) when less than 3 minutes in duration
|
||||
- Optimize Jellyfin database fields and indexes
|
||||
- Optimize Jellyfin show library scans by only requesting `People` (actors, directors, writers) when etags don't match
|
||||
- This should significantly speed up periodic library scans, particularly against Jellyfin 10.11.x
|
||||
- Lazy load media item images in UI
|
||||
- Align alternate schedule and template handling (between classic schedules and block schedules)
|
||||
- Both systems now support limiting to a date range
|
||||
- This date range can be repeating (when year is not specified for start or end dates)
|
||||
- This date range can be exact (when year is specified for start and end dates)
|
||||
|
||||
## [25.9.0] - 2025-11-29
|
||||
### Added
|
||||
- Show playout warnings count badge in left menu
|
||||
- Graphics Engine:
|
||||
- Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution)
|
||||
- Add `MediaItem_Start` template data (DateTimeOffset)
|
||||
- Add `MediaItem_Stop` template data (DateTimeOffset)
|
||||
- Add `ScaledResolution` template data (the final size of the frame before padding)
|
||||
- Add `place_within_source_content` (true/false) field to image graphics element
|
||||
- Add `name` field to all graphics elements to display in the UI
|
||||
- Classic and block schedules: add collection type `Search Query`
|
||||
- This allows defining search queries directly on schedule items without creating smart collections beforehand
|
||||
- As an example, this can be used to filter or combine existing smart collections
|
||||
- Filter: `smart_collection:"sd movies" AND plot:"christmas"`
|
||||
- Combine: `smart_collection:"old commercials" OR smart_collection:"nick promos"`
|
||||
- Scripted schedules: add `custom_title` to `start_epg_group`
|
||||
- Add MPEG-TS Script system
|
||||
- This allows using something other than ffmpeg (e.g. streamlink) to concatenate segments back together when using MPEG-TS streaming mode
|
||||
- Scripts live in config / scripts / mpegts
|
||||
- Each script gets its own subfolder which contains an `mpegts.yml` definition and corresponding windows (batch) and linux (bash) scripts
|
||||
- The global MPEG-TS script can be configured in **Settings** > **FFmpeg** > **Default MPEG-TS Script**
|
||||
- Add `.avs` AviSynth Script support to all local libraries
|
||||
- `.avs` was added as a valid extension, so they should behave the same any other video file
|
||||
- There are two requirements for AviSynth Scripts to work:
|
||||
- FFmpeg needs to be compiled with AviSynth support (not currently available in Docker)
|
||||
- AviSynth itself needs to be installed
|
||||
- Add `Troubleshoot` button to classic schedule list
|
||||
- This generates JSON representing the entire schedule which can be shared when requested for troubleshooting
|
||||
- Add **Settings** > **FFmpeg** > **Probe For Interlaced Frames**
|
||||
- When enabled, this will probe *local content* for interlaced frames on demand (immediately before playback)
|
||||
- This will be used as a more accurate check for interlaced content
|
||||
- The result will be cached (only probed once and stored) in the database along with all other media item statistics (e.g. duration)
|
||||
- This feature will currently ignore content that is not streamed from disk
|
||||
- Add error/offline background customization
|
||||
- Default error background is now named `_background.png`
|
||||
- Error streams will prioritize using `background.png` if it exists
|
||||
- Replacing this `background.png` file will allow custom error/offline backgrounds
|
||||
- Add `Troubleshoot Playback` buttons on movie and episode detail pages
|
||||
- Add song background and missing album art customization
|
||||
- Default files start with an underscore; custom versions must remove the underscore
|
||||
- Expose arbitrary EPG data to graphics engine via channel guide templates
|
||||
- XML nodes using the `etv:` namespace will be passed to the graphics engine EPG template data
|
||||
- For example, adding `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine
|
||||
- All values parsed from XMLTV will be available as strings in the graphics engine (not numbers)
|
||||
- All `etv:` nodes will be stripped from the XMLTV data when requested by a client
|
||||
- Add channel troubleshooting button to channels list
|
||||
- This will open the playback troubleshooting tool in "channel" mode
|
||||
- This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout* starting at the entered date and time
|
||||
- Block schedules: add copy template button to templates table
|
||||
|
||||
### Fixed
|
||||
- Fix HLS Direct playback with Jellyfin 10.11
|
||||
- Fix remote stream scripts (parsing issue with spaces and quotes)
|
||||
- Fix block history being removed when it is still needed for mirror channel
|
||||
- This caused playout build errors like "Unable to locate history for playout item"
|
||||
- Fix crashes due to invalid smart collection searches, e.g. `smart_collection:"this collection does not exist"`
|
||||
- Fix UI crash when editing block playout that has default deco
|
||||
- Fix playback failure when seeking content with certain DTS audio (e.g. DTS-HD MA)
|
||||
- Properly set explicit audio decoder on combined audio and video input file
|
||||
- Fix building sequential schedules across a UTC offset change
|
||||
- Fix block start time calculation across a UTC offset change
|
||||
- Fix classic schedule start time calculation across a UTC offset change
|
||||
- Fix XMLTV generation for channels using on-demand playout mode
|
||||
- Fix some file not found songs missing from trash view
|
||||
- Fix error/offline screen generation
|
||||
- Fix subtitle title sync from Jellyfin libraries
|
||||
- Deep scans will be required to update subtitle titles on existing media items
|
||||
- Fix saving subtitle title changes to the database
|
||||
- This fixes e.g. where stream selection would continue to use the original title
|
||||
- This fix applies to all libraries (local and media server)
|
||||
- Fix (3 year old) bug removing tags from local libraries when they are removed from NFO files (all content types)
|
||||
- New scans will properly remove old tags; NFO files may need to be touched to force updating during a scan
|
||||
- Fix bug where looping motion graphics wouldn't be displayed when seeking into second half of content
|
||||
- Fix `content_total_duration` value in graphics engine opacity expressions
|
||||
- This bug caused some graphics elements to display too early after first joining a channel
|
||||
- Optimize database calls made for search index rebuilds and updates
|
||||
- This should improve performance of library scans
|
||||
- Add toggle to hide/show disabled channels in channel list
|
||||
- Add disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list
|
||||
- Graphics engine: fix subtitle path escaping and font loading
|
||||
- Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs
|
||||
- Work around sequential schedule validation limit (1000/hr by Newtonsoft.Json.Schema library)
|
||||
- Playout builds now use JsonSchema.Net library which has no validation limit
|
||||
- Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand
|
||||
- Fix editing scripted and sequential playouts when using MySql
|
||||
- Fix HLS Direct streams remaining open after client disconnect
|
||||
- Always log scanner exit code when it is non-zero
|
||||
|
||||
### Changed
|
||||
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them
|
||||
- This mode maintains progress; progress can be reset by editing the playout and clicking `Erase Items and History`
|
||||
- Use smaller batch size for search index updates (100, down from 1000)
|
||||
- This should help newly scanned items appear in the UI more quickly
|
||||
- Replace favicon and logo in background image used for error streams
|
||||
- Block schedules:
|
||||
- Auto scroll day view to block item time when adding and removing block items from template
|
||||
- Allow keyboard selection of
|
||||
- Block groups in block list
|
||||
- Template groups in template list
|
||||
- Block groups and blocks in template editor
|
||||
- Replace template tree view with searchable table (like blocks)
|
||||
- Upgrade to dotnet 10
|
||||
|
||||
## [25.8.0] - 2025-10-26
|
||||
### Added
|
||||
@@ -525,13 +836,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `random` will start at a random point in the content
|
||||
- `2` (similar to before this change) will skip the first two items in the content
|
||||
- YAML playout: make `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- YAML playout: add `disable_watermarks` property to all content instructions
|
||||
- This property defaults to `false` (meaning watermarks are allowed by default)
|
||||
- Setting to `true` will prevent watermarks from ever appearing over the content
|
||||
@@ -2888,7 +3199,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
|
||||
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
|
||||
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
|
||||
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
|
||||
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
|
||||
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
|
||||
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
|
||||
[25.7.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...v25.7.1
|
||||
[25.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# ErsatzTV Fork
|
||||
|
||||
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
- **Functional C#**: Language Ext (Option, Either monads throughout)
|
||||
|
||||
### Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
|
||||
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
|
||||
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
|
||||
| `ErsatzTV.Scanner/` | Media library scanning |
|
||||
|
||||
### Key Files
|
||||
|
||||
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs` → `ToM3U()`
|
||||
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
|
||||
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs` — `/iptv/*` routes
|
||||
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
|
||||
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
|
||||
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod container still runs upstream `ghcr.io/ersatztv/ersatztv:latest` pending cutover (server-management#481). Pipeline details: `docs/ci-cd.md`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ErsatzTV.sln
|
||||
|
||||
# Run locally (needs FFmpeg in PATH)
|
||||
dotnet run --project ErsatzTV
|
||||
|
||||
# Docker build
|
||||
docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep Blazor pages thin — delegate to MediatR handlers
|
||||
- Test with xUnit (existing test projects)
|
||||
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
|
||||
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
|
||||
|
||||
## Task Completion Protocol
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
|
||||
|
||||
**ersatztv does NOT own**:
|
||||
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
|
||||
- NFS mounts, Ansible, DNS, networking → server-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
|
||||
- Jellyfin skill → server-management (symlinked)
|
||||
|
||||
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
|
||||
|
||||
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
|
||||
|
||||
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
|
||||
|
||||
**Full cross-project rules**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
|
||||
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
|
||||
+12
-1
@@ -2,5 +2,16 @@
|
||||
<PropertyGroup>
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
|
||||
packages as NU1901-1904 warnings. Several projects set
|
||||
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
|
||||
on advisories we can't immediately fix. Demote low/moderate/high audit
|
||||
advisories to warnings (still printed in build logs); NU1904 (critical)
|
||||
stays an error so criticals still block. Track fixes separately.
|
||||
WarningsAsErrors promotes NU1904 in EVERY project (even those without
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Version="17.14.15"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,2 +0,0 @@
|
||||
target/
|
||||
|
||||
Generated
-1035
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "ersatztv_windows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tray-item = { git = "https://github.com/olback/tray-item-rs" }
|
||||
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
|
||||
process_path = "0.1.4"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.43.0"
|
||||
features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_Foundation"
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
windres = "*"
|
||||
static_vcruntime = "2.0"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,6 +0,0 @@
|
||||
use windres::Build;
|
||||
|
||||
fn main() {
|
||||
static_vcruntime::metabuild();
|
||||
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
id ICON "ersatztv.ico"
|
||||
ersatztv-icon ICON "ersatztv.ico"
|
||||
@@ -1,115 +0,0 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use special_folder::SpecialFolder;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use windows::Win32::System::Console;
|
||||
use {std::sync::mpsc, tray_item::TrayItem};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
enum Message {
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
tray.add_menu_item("Launch Web UI", || {
|
||||
let ui_port = env::var("ETV_UI_PORT")
|
||||
.ok()
|
||||
.and_then(|val| val.parse::<u16>().ok())
|
||||
.unwrap_or(8409);
|
||||
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg(format!("http://localhost:{}", ui_port))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.add_menu_item("Show Logs", || {
|
||||
let path = SpecialFolder::LocalApplicationData
|
||||
.get()
|
||||
.unwrap()
|
||||
.join("ersatztv")
|
||||
.join("logs");
|
||||
match path.to_str() {
|
||||
None => {}
|
||||
Some(folder) => {
|
||||
fs::create_dir_all(folder).unwrap();
|
||||
let _ = Command::new("explorer.exe")
|
||||
.arg(folder)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.inner_mut().add_separator().unwrap();
|
||||
|
||||
tray.add_menu_item("Exit", move || {
|
||||
tx.send(Message::Exit).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = process_path::get_executable_path();
|
||||
let mut child: Option<Child> = None;
|
||||
match path {
|
||||
None => {}
|
||||
Some(path) => {
|
||||
let etv = path.parent().unwrap().join("ErsatzTV.exe");
|
||||
if etv.exists() {
|
||||
match etv.to_str() {
|
||||
None => {}
|
||||
Some(etv) => {
|
||||
child = Some(
|
||||
Command::new(etv)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Message::Exit) => {
|
||||
match child {
|
||||
None => {}
|
||||
Some(mut child) => {
|
||||
unsafe {
|
||||
if Console::AttachConsole(child.id()) == true
|
||||
{
|
||||
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
|
||||
}
|
||||
}
|
||||
child.wait().unwrap();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule ErsatzTV-macOS updated: d4dd985fd6...8dbe1e22f2
@@ -1,30 +1,26 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Artists.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
public class GetArtistByIdHandler(
|
||||
IArtistRepository artistRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ILanguageCodeService languageCodeService)
|
||||
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
|
||||
{
|
||||
_artistRepository = artistRepository;
|
||||
_searchRepository = searchRepository;
|
||||
}
|
||||
|
||||
public async Task<Option<ArtistViewModel>> Handle(
|
||||
GetArtistById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
|
||||
Option<Artist> maybeArtist = await artistRepository.GetArtist(request.ArtistId);
|
||||
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
|
||||
async artist =>
|
||||
{
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||
List<string> mediaCodes = await searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = languageCodeService.GetAllLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
|
||||
@@ -11,6 +11,7 @@ public record ChannelViewModel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -10,6 +10,7 @@ public record CreateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -80,6 +80,7 @@ public class CreateChannelHandler(
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
SlugSeconds = request.SlugSeconds,
|
||||
PlayoutSource = request.PlayoutSource,
|
||||
PlayoutMode = request.PlayoutMode,
|
||||
MirrorSourceChannelId = request.MirrorSourceChannelId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -12,19 +12,19 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeleteChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_fileSystem = fileSystem;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.Xml;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core;
|
||||
@@ -26,6 +27,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
@@ -33,12 +35,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
public RefreshChannelDataHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<RefreshChannelDataHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
@@ -69,9 +73,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
|
||||
string songTemplateFileName = GetSongTemplateFileName();
|
||||
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
|
||||
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
|
||||
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
|
||||
musicVideoTemplateFileName is null ||
|
||||
songTemplateFileName is null || otherVideoTemplateFileName is null)
|
||||
songTemplateFileName is null || otherVideoTemplateFileName is null ||
|
||||
remoteStreamTemplateFileName is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -101,6 +107,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
|
||||
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
|
||||
|
||||
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
|
||||
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
|
||||
|
||||
TimeSpan playoutOffset = TimeSpan.Zero;
|
||||
string mirrorChannelNumber = null;
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
@@ -189,6 +198,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
@@ -199,6 +212,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
@@ -239,6 +253,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
@@ -264,6 +279,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
@@ -287,6 +303,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
@@ -316,6 +333,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -390,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
@@ -406,6 +425,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -461,6 +481,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
}
|
||||
@@ -500,6 +521,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
@@ -523,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
{
|
||||
@@ -584,6 +607,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
title,
|
||||
templateContext,
|
||||
otherVideoTemplate),
|
||||
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
|
||||
request,
|
||||
templateRemoteStream,
|
||||
start,
|
||||
stop,
|
||||
hasCustomTitle,
|
||||
displayItem,
|
||||
title,
|
||||
templateContext,
|
||||
remoteStreamTemplate),
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
@@ -850,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
metadata.Genres ??= [];
|
||||
metadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
@@ -864,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
OtherVideoPlot = metadata.Plot,
|
||||
OtherVideoHasYear = metadata.Year.HasValue,
|
||||
OtherVideoYear = metadata.Year,
|
||||
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
OtherVideoArtworkUrl = artworkPath,
|
||||
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
||||
OtherVideoContentRating = metadata.ContentRating
|
||||
@@ -879,18 +916,63 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
private string GetMovieTemplateFileName()
|
||||
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
|
||||
RefreshChannelData request,
|
||||
RemoteStream templateRemoteStream,
|
||||
string start,
|
||||
string stop,
|
||||
bool hasCustomTitle,
|
||||
PlayoutItem displayItem,
|
||||
string title,
|
||||
XmlTemplateContext templateContext,
|
||||
Template remoteStreamTemplate)
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_movie.sbntxt");
|
||||
metadata.Genres ??= [];
|
||||
metadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
ProgrammeStop = stop,
|
||||
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
|
||||
request.ChannelNumber,
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
RemoteStreamTitle = title,
|
||||
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
|
||||
RemoteStreamPlot = metadata.Plot,
|
||||
RemoteStreamHasYear = metadata.Year.HasValue,
|
||||
RemoteStreamYear = metadata.Year,
|
||||
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
RemoteStreamArtworkUrl = artworkPath,
|
||||
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
||||
RemoteStreamContentRating = metadata.ContentRating
|
||||
};
|
||||
|
||||
var scriptObject = new ScriptObject();
|
||||
scriptObject.Import(data);
|
||||
templateContext.PushGlobal(scriptObject);
|
||||
|
||||
return await remoteStreamTemplate.RenderAsync(templateContext);
|
||||
}
|
||||
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
private string GetMovieTemplateFileName()
|
||||
{
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"movie.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -904,16 +986,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetEpisodeTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "episode.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_episode.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"episode.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -927,16 +1005,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetMusicVideoTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "musicVideo.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_musicVideo.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"musicVideo.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -950,16 +1024,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetSongTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "song.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_song.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"song.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -973,16 +1043,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetOtherVideoTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "otherVideo.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_otherVideo.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"otherVideo.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -994,6 +1060,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
return templateFileName;
|
||||
}
|
||||
|
||||
private string GetRemoteStreamTemplateFileName()
|
||||
{
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"remoteStream.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
templateFileName);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return templateFileName;
|
||||
}
|
||||
|
||||
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string artworkPath = artwork.Path;
|
||||
@@ -1049,6 +1134,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
@@ -1097,7 +1184,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
|
||||
if (_localFileSystem.FileExists(path))
|
||||
if (_fileSystem.File.Exists(path))
|
||||
{
|
||||
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
|
||||
await File.ReadAllTextAsync(path));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions;
|
||||
using System.Net;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
@@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelListHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
@@ -26,11 +28,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelListHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -44,13 +48,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
|
||||
}
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
|
||||
|
||||
@@ -11,6 +11,7 @@ public record UpdateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -52,6 +52,7 @@ public class UpdateChannelHandler(
|
||||
c.Group = update.Group;
|
||||
c.Categories = update.Categories;
|
||||
c.FFmpegProfileId = update.FFmpegProfileId;
|
||||
c.SlugSeconds = update.SlugSeconds;
|
||||
c.StreamSelectorMode = update.StreamSelectorMode;
|
||||
c.StreamSelector = update.StreamSelector;
|
||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -38,6 +39,14 @@ public class UpdateChannelNumbersHandler(
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
channel.Number = numberUpdates[channel.Id];
|
||||
if (double.TryParse(channel.Number, CultureInfo.InvariantCulture, out double sortNumber))
|
||||
{
|
||||
channel.SortNumber = sortNumber;
|
||||
}
|
||||
else
|
||||
{
|
||||
return BaseError.New($"Failed to parse channel number {channel.Number}");
|
||||
}
|
||||
}
|
||||
|
||||
// save those changes
|
||||
|
||||
@@ -14,6 +14,7 @@ internal static class Mapper
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
GetLogo(channel),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
|
||||
public record GetAllChannels(bool ShowDisabled = true) : IRequest<List<ChannelViewModel>>;
|
||||
|
||||
@@ -9,7 +9,8 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
{
|
||||
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
|
||||
await channelRepository.GetAll(cancellationToken)
|
||||
.Map(list => list.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
|
||||
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
|
||||
private static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelByPlayoutId(int PlayoutId) : IRequest<Option<ChannelViewModel>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelByPlayoutId, Option<ChannelViewModel>>
|
||||
{
|
||||
public async Task<Option<ChannelViewModel>> Handle(
|
||||
GetChannelByPlayoutId request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ThenInclude(c => c.Artwork)
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken)
|
||||
.Map(p => ProjectToViewModel(p.Channel, 1));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.FFmpeg;
|
||||
|
||||
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>;
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>>
|
||||
public class GetChannelFramerateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<GetChannelFramerateHandler> logger)
|
||||
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILogger<GetChannelFramerateHandler> _logger;
|
||||
|
||||
public GetChannelFramerateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<GetChannelFramerateHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
@@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
return Option<int>.None;
|
||||
return Option<FrameRate>.None;
|
||||
}
|
||||
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
@@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => mv.RFrameRate)
|
||||
.Map(mv => new FrameRate(mv.RFrameRate))
|
||||
.ToList();
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
int result = frameRates.Map(ParseFrameRate).Min();
|
||||
if (result < 24)
|
||||
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
|
||||
if (validFrameRates.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
|
||||
logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
24,
|
||||
result);
|
||||
|
||||
return 24;
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
result.RFrameRate);
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
|
||||
logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
result);
|
||||
return result;
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
FrameRate.DefaultFrameRate.RFrameRate,
|
||||
minFrameRate.RFrameRate);
|
||||
|
||||
return FrameRate.DefaultFrameRate;
|
||||
}
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
distinct[0].RFrameRate);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Unexpected error checking frame rates on channel {ChannelNumber}",
|
||||
request.ChannelNumber);
|
||||
@@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private int ParseFrameRate(string frameRate)
|
||||
{
|
||||
if (!int.TryParse(frameRate, out int fr))
|
||||
{
|
||||
string[] split = (frameRate ?? string.Empty).Split("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
{
|
||||
fr = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
fr = 24;
|
||||
}
|
||||
}
|
||||
|
||||
return fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
@@ -9,27 +11,18 @@ using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -37,13 +30,13 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
.Select(n => $"{n}.xml")
|
||||
.ToImmutableHashSet();
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_localFileSystem.FileExists(channelsFile))
|
||||
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
@@ -51,7 +44,7 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
@@ -60,27 +53,54 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
try
|
||||
{
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
|
||||
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = fileSystem.FileStream.New(
|
||||
fileName,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
return await reader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
|
||||
private static partial Regex EtvTagRegex();
|
||||
}
|
||||
|
||||
@@ -4,16 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
public class GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
|
||||
@@ -0,0 +1,17 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
|
||||
{
|
||||
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
|
||||
.Map(c => Optional(c?.SlugSeconds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateUiSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateUiSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await ApplyUpdate(request.UiSettings, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
uiSettings.IsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetMpegTsScripts : IRequest<List<MpegTsScript>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService)
|
||||
: IRequestHandler<GetMpegTsScripts, List<MpegTsScript>>
|
||||
{
|
||||
public async Task<List<MpegTsScript>> Handle(GetMpegTsScripts request, CancellationToken cancellationToken)
|
||||
{
|
||||
await mpegTsScriptService.RefreshScripts();
|
||||
return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetUiSettings : IRequest<UiSettingsViewModel>;
|
||||
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
|
||||
{
|
||||
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.PagesLanguage,
|
||||
cancellationToken);
|
||||
|
||||
return new UiSettingsViewModel
|
||||
{
|
||||
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
|
||||
Language = await pagesLanguage.IfNoneAsync("en")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UiSettingsViewModel
|
||||
{
|
||||
public bool IsDarkMode { get; set; }
|
||||
|
||||
public string Language { get; set; }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -21,7 +22,12 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyCollectionScannerHandler> logger) : base(
|
||||
dbContextFactory,
|
||||
configElementRepository,
|
||||
runtimeInfo,
|
||||
logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -22,8 +23,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -19,8 +20,9 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyShowScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
@@ -10,19 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.9.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=emby_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpeg_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.FFmpeg;
|
||||
|
||||
public record RefreshFFmpegCapabilities : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,45 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpeg;
|
||||
|
||||
public class RefreshFFmpegCapabilitiesHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
|
||||
ILocalStatisticsProvider localStatisticsProvider)
|
||||
: IRequestHandler<RefreshFFmpegCapabilities>
|
||||
{
|
||||
public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken)
|
||||
{
|
||||
hardwareCapabilitiesFactory.ClearCache();
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<string> maybeFFmpegPath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
|
||||
.FilterT(File.Exists);
|
||||
|
||||
foreach (string ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
|
||||
|
||||
Option<string> maybeFFprobePath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||
.FilterT(File.Exists);
|
||||
|
||||
foreach (string ffprobePath in maybeFFprobePath)
|
||||
{
|
||||
Either<BaseError, MediaVersion> result = await localStatisticsProvider.GetStatistics(
|
||||
ffprobePath,
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs"));
|
||||
|
||||
hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
public record CreateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -14,6 +16,7 @@ public record CreateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -26,7 +29,9 @@ public record CreateFFmpegProfile(
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
double? TargetLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -44,37 +44,63 @@ public class CreateFFmpegProfileHandler :
|
||||
CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
|
||||
.Apply((name, threadCount, resolutionId) =>
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
HardwareAcceleration = request.HardwareAcceleration,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
VideoFormat = request.VideoFormat,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
return new FFmpegProfile
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.AudioFormat,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
NormalizeVideo = request.NormalizeVideo,
|
||||
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
// only allow customization with VAAPI accel
|
||||
PadMode = hwAccel switch
|
||||
{
|
||||
HardwareAccelerationKind.None => FilterMode.Software,
|
||||
HardwareAccelerationKind.Vaapi => request.PadMode,
|
||||
_ => FilterMode.HardwareIfPossible
|
||||
},
|
||||
|
||||
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? request.TargetLoudness
|
||||
: null,
|
||||
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
};
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
|
||||
@@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
|
||||
int FFmpegProfileId,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record UpdateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -27,7 +30,9 @@ public record UpdateFFmpegProfile(
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
double? TargetLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -27,16 +27,23 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
UpdateFFmpegProfile update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hwAccel = update.NormalizeVideo
|
||||
? update.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
p.Name = update.Name;
|
||||
p.ThreadCount = update.ThreadCount;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.NormalizeVideo = update.NormalizeVideo;
|
||||
p.HardwareAcceleration = hwAccel;
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.PadMode = update.PadMode;
|
||||
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
|
||||
p.VideoProfile = update.VideoProfile;
|
||||
p.VideoPreset = update.VideoPreset;
|
||||
p.AllowBFrames = update.AllowBFrames;
|
||||
@@ -53,16 +60,32 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
|
||||
}
|
||||
|
||||
// only allow customization with VAAPI accel
|
||||
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
|
||||
{
|
||||
p.PadMode = FilterMode.Software;
|
||||
}
|
||||
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
p.PadMode = FilterMode.HardwareIfPossible;
|
||||
}
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.TonemapAlgorithm = update.TonemapAlgorithm;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
|
||||
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
|
||||
p.TargetLoudness = update.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? update.TargetLoudness
|
||||
: null;
|
||||
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
|
||||
// don't save invalid preset
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.FFmpeg;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
public class UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IFileSystem fileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateFFmpegSettings request,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -44,7 +35,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
if (!fileSystem.File.Exists(path))
|
||||
{
|
||||
return BaseError.New($"{name} path does not exist");
|
||||
}
|
||||
@@ -71,32 +62,36 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegDefaultProfileId,
|
||||
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture),
|
||||
cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSaveReports,
|
||||
request.Settings.SaveReports.ToString(),
|
||||
cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
request.Settings.HlsDirectOutputFormat,
|
||||
cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegDefaultMpegTsScript,
|
||||
request.Settings.DefaultMpegTsScript,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode,
|
||||
request.Settings.PreferredAudioLanguageCode,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
|
||||
request.Settings.UseEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
@@ -107,7 +102,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
request.Settings.ExtractEmbeddedSubtitles = false;
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
|
||||
request.Settings.ExtractEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
@@ -115,48 +110,55 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
// queue extracting all embedded subtitles
|
||||
if (request.Settings.ExtractEmbeddedSubtitles)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
|
||||
}
|
||||
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegProbeForInterlacedFrames,
|
||||
request.Settings.ProbeForInterlacedFrames,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Settings.GlobalWatermarkId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalWatermarkId,
|
||||
request.Settings.GlobalWatermarkId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
}
|
||||
|
||||
if (request.Settings.GlobalFallbackFillerId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalFallbackFillerId,
|
||||
request.Settings.GlobalFallbackFillerId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSegmenterTimeout,
|
||||
request.Settings.HlsSegmenterIdleTimeout,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegWorkAheadSegmenters,
|
||||
request.Settings.WorkAheadSegmenterLimit,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegInitialSegmentCount,
|
||||
request.Settings.InitialSegmentCount,
|
||||
cancellationToken);
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshFFmpegCapabilities(), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record FFmpegProfileViewModel(
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -27,7 +30,9 @@ public record FFmpegProfileViewModel(
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
double? TargetLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo);
|
||||
|
||||
@@ -10,6 +10,7 @@ public class FFmpegSettingsViewModel
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
public bool UseEmbeddedSubtitles { get; set; }
|
||||
public bool ExtractEmbeddedSubtitles { get; set; }
|
||||
public bool ProbeForInterlacedFrames { get; set; }
|
||||
public bool SaveReports { get; set; }
|
||||
public int? GlobalWatermarkId { get; set; }
|
||||
public int? GlobalFallbackFillerId { get; set; }
|
||||
@@ -17,4 +18,5 @@ public class FFmpegSettingsViewModel
|
||||
public int WorkAheadSegmenterLimit { get; set; }
|
||||
public int InitialSegmentCount { get; set; }
|
||||
public OutputFormatKind HlsDirectOutputFormat { get; set; }
|
||||
public string DefaultMpegTsScript { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ internal static class Mapper
|
||||
profile.Id,
|
||||
profile.Name,
|
||||
profile.ThreadCount,
|
||||
profile.NormalizeAudio,
|
||||
profile.NormalizeVideo,
|
||||
profile.HardwareAcceleration,
|
||||
profile.VaapiDisplay ?? "drm",
|
||||
profile.VaapiDriver,
|
||||
@@ -17,6 +19,7 @@ internal static class Mapper
|
||||
profile.QsvExtraHardwareFrames,
|
||||
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
|
||||
profile.ScalingBehavior,
|
||||
profile.PadMode,
|
||||
profile.VideoFormat,
|
||||
profile.VideoProfile,
|
||||
profile.VideoPreset ?? string.Empty,
|
||||
@@ -29,9 +32,11 @@ internal static class Mapper
|
||||
profile.AudioBitrate,
|
||||
profile.AudioBufferSize,
|
||||
profile.NormalizeLoudnessMode,
|
||||
profile.TargetLoudness,
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeFramerate,
|
||||
profile.NormalizeColors,
|
||||
profile.DeinterlaceVideo == true);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
|
||||
@@ -5,18 +5,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
|
||||
public class GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<FFmpegProfileViewModel>> Handle(
|
||||
GetFFmpegProfileById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FFmpegProfiles
|
||||
.Include(p => p.Resolution)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)
|
||||
|
||||
@@ -4,41 +4,59 @@ using ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
|
||||
public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<FFmpegSettingsViewModel> Handle(
|
||||
GetFFmpegSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken);
|
||||
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken);
|
||||
Option<string> ffmpegPath = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegPath,
|
||||
cancellationToken);
|
||||
Option<string> ffprobePath = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFprobePath,
|
||||
cancellationToken);
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
|
||||
await configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
|
||||
Option<string> preferredAudioLanguageCode =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode, cancellationToken);
|
||||
await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode,
|
||||
cancellationToken);
|
||||
Option<bool> useEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken);
|
||||
await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
Option<bool> extractEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken);
|
||||
await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
Option<bool> probeForInterlacedFrames =
|
||||
await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.FFmpegProbeForInterlacedFrames,
|
||||
cancellationToken);
|
||||
Option<int> watermark =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
Option<int> fallbackFiller =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.FFmpegGlobalFallbackFillerId,
|
||||
cancellationToken);
|
||||
Option<int> hlsSegmenterIdleTimeout =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
|
||||
Option<int> workAheadSegmenterLimit =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
|
||||
Option<int> initialSegmentCount =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
|
||||
Option<OutputFormatKind> outputFormatKind =
|
||||
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
|
||||
await configElementRepository.GetValue<OutputFormatKind>(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
cancellationToken);
|
||||
Option<string> defaultMpegTsScript =
|
||||
await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegDefaultMpegTsScript,
|
||||
cancellationToken);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -48,11 +66,13 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
SaveReports = await saveReports.IfNoneAsync(false),
|
||||
UseEmbeddedSubtitles = await useEmbeddedSubtitles.IfNoneAsync(true),
|
||||
ExtractEmbeddedSubtitles = await extractEmbeddedSubtitles.IfNoneAsync(false),
|
||||
ProbeForInterlacedFrames = await probeForInterlacedFrames.IfNoneAsync(false),
|
||||
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
|
||||
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
|
||||
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
|
||||
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs)
|
||||
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs),
|
||||
DefaultMpegTsScript = await defaultMpegTsScript.IfNoneAsync("Default"),
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
|
||||
+6
-3
@@ -31,15 +31,18 @@ public class
|
||||
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
|
||||
|
||||
return await validation.Match(
|
||||
GetHardwareAccelerationKinds,
|
||||
ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken),
|
||||
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
|
||||
}
|
||||
|
||||
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
|
||||
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
|
||||
|
||||
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
|
||||
IFFmpegCapabilities ffmpegCapabilities =
|
||||
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
|
||||
{
|
||||
|
||||
@@ -1,57 +1,67 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
|
||||
public class CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CreateFillerPresetHandler(IClient client, IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_client = client;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(CreateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken));
|
||||
}
|
||||
|
||||
private static async Task<Unit> Persist(
|
||||
TvContext dbContext,
|
||||
FillerPreset fillerPreset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, FillerPreset>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateFillerPreset request) =>
|
||||
ValidateName(dbContext, request).MapT(name => new FillerPreset
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Name = name,
|
||||
FillerKind = request.FillerKind,
|
||||
FillerMode = request.FillerMode,
|
||||
Duration = request.Duration,
|
||||
Count = request.Count,
|
||||
PadToNearestMinute = request.PadToNearestMinute,
|
||||
AllowWatermarks = request.AllowWatermarks,
|
||||
CollectionType = request.CollectionType,
|
||||
CollectionId = request.CollectionId,
|
||||
MediaItemId = request.MediaItemId,
|
||||
MultiCollectionId = request.MultiCollectionId,
|
||||
SmartCollectionId = request.SmartCollectionId,
|
||||
PlaylistId = request.PlaylistId,
|
||||
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null,
|
||||
UseChaptersAsMediaItems =
|
||||
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems
|
||||
});
|
||||
|
||||
var fillerPreset = new FillerPreset
|
||||
{
|
||||
Name = request.Name,
|
||||
FillerKind = request.FillerKind,
|
||||
FillerMode = request.FillerMode,
|
||||
Duration = request.Duration,
|
||||
Count = request.Count,
|
||||
PadToNearestMinute = request.PadToNearestMinute,
|
||||
AllowWatermarks = request.AllowWatermarks,
|
||||
CollectionType = request.CollectionType,
|
||||
CollectionId = request.CollectionId,
|
||||
MediaItemId = request.MediaItemId,
|
||||
MultiCollectionId = request.MultiCollectionId,
|
||||
SmartCollectionId = request.SmartCollectionId,
|
||||
PlaylistId = request.PlaylistId,
|
||||
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null,
|
||||
UseChaptersAsMediaItems =
|
||||
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems
|
||||
};
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
CreateFillerPreset request)
|
||||
{
|
||||
Validation<BaseError, string> result1 = request.NotEmpty(fp => fp.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(fp => fp.Name));
|
||||
|
||||
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
bool duplicateName = await dbContext.FillerPresets
|
||||
.AnyAsync(fp => fp.Name == request.Name);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
Validation<BaseError, Unit> result2 = duplicateName
|
||||
? Fail<BaseError, Unit>("Filler preset name must be unique")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
|
||||
return (result1, result2).Apply((_, _) => request.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,27 +6,21 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Either<BaseError, Unit>>
|
||||
public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<UpdateFillerPreset, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
FillerPreset existing,
|
||||
UpdateFillerPreset request)
|
||||
UpdateFillerPreset request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
existing.Name = request.Name;
|
||||
existing.FillerKind = request.FillerKind;
|
||||
@@ -45,16 +39,40 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
existing.UseChaptersAsMediaItems =
|
||||
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, FillerPreset>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateFillerPreset request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FillerPresetMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFillerPreset request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FillerPresets
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FillerPreset does not exist"));
|
||||
.Map(o => o.ToValidation<BaseError>("Filler preset does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
UpdateFillerPreset request)
|
||||
{
|
||||
Validation<BaseError, string> result1 = request.NotEmpty(fp => fp.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(fp => fp.Name));
|
||||
|
||||
bool duplicateName = await dbContext.FillerPresets
|
||||
.AnyAsync(c => c.Id != request.Id && c.Name == request.Name);
|
||||
|
||||
Validation<BaseError, Unit> result2 = duplicateName
|
||||
? Fail<BaseError, Unit>("Filler preset name must be unique")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
|
||||
return (result1, result2).Apply((_, _) => request.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,18 @@ using static ErsatzTV.Application.Filler.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class GetPagedFillerPresetsHandler : IRequestHandler<GetPagedFillerPresets, PagedFillerPresetsViewModel>
|
||||
public class GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPagedFillerPresets, PagedFillerPresetsViewModel>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<PagedFillerPresetsViewModel> Handle(
|
||||
GetPagedFillerPresets request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.FillerPresets.CountAsync(cancellationToken);
|
||||
List<FillerPresetViewModel> page = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.OrderBy(f => EF.Functions.Collate(f.Name, TvContext.CaseInsensitiveCollation))
|
||||
.OrderBy(f => f.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -9,7 +11,9 @@ namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class RefreshGraphicsElementsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IGraphicsElementLoader graphicsElementLoader,
|
||||
ILogger<RefreshGraphicsElementsHandler> logger)
|
||||
: IRequestHandler<RefreshGraphicsElements>
|
||||
{
|
||||
@@ -21,7 +25,11 @@ public class RefreshGraphicsElementsHandler(
|
||||
List<GraphicsElement> allExisting = await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (GraphicsElement existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path)))
|
||||
var missing = allExisting
|
||||
.Where(e => !fileSystem.File.Exists(e.Path) || (Path.GetExtension(e.Path) != ".yml" && Path.GetExtension(e.Path) != ".yaml"))
|
||||
.ToList();
|
||||
|
||||
foreach (GraphicsElement existing in missing)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Removing graphics element that references non-existing file {File}",
|
||||
@@ -30,8 +38,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
dbContext.GraphicsElements.Remove(existing);
|
||||
}
|
||||
|
||||
foreach (GraphicsElement existing in allExisting.Except(missing))
|
||||
{
|
||||
await TryRefreshName(existing, cancellationToken);
|
||||
}
|
||||
|
||||
// add new text elements
|
||||
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder)
|
||||
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -45,11 +58,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new image elements
|
||||
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder)
|
||||
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -63,11 +78,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Image
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new motion elements
|
||||
var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder)
|
||||
var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -81,11 +98,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Motion
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new subtitle elements
|
||||
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder)
|
||||
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -99,9 +118,41 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Subtitle
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new script elements
|
||||
var newScriptPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsScriptTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
foreach (string path in newScriptPaths)
|
||||
{
|
||||
logger.LogDebug("Adding new graphics element from file {File}", path);
|
||||
|
||||
var graphicsElement = new GraphicsElement
|
||||
{
|
||||
Path = path,
|
||||
Kind = GraphicsElementKind.Script
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TryRefreshName(GraphicsElement graphicsElement, CancellationToken cancellationToken)
|
||||
{
|
||||
graphicsElement.Name = null;
|
||||
Option<string> maybeName = await graphicsElementLoader.TryLoadName(graphicsElement.Path, cancellationToken);
|
||||
foreach (string name in maybeName)
|
||||
{
|
||||
graphicsElement.Name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GraphicsElementViewModel(int Id, string Name);
|
||||
public record GraphicsElementViewModel(int Id, string Name, string FileName);
|
||||
|
||||
@@ -7,13 +7,22 @@ public static class Mapper
|
||||
public static GraphicsElementViewModel ProjectToViewModel(GraphicsElement graphicsElement)
|
||||
{
|
||||
string fileName = Path.GetFileName(graphicsElement.Path);
|
||||
return graphicsElement.Kind switch
|
||||
fileName = graphicsElement.Kind switch
|
||||
{
|
||||
GraphicsElementKind.Text => new GraphicsElementViewModel(graphicsElement.Id, $"text/{fileName}"),
|
||||
GraphicsElementKind.Image => new GraphicsElementViewModel(graphicsElement.Id, $"image/{fileName}"),
|
||||
GraphicsElementKind.Subtitle => new GraphicsElementViewModel(graphicsElement.Id, $"subtitle/{fileName}"),
|
||||
GraphicsElementKind.Motion => new GraphicsElementViewModel(graphicsElement.Id, $"motion/{fileName}"),
|
||||
_ => new GraphicsElementViewModel(graphicsElement.Id, graphicsElement.Path)
|
||||
GraphicsElementKind.Text => $"text/{fileName}",
|
||||
GraphicsElementKind.Image => $"image/{fileName}",
|
||||
GraphicsElementKind.Subtitle => $"subtitle/{fileName}",
|
||||
GraphicsElementKind.Motion => $"motion/{fileName}",
|
||||
GraphicsElementKind.Script => $"script/{fileName}",
|
||||
_ => graphicsElement.Path
|
||||
};
|
||||
|
||||
string name = fileName;
|
||||
if (!string.IsNullOrWhiteSpace(graphicsElement.Name))
|
||||
{
|
||||
name = $"{graphicsElement.Name} ({fileName})";
|
||||
}
|
||||
|
||||
return new GraphicsElementViewModel(graphicsElement.Id, name, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ public class GetAllGraphicsElementsHandler(IDbContextFactory<TvContext> dbContex
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(ProjectToViewModel).OrderBy(e => e.Name == e.FileName).ThenBy(e => e.Name).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ public class
|
||||
ffmpegPath,
|
||||
originalPath,
|
||||
withExtension,
|
||||
request.MaxHeight.Value);
|
||||
request.MaxHeight.Value,
|
||||
cancellationToken);
|
||||
|
||||
CommandResult resize = await process.ExecuteAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
@@ -21,7 +22,12 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallJellyfinCollectionScannerHandler> logger) : base(
|
||||
dbContextFactory,
|
||||
configElementRepository,
|
||||
runtimeInfo,
|
||||
logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
@@ -22,8 +23,9 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallJellyfinLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
@@ -19,8 +20,9 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallJellyfinShowScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
@@ -36,8 +36,10 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
|
||||
|
||||
private async Task<Validation<BaseError, Parameters>> Validate(SaveJellyfinSecrets request)
|
||||
{
|
||||
var connectionParameters = new JellyfinConnectionParameters(request.Secrets.Address, request.Secrets.ApiKey, 0);
|
||||
|
||||
Either<BaseError, JellyfinServerInformation> maybeServerInformation = await _jellyfinApiClient
|
||||
.GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey);
|
||||
.GetServerInformation(connectionParameters.Address, connectionParameters.AuthorizationHeader);
|
||||
|
||||
return maybeServerInformation.Match(
|
||||
info => Validation<BaseError, Parameters>.Success(new Parameters(request.Secrets, info)),
|
||||
|
||||
@@ -38,7 +38,7 @@ public class
|
||||
.MapT(p => SynchronizeLibraries(p, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
private Task<Validation<BaseError, ConnectionAndSource>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
@@ -48,43 +48,48 @@ public class
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
private Validation<BaseError, ConnectionAndSource> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
return maybeConnection.Map(connection => new ConnectionAndSource(
|
||||
new JellyfinConnectionParameters(connection.Address, string.Empty, connection.JellyfinMediaSourceId),
|
||||
jellyfinMediaSource))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
private async Task<Validation<BaseError, ConnectionAndSource>> MediaSourceMustHaveApiKey(
|
||||
ConnectionAndSource connectionAndSource)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
return Optional(secrets.Address == connectionAndSource.ConnectionParameters.Address)
|
||||
.Where(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.Map(_ => connectionAndSource with
|
||||
{
|
||||
ConnectionParameters = connectionAndSource.ConnectionParameters with { ApiKey = secrets.ApiKey }
|
||||
})
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(
|
||||
ConnectionParameters connectionParameters,
|
||||
ConnectionAndSource connectionAndSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
connectionAndSource.ConnectionParameters.Address,
|
||||
connectionAndSource.ConnectionParameters.AuthorizationHeader);
|
||||
|
||||
foreach (BaseError error in maybeLibraries.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
|
||||
connectionParameters.JellyfinMediaSource.ServerName,
|
||||
connectionAndSource.MediaSource.ServerName,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
|
||||
{
|
||||
var existing = connectionParameters.JellyfinMediaSource.Libraries
|
||||
var existing = connectionAndSource.MediaSource.Libraries
|
||||
.OfType<JellyfinLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
@@ -92,7 +97,7 @@ public class
|
||||
var toUpdate = libraries
|
||||
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.JellyfinMediaSource.Id,
|
||||
connectionAndSource.MediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate,
|
||||
@@ -107,10 +112,7 @@ public class
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private sealed record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
private sealed record ConnectionAndSource(
|
||||
JellyfinConnectionParameters ConnectionParameters,
|
||||
JellyfinMediaSource MediaSource);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@ using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact.Reader;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler<TRequest>(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger logger)
|
||||
{
|
||||
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
|
||||
|
||||
@@ -42,6 +45,7 @@ public abstract class CallLibraryScannerHandler<TRequest>(
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
logger.LogWarning("ErsatzTV.Scanner exited with code {ExitCode}", process.ExitCode);
|
||||
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
@@ -64,7 +68,7 @@ public abstract class CallLibraryScannerHandler<TRequest>(
|
||||
// writes in UTC
|
||||
LogEvent logEvent = LogEventReader.ReadFromString(s);
|
||||
|
||||
ILogger log = Log.Logger;
|
||||
Serilog.ILogger log = Log.Logger;
|
||||
if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property))
|
||||
{
|
||||
log = log.ForContext(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -15,20 +15,23 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILanguageCodeService _languageCodeService;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_languageCodeService = languageCodeService;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -64,6 +67,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
_languageCodeService,
|
||||
[mediaItem]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,174 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using Humanizer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class DeleteOrphanedArtworkHandler : IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
public class DeleteOrphanedArtworkHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IArtworkRepository artworkRepository,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<DeleteOrphanedArtworkHandler> logger)
|
||||
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IArtworkRepository _artworkRepository;
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CleanUpDatabase();
|
||||
await CleanUpFileSystem(cancellationToken);
|
||||
|
||||
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
|
||||
_artworkRepository = artworkRepository;
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return BaseError.New(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>>
|
||||
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
|
||||
_artworkRepository.GetOrphanedArtwork()
|
||||
.Bind(_artworkRepository.Delete)
|
||||
.Map(_ => Right<BaseError, Unit>(Unit.Default));
|
||||
private async Task CleanUpDatabase()
|
||||
{
|
||||
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
await artworkRepository.Delete(ids);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanUpFileSystem(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
System.Collections.Generic.HashSet<string> validFiles = [];
|
||||
|
||||
List<string> watermarks = await dbContext.ChannelWatermarks
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Select(c => c.Image)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (string watermark in watermarks.Where(w => !string.IsNullOrWhiteSpace(w)))
|
||||
{
|
||||
validFiles.Add(watermark);
|
||||
}
|
||||
|
||||
var lastId = 0;
|
||||
while (true)
|
||||
{
|
||||
List<MinimalArtwork> result = await dbContext.Artwork
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id > lastId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(1000)
|
||||
.Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (result.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (MinimalArtwork artwork in result)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
|
||||
{
|
||||
validFiles.Add(artwork.Path);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
|
||||
}
|
||||
}
|
||||
|
||||
lastId = result.Last().Id;
|
||||
}
|
||||
|
||||
logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count);
|
||||
|
||||
var deleted = 0;
|
||||
long bytes = 0;
|
||||
foreach (string file in fileSystem.Directory.EnumerateFiles(
|
||||
FileSystemLayout.ArtworkCacheFolder,
|
||||
"*.*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
string fileName = fileSystem.Path.GetFileName(file);
|
||||
if (!validFiles.Contains(fileName))
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes += fileSystem.FileInfo.New(file).Length;
|
||||
|
||||
fileSystem.File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete artwork file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Deleted {Count} unused artwork cache files totaling {Size}",
|
||||
deleted,
|
||||
bytes.Bytes().Humanize(CultureInfo.CurrentCulture));
|
||||
|
||||
DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder);
|
||||
}
|
||||
|
||||
private void DeleteEmptySubfolders(string path)
|
||||
{
|
||||
if (!fileSystem.Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string sub in fileSystem.Directory.GetDirectories(path))
|
||||
{
|
||||
DeleteEmptySubfolders(sub);
|
||||
}
|
||||
|
||||
if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
// don't delete artwork cache folder or its direct children
|
||||
if (path != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
var parent = fileSystem.Directory.GetParent(path);
|
||||
if (parent?.FullName != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
fileSystem.Directory.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -8,16 +7,13 @@ namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public EmptyTrashHandler(
|
||||
IClient client,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_client = client;
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
@@ -27,7 +23,6 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult result = await _searchIndex.Search(
|
||||
_client,
|
||||
"state:FileNotFound",
|
||||
string.Empty,
|
||||
0,
|
||||
|
||||
@@ -140,7 +140,7 @@ internal static class Mapper
|
||||
return new SongCardViewModel(
|
||||
songMetadata.SongId,
|
||||
songMetadata.Title,
|
||||
string.Join(", ", songMetadata.Artists) + album,
|
||||
string.Join(", ", songMetadata.Artists ?? []) + album,
|
||||
songMetadata.SortTitle,
|
||||
GetThumbnail(songMetadata, None, None),
|
||||
songMetadata.Song.State);
|
||||
@@ -161,7 +161,7 @@ internal static class Mapper
|
||||
remoteStreamMetadata.Title,
|
||||
remoteStreamMetadata.OriginalTitle,
|
||||
remoteStreamMetadata.SortTitle,
|
||||
string.Empty, // TODO: thumbnail?
|
||||
GetThumbnail(remoteStreamMetadata, None, None),
|
||||
remoteStreamMetadata.RemoteStream.State);
|
||||
|
||||
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -19,13 +19,14 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, languageCodeService, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
@@ -109,10 +110,10 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
|
||||
return maybeList.Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
private static partial Regex UriTraktListRegex();
|
||||
|
||||
[GeneratedRegex(@"https:\/\/trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
|
||||
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
|
||||
private static partial Regex UriTraktListRegex2();
|
||||
|
||||
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
|
||||
@@ -49,16 +49,15 @@ public class CreateCollectionHandler :
|
||||
TvContext dbContext,
|
||||
CreateCollection createCollection)
|
||||
{
|
||||
List<string> allNames = await dbContext.Collections
|
||||
.Map(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(createCollection.Name)
|
||||
.Where(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("Collection name must be unique");
|
||||
bool duplicateName = await dbContext.Collections
|
||||
.AnyAsync(c => c.Name == createCollection.Name);
|
||||
|
||||
Validation<BaseError, Unit> result2 = duplicateName
|
||||
? Fail<BaseError, Unit>("Collection name must be unique")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
|
||||
return (result1, result2).Apply((_, _) => createCollection.Name);
|
||||
}
|
||||
|
||||
@@ -92,16 +92,15 @@ public class CreateMultiCollectionHandler :
|
||||
TvContext dbContext,
|
||||
CreateMultiCollection createMultiCollection)
|
||||
{
|
||||
List<string> allNames = await dbContext.MultiCollections
|
||||
.Map(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(createMultiCollection.Name)
|
||||
.Where(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("MultiCollection name must be unique");
|
||||
bool duplicateName = await dbContext.MultiCollections
|
||||
.AnyAsync(c => c.Name == createMultiCollection.Name);
|
||||
|
||||
Validation<BaseError, Unit> result2 = duplicateName
|
||||
? Fail<BaseError, Unit>("MultiCollection name must be unique")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
|
||||
return (result1, result2).Apply((_, _) => createMultiCollection.Name);
|
||||
}
|
||||
|
||||
@@ -40,11 +40,10 @@ public class CreatePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
return BaseError.New($"Playlist name \"{request.Name}\" is invalid");
|
||||
}
|
||||
|
||||
Option<Playlist> maybeExisting = await dbContext.Playlists
|
||||
.FirstOrDefaultAsync(r => r.PlaylistGroupId == request.PlaylistGroupId && r.Name == request.Name)
|
||||
.Map(Optional);
|
||||
bool duplicate = await dbContext.Playlists
|
||||
.AnyAsync(r => r.PlaylistGroupId == request.PlaylistGroupId && r.Name == request.Name);
|
||||
|
||||
return maybeExisting.IsSome
|
||||
return duplicate
|
||||
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
|
||||
: Success<BaseError, string>(request.Name);
|
||||
}
|
||||
|
||||
@@ -46,16 +46,15 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
|
||||
TvContext dbContext,
|
||||
CreateRerunCollection createCollection)
|
||||
{
|
||||
List<string> allNames = await dbContext.RerunCollections
|
||||
.Map(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(createCollection.Name)
|
||||
.Where(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("Rerun collection name must be unique");
|
||||
bool duplicateName = await dbContext.RerunCollections
|
||||
.AnyAsync(c => c.Name == createCollection.Name);
|
||||
|
||||
Validation<BaseError, Unit> result2 = duplicateName
|
||||
? Fail<BaseError, Unit>("Rerun collection name must be unique")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
|
||||
return (result1, result2).Apply((_, _) => createCollection.Name);
|
||||
}
|
||||
|
||||
@@ -59,17 +59,16 @@ public class CreateSmartCollectionHandler :
|
||||
TvContext dbContext,
|
||||
CreateSmartCollection createSmartCollection)
|
||||
{
|
||||
List<string> allNames = await dbContext.SmartCollections
|
||||
.Map(c => c.Name)
|
||||
.ToListAsync();
|
||||
bool duplicateName = await dbContext.SmartCollections
|
||||
.AnyAsync(c => c.Name == createSmartCollection.Name);
|
||||
|
||||
Validation<BaseError, Unit> result2 = duplicateName
|
||||
? Fail<BaseError, Unit>("SmartCollection name must be unique")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
|
||||
Validation<BaseError, string> result1 = createSmartCollection.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(createSmartCollection.Name)
|
||||
.Where(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("SmartCollection name must be unique");
|
||||
|
||||
return (result1, result2).Apply((_, _) => createSmartCollection.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -16,22 +16,25 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILanguageCodeService _languageCodeService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, languageCodeService, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_languageCodeService = languageCodeService;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
@@ -65,6 +68,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
_languageCodeService,
|
||||
mediaItemIds,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -19,9 +19,10 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(
|
||||
@@ -29,6 +30,7 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
languageCodeService,
|
||||
logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user