Compare commits

...
Author SHA1 Message Date
Jason Dove 88b645af2d keep releases as prerelease 2021-02-11 15:54:00 -06:00
Jason DoveandGitHub 941f1a59ee fix HDHR channel routes (#10) 2021-02-11 20:47:33 +00:00
Jason DoveandGitHub a3e20826a5 Movie metadata fixes (#9)
* reorganize metadata parsing; only attempt to parse appropriate media type based on media source configuration

* add fallback metadata for movie sources

* only request read access for nfo metadata

* fix tests
2021-02-11 19:33:59 +00:00
Jason DoveandGitHub ebff29d6cd Xml and scanner fixes (#7)
* flush xml, use utf8

* scan ts files

* use links instead of icons for m3u, xmltv, api
2021-02-11 15:35:32 +00:00
Jason DoveandGitHub 5a29fc1cbb fix docker-compose port mapping (#6) 2021-02-11 13:11:30 +00:00
Jason DoveandGitHub f4487eb422 use xml writer so strings are properly escaped (#4) 2021-02-11 12:18:20 +00:00
Jason DoveandGitHub d04f3d574b Fix fallback metadata parsing for TV (#3)
* fix yml formatting

* test build action

* move ci workflow

* fix unit tests from dotnet test command

* add release workflow

* try another matrix syntax

* fix path to csproj

* fix target framework

* more release fixes

* port fixes for running outside of docker

* more search fixes

* improve fallback metadata parsing, add tests

* fix odd behavior when searching collections

* more fallback metadata fixes

* refresh "other" metadata on startup
2021-02-11 11:57:32 +00:00
Jason Dove a2e85655d2 include cli in release builds 2021-02-10 21:37:31 -06:00
Jason Dove 4d49289dbd relax case sensitivity in searches 2021-02-10 21:29:54 -06:00
20 changed files with 297 additions and 111 deletions
+3
View File
@@ -21,6 +21,9 @@ jobs:
with:
dotnet-version: 5.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
+6
View File
@@ -39,23 +39,29 @@ jobs:
# Define some variables for things we need
tag=$(git describe --tags --abbrev=0)
release_name="ErsatzTV-$tag-${{ matrix.target }}"
release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name"
dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli"
# Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*"
else
tar czvf "${release_name}.tar.gz" "$release_name"
tar czvf "${release_name_cli}.tar.gz" "$release_name_cli"
fi
# Delete output directory
rm -r "$release_name"
rm -r "$release_name_cli"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
files: |
ErsatzTV*.zip
ErsatzTV*.tar.gz
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -24,7 +25,9 @@ namespace ErsatzTV.Application.MediaItems.Queries
if (!string.IsNullOrEmpty(request.SearchString))
{
allItems = allItems.Filter(i => i.Metadata?.Title.Contains(request.SearchString) == true);
allItems = allItems.Filter(
i => i.Metadata?.Title.Contains(request.SearchString, StringComparison.OrdinalIgnoreCase) ==
true);
}
return allItems.GroupBy(c => new { c.Source.Name, c.Metadata.Title }).Map(
@@ -6,6 +6,7 @@ using NUnit.Framework;
namespace ErsatzTV.Core.Tests.FFmpeg
{
[TestFixture]
public class FFmpegPlaybackSettingsCalculatorTests
{
public class CalculateSettings
@@ -0,0 +1,49 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata
{
[TestFixture]
public class FallbackMetadataProviderTests
{
[Test]
[TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase(
"Awesome Show (2021) - S01E02 - Description; More Description (1080p QUALITY codec GROUP).mkv",
"Awesome Show (2021)",
1,
2)]
[TestCase(
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
"Awesome.Show",
1,
2)]
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode)
{
MediaMetadata metadata = FallbackMetadataProvider.GetFallbackMetadata(
new MediaItem { Path = path, Source = new LocalMediaSource { MediaType = MediaType.TvShow } });
metadata.MediaType.Should().Be(MediaType.TvShow);
metadata.Title.Should().Be(title);
metadata.SeasonNumber.Should().Be(season);
metadata.EpisodeNumber.Should().Be(episode);
}
}
}
@@ -9,6 +9,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.Scheduling
{
[TestFixture]
public class ChronologicalContentTests
{
[Test]
@@ -14,6 +14,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.Scheduling
{
[TestFixture]
public class PlayoutBuilderTests
{
private readonly ILogger<PlayoutBuilder> _logger;
@@ -9,6 +9,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.Scheduling
{
[TestFixture]
public class RandomizedContentTests
{
private const int KnownSeed = 22295;
@@ -9,6 +9,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.Scheduling
{
[TestFixture]
public class ShuffledContentTests
{
// this seed will produce (shuffle) 1-10 in order
+6 -1
View File
@@ -19,6 +19,11 @@ namespace ErsatzTV.Core.Hdhr
public string GuideNumber => _channel.Number.ToString();
public string GuideName => _channel.Name;
public string URL => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}";
public string URL => _channel.StreamingMode switch
{
StreamingMode.HttpLiveStreaming => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
};
}
}
+59 -25
View File
@@ -2,6 +2,7 @@
using System.IO;
using System.Linq;
using System.Text;
using System.Xml;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@@ -22,20 +23,32 @@ namespace ErsatzTV.Core.Iptv
public string ToXml()
{
var sb = new StringBuilder();
sb.AppendLine("<?xml version=\"1.0\"?>");
sb.AppendLine("<tv generator-info-name=\"ersatztv\">");
using var ms = new MemoryStream();
using var xml = XmlWriter.Create(ms);
xml.WriteStartDocument();
xml.WriteStartElement("tv");
xml.WriteAttributeString("generator-info-name", "ersatztv");
foreach (Channel channel in _channels)
{
sb.AppendLine($"<channel id=\"{channel.Number}\">");
sb.AppendLine($"<display-name lang=\"en\">{channel.Name}</display-name>");
sb.AppendLine(
!string.IsNullOrWhiteSpace(channel.Logo)
? $"<icon src=\"{_scheme}://{_host}/iptv/images/{channel.Logo}\"/>"
: $"<icon src=\"{_scheme}://{_host}/images/ersatztv-500.png\"/>");
xml.WriteStartElement("channel");
xml.WriteAttributeString("id", channel.Number.ToString());
xml.WriteStartElement("display-name");
xml.WriteAttributeString("lang", "en");
xml.WriteString(channel.Name);
xml.WriteEndElement(); // display-name
sb.AppendLine("</channel>");
xml.WriteStartElement("icon");
xml.WriteAttributeString(
"src",
!string.IsNullOrWhiteSpace(channel.Logo)
? $"{_scheme}://{_host}/iptv/images/{channel.Logo}"
: $"{_scheme}://{_host}/images/ersatztv-500.png");
xml.WriteEndElement(); // icon
xml.WriteEndElement(); // channel
}
foreach (Channel channel in _channels)
@@ -50,37 +63,58 @@ namespace ErsatzTV.Core.Iptv
Title = Path.GetFileName(playoutItem.MediaItem.Path)
});
sb.AppendLine(
$"<programme start=\"{start}\" stop=\"{stop}\" channel=\"{channel.Number}\">");
sb.AppendLine($"<title lang=\"en\">{metadata.Title}</title>");
sb.AppendLine("<previously-shown/>");
sb.AppendLine("<sub-title lang=\"en\"></sub-title>");
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
xml.WriteAttributeString("stop", stop);
xml.WriteAttributeString("channel", channel.Number.ToString());
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(metadata.Title);
xml.WriteEndElement(); // title
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown
xml.WriteStartElement("sub-title");
xml.WriteAttributeString("lang", "en");
xml.WriteEndElement(); // sub-title
int season = Optional(metadata.SeasonNumber).IfNone(0);
int episode = Optional(metadata.EpisodeNumber).IfNone(0);
if (season > 0 && episode > 0)
{
sb.AppendLine($"<episode-num system=\"xmltv_ns\">{season - 1}.{episode - 1}.0/1</episode-num>");
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "xmltv_ns");
xml.WriteString($"{season - 1}.{episode - 1}.0/1");
xml.WriteEndElement(); // episode-num
}
// sb.AppendLine("<icon src=\"\"/>");
sb.AppendLine($"<desc lang=\"en\">{metadata.Description}</desc>");
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(metadata.Description);
xml.WriteEndElement(); // desc
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
{
sb.AppendLine("<rating system=\"MPAA\">");
sb.AppendLine($"<value>{metadata.ContentRating}</value>");
sb.AppendLine("</rating>");
xml.WriteStartElement("rating");
xml.WriteAttributeString("system", "MPAA");
xml.WriteStartElement("value");
xml.WriteString(metadata.ContentRating);
xml.WriteEndElement(); // value
xml.WriteEndElement(); // rating
}
sb.AppendLine("</programme>");
xml.WriteEndElement(); // programme
}
}
sb.AppendLine("</tv>");
xml.WriteEndElement(); // tv
xml.WriteEndDocument();
return sb.ToString();
xml.Flush();
return Encoding.UTF8.GetString(ms.ToArray());
}
}
}
@@ -0,0 +1,76 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Metadata
{
public static class FallbackMetadataProvider
{
public static MediaMetadata GetFallbackMetadata(MediaItem mediaItem)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MediaMetadata { Title = fileName ?? mediaItem.Path };
if (fileName != null)
{
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
{
return metadata;
}
return localMediaSource.MediaType switch
{
MediaType.TvShow => GetTvShowMetadata(fileName, metadata),
MediaType.Movie => GetMovieMetadata(fileName, metadata),
_ => metadata
};
}
return metadata;
}
private static MediaMetadata GetTvShowMetadata(string fileName, MediaMetadata metadata)
{
try
{
const string PATTERN = @"^(.*?)[.\s-]+[sS](\d+)[eE](\d+).*\.\w+$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.MediaType = MediaType.TvShow;
metadata.Title = match.Groups[1].Value;
metadata.SeasonNumber = int.Parse(match.Groups[2].Value);
metadata.EpisodeNumber = int.Parse(match.Groups[3].Value);
}
}
catch (Exception)
{
// ignored
}
return metadata;
}
private static MediaMetadata GetMovieMetadata(string fileName, MediaMetadata metadata)
{
try
{
const string PATTERN = @"^(.*?)[.\(](\d{4})[.\)].*\.\w+$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.MediaType = MediaType.Movie;
metadata.Title = match.Groups[1].Value;
metadata.Aired = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
}
}
catch (Exception)
{
// ignored
}
return metadata;
}
}
}
+3 -2
View File
@@ -60,7 +60,7 @@ namespace ErsatzTV.Core.Metadata
var knownExtensions = new List<string>
{
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", ".m4p", ".m4v",
".avi", ".wmv", ".mov", ".mkv"
".avi", ".wmv", ".mov", ".mkv", ".ts"
};
var allFiles = Directory.GetFiles(localMediaSource.Folder, "*", SearchOption.AllDirectories)
@@ -82,12 +82,13 @@ namespace ErsatzTV.Core.Metadata
}
// if exists, check if the file was modified
// also, try to re-categorize "other" by refreshing metadata
Seq<MediaItem> modifiedMediaItems = existingMediaItems.Filter(
mediaItem =>
{
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue);
return modified || mediaItem.Metadata == null;
return modified || mediaItem.Metadata == null || mediaItem.Metadata.MediaType == MediaType.Other;
});
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems));
foreach (MediaItem mediaItem in modifiedMediaItems)
+71 -66
View File
@@ -1,27 +1,35 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class LocalMetadataProvider : ILocalMetadataProvider
{
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowEpisodeNfo));
private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
public LocalMetadataProvider(IMediaItemRepository mediaItemRepository) =>
public LocalMetadataProvider(IMediaItemRepository mediaItemRepository, ILogger<LocalMetadataProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_logger = logger;
}
public async Task RefreshMetadata(MediaItem mediaItem)
{
Option<MediaMetadata> maybeMetadata = await LoadMetadata(mediaItem);
MediaMetadata metadata = maybeMetadata.IfNone(() => GetFallbackMetadata(mediaItem));
MediaMetadata metadata =
maybeMetadata.IfNone(() => FallbackMetadataProvider.GetFallbackMetadata(mediaItem));
await ApplyMetadataUpdate(mediaItem, metadata);
}
@@ -49,75 +57,72 @@ namespace ErsatzTV.Core.Metadata
string nfoFileName = Path.ChangeExtension(mediaItem.Path, "nfo");
if (nfoFileName == null || !File.Exists(nfoFileName))
{
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
return None;
}
var tvShowSerializer = new XmlSerializer(typeof(TvShowEpisodeNfo));
var movieSerializer = new XmlSerializer(typeof(MovieNfo));
TryAsync<object> tvShowAttempt = TryAsync(
async () =>
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open);
return tvShowSerializer.Deserialize(fileStream);
});
TryAsync<object> movieAttempt = TryAsync(
async () =>
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open);
return movieSerializer.Deserialize(fileStream);
});
return await choice(tvShowAttempt, movieAttempt).Match<object, Option<MediaMetadata>>(
result =>
{
switch (result)
{
case TvShowEpisodeNfo nfo:
return new MediaMetadata
{
MediaType = MediaType.TvShow,
Title = nfo.ShowTitle,
Subtitle = nfo.Title,
Description = nfo.Outline,
EpisodeNumber = nfo.Episode,
SeasonNumber = nfo.Season,
Aired = GetAired(nfo.Aired)
};
case MovieNfo nfo:
return new MediaMetadata
{
MediaType = MediaType.Movie,
Title = nfo.Title,
Description = nfo.Outline,
ContentRating = nfo.ContentRating,
Aired = GetAired(nfo.Premiered)
};
default:
return None;
}
},
None);
}
private MediaMetadata GetFallbackMetadata(MediaItem mediaItem)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MediaMetadata { Title = fileName ?? mediaItem.Path };
if (fileName != null)
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
{
const string PATTERN = @"^(.*?)[\s-]+[sS](\d+)[eE](\d+)\.\w+$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.MediaType = MediaType.TvShow;
metadata.Title = match.Groups[1].Value;
metadata.SeasonNumber = int.Parse(match.Groups[2].Value);
metadata.EpisodeNumber = int.Parse(match.Groups[3].Value);
}
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None;
}
return metadata;
return localMediaSource.MediaType switch
{
MediaType.Movie => await LoadMovieMetadata(nfoFileName),
MediaType.TvShow => await LoadTvShowMetadata(nfoFileName),
_ => None
};
}
private async Task<Option<MediaMetadata>> LoadTvShowMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<MediaMetadata>>(
nfo => new MediaMetadata
{
MediaType = MediaType.TvShow,
Title = nfo.ShowTitle,
Subtitle = nfo.Title,
Description = nfo.Outline,
EpisodeNumber = nfo.Episode,
SeasonNumber = nfo.Season,
Aired = GetAired(nfo.Aired)
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read TV nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<Option<MediaMetadata>> LoadMovieMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<MovieNfo> maybeNfo = MovieSerializer.Deserialize(fileStream) as MovieNfo;
return maybeNfo.Match<Option<MediaMetadata>>(
nfo => new MediaMetadata
{
MediaType = MediaType.Movie,
Title = nfo.Title,
Description = nfo.Outline,
ContentRating = nfo.ContentRating,
Aired = GetAired(nfo.Premiered)
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName);
return None;
}
}
private static DateTime? GetAired(string aired)
@@ -82,7 +82,7 @@ namespace ErsatzTV.Core.Metadata
private MediaMetadata ProjectToMediaMetadata(FFprobe probeOutput) =>
Optional(probeOutput)
.Filter(json => json != null)
.Filter(json => json?.format != null && json.streams != null)
.ToValidation<BaseError>("Unable to parse ffprobe output")
.ToEither<FFprobe>()
.Match(
+5 -1
View File
@@ -91,7 +91,11 @@
_totalItems = aggregateData.Count;
_pagedData = aggregateData.Skip(state.Page * state.PageSize).Take(state.PageSize);
_pagedData = aggregateData
.Skip(_totalItems <= state.PageSize ? 0 : state.Page * state.PageSize)
.Take(state.PageSize)
.OrderBy(c => c.Name);
return new TableData<MediaCollectionSummaryViewModel> { TotalItems = _totalItems, Items = _pagedData };
}
+2 -2
View File
@@ -49,10 +49,10 @@
}
private Task<IEnumerable<ChannelViewModel>> SearchChannels(string value) =>
_channels.Filter(c => $"{c.Number} - {c.Name}".ToLowerInvariant().Contains(value ?? string.Empty)).AsTask();
_channels.Filter(c => $"{c.Number} - {c.Name}".Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<ProgramScheduleViewModel>> SearchProgramSchedules(string value) =>
_programSchedules.Filter(c => c.Name.ToLowerInvariant().StartsWith(value ?? string.Empty)).AsTask();
_programSchedules.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private async Task HandleSubmitAsync()
+1 -1
View File
@@ -170,7 +170,7 @@
}
private Task<IEnumerable<MediaCollectionViewModel>> SearchMediaCollections(string value) =>
_mediaCollections.Filter(c => c.Name.ToLowerInvariant().StartsWith(value ?? string.Empty)).AsTask();
_mediaCollections.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private async Task SaveChanges()
{
+4 -9
View File
@@ -9,21 +9,16 @@
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(_ => DrawerToggle())"/>
<MudText Typo="Typo.h5" Class="ml-3">ErsatzTV</MudText>
<MudAppBarSpacer/>
<MudTooltip Text="Channels M3U">
<MudIconButton Icon="@Icons.Custom.FileFormats.FileVideo" Color="Color.Inherit" Link="/iptv/channels.m3u" Target="_blank"/>
</MudTooltip>
<MudTooltip Text="EPG XMLTV">
<MudIconButton Icon="@Icons.Custom.FileFormats.FileCode" Color="Color.Inherit" Link="/iptv/xmltv.xml" Target="_blank"/>
</MudTooltip>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink>
<MudDivider Vertical="true" FlexItem="true" DividerType="DividerType.Middle" Class="mx-4 my-5" />
<MudTooltip Text="Discord">
<MudIconButton Icon="fab fa-discord" Color="Color.Inherit" Link="https://discord.gg/hHaJm3yGy6" Target="_blank"/>
</MudTooltip>
<MudTooltip Text="GitHub">
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Inherit" Link="https://github.com/jasongdove/ErsatzTV" Target="_blank"/>
</MudTooltip>
<MudTooltip Text="OpenAPI">
<MudIconButton Icon="@Icons.Material.Filled.Api" Color="Color.Inherit" Link="/swagger" Target="_blank"/>
</MudTooltip>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2">
<MudNavMenu>
+1 -1
View File
@@ -5,7 +5,7 @@ services:
build:
context: .
ports:
- "8409:80"
- "8409:8409"
volumes:
- ersatztv:/root/.local/share/ersatztv
#- /media/shared:/media/shared:ro