commit f47b51127c1595c2dc2d5d6c465e53aa88c0e556 Author: icedream Date: Thu May 1 07:31:05 2014 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0bd02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,184 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +*.pubxml + +# NuGet Packages Directory +packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +!packages/build/ + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ diff --git a/.nuget/NuGet.Config b/.nuget/NuGet.Config new file mode 100644 index 0000000..67f8ea0 --- /dev/null +++ b/.nuget/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.nuget/NuGet.exe b/.nuget/NuGet.exe new file mode 100644 index 0000000..3ffdd33 Binary files /dev/null and b/.nuget/NuGet.exe differ diff --git a/.nuget/NuGet.targets b/.nuget/NuGet.targets new file mode 100644 index 0000000..f943812 --- /dev/null +++ b/.nuget/NuGet.targets @@ -0,0 +1,144 @@ + + + + $(MSBuildProjectDirectory)\..\ + + + false + + + false + + + true + + + false + + + + + + + + + + + $([System.IO.Path]::Combine($(SolutionDir), ".nuget")) + + + + + $(SolutionDir).nuget + + + + $(MSBuildProjectDirectory)\packages.$(MSBuildProjectName.Replace(' ', '_')).config + $(MSBuildProjectDirectory)\packages.$(MSBuildProjectName).config + + + + $(MSBuildProjectDirectory)\packages.config + $(PackagesProjectConfig) + + + + + $(NuGetToolsPath)\NuGet.exe + @(PackageSource) + + "$(NuGetExePath)" + mono --runtime=v4.0.30319 $(NuGetExePath) + + $(TargetDir.Trim('\\')) + + -RequireConsent + -NonInteractive + + "$(SolutionDir) " + "$(SolutionDir)" + + + $(NuGetCommand) install "$(PackagesConfig)" -source "$(PackageSources)" $(NonInteractiveSwitch) $(RequireConsentSwitch) -solutionDir $(PaddedSolutionDir) + $(NuGetCommand) pack "$(ProjectPath)" -Properties "Configuration=$(Configuration);Platform=$(Platform)" $(NonInteractiveSwitch) -OutputDirectory "$(PackageOutputDir)" -symbols + + + + RestorePackages; + $(BuildDependsOn); + + + + + $(BuildDependsOn); + BuildPackage; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ShoutcastBridge.sln b/ShoutcastBridge.sln new file mode 100644 index 0000000..2e8b9be --- /dev/null +++ b/ShoutcastBridge.sln @@ -0,0 +1,29 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2013 +VisualStudioVersion = 12.0.21005.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShoutcastBridge", "src\sc_bridge\ShoutcastBridge.csproj", "{491CCFC0-2C51-451F-92AE-056B0474AD7E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{E4EA1463-A0D4-4D06-9F76-8FD55C5797CE}" + ProjectSection(SolutionItems) = preProject + .nuget\NuGet.Config = .nuget\NuGet.Config + .nuget\NuGet.exe = .nuget\NuGet.exe + .nuget\NuGet.targets = .nuget\NuGet.targets + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {491CCFC0-2C51-451F-92AE-056B0474AD7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {491CCFC0-2C51-451F-92AE-056B0474AD7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {491CCFC0-2C51-451F-92AE-056B0474AD7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {491CCFC0-2C51-451F-92AE-056B0474AD7E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/sc_bridge/AdminHandler.cs b/src/sc_bridge/AdminHandler.cs new file mode 100644 index 0000000..59918d5 --- /dev/null +++ b/src/sc_bridge/AdminHandler.cs @@ -0,0 +1,46 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +namespace AFR.ShoutcastBridge +{ + internal class AdminHandler : IHttpRequestHandler + { + private readonly ShoutcastBridgeServer _shoutcastBridge; + + public AdminHandler(ShoutcastBridgeServer bridgeServer) + { + _shoutcastBridge = bridgeServer; + } + + public Task Handle(IHttpContext context, Func next) + { + string mode; + context.Request.QueryString.TryGetByName("mode", out mode); + + switch (mode.ToLower()) + { + case "updinfo": + string password; + string song; + context.Request.QueryString.TryGetByName("pass", out password); + context.Request.QueryString.TryGetByName("song", out song); + + if (string.IsNullOrEmpty(password)) + break; + + context.Response = HttpResponse.CreateWithMessage(HttpResponseCode.Ok, _shoutcastBridge.UpdateMetadata((IPEndPoint)context.RemoteEndPoint, password, song) + ? "OK" + : "Could not update metadata", + context.Request.Headers.KeepAliveConnection()); + break; + default: + context.Response = HttpResponse.CreateWithMessage(HttpResponseCode.Ok, "Unknown mode", context.Request.Headers.KeepAliveConnection()); + break; + } + + + return Task.Factory.GetCompleted(); + } + } +} \ No newline at end of file diff --git a/src/sc_bridge/Config.xml b/src/sc_bridge/Config.xml new file mode 100644 index 0000000..efe5ed8 --- /dev/null +++ b/src/sc_bridge/Config.xml @@ -0,0 +1,37 @@ + + + + + + 61121 + + + 8000 + /stream + + + + internal.listen.rekt.in + 61120 + source + testing + /afr-herp/live + + + + internal.listen.rekt.in + 61120 + source + testing + /afr-derp/live + + + + internal.listen.rekt.in + 61120 + source + testing + /afr-lurk/live + + + \ No newline at end of file diff --git a/src/sc_bridge/IcecastWriter.cs b/src/sc_bridge/IcecastWriter.cs new file mode 100644 index 0000000..639eb94 --- /dev/null +++ b/src/sc_bridge/IcecastWriter.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Web; +using log4net; + +namespace AFR.ShoutcastBridge +{ + public class IcecastWriter + { + private NetworkStream _ns; + private StreamReader _sr; + private StreamWriter _sw; + private TcpClient _tcp; + + public IcecastWriter() + { + Hostname = "127.0.0.1"; + Port = 8000; + Username = "source"; + Password = "hackme"; + ContentType = "audio/mpeg"; + Description = "Livestream"; + Name = "Livestream"; + Genre = "Various"; + Public = false; + } + + public string Name { get; set; } + public string Genre { get; set; } + public string Url { get; set; } + public ushort KBitrate { get; set; } + public bool Public { get; set; } + public string Description { get; set; } + public string ContentType { get; set; } + public string Hostname { get; set; } + public ushort Port { get; set; } + public string Mountpoint { get; set; } + public string Username { get; set; } + public string Password { get; set; } + + public bool Connected + { + get { return _ns != null && _sr != null && _sw != null && _tcp != null && _tcp.Connected; } + } + + public bool Open() + { + try + { + _tcp = new TcpClient(Hostname, Port); + _ns = _tcp.GetStream(); + _sr = new StreamReader(_ns); + _sw = new StreamWriter(_ns) {AutoFlush = true}; + + // Request headers + _sw.WriteLine("SOURCE {0} ICE/1.0", Mountpoint); + _sw.WriteLine("content-type: {0}", ContentType); + _sw.WriteLine("Authorization: Basic {0}", + Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Format("{0}:{1}", Username, Password)))); + _sw.WriteLine("ice-name: {0}", Name); + _sw.WriteLine("ice-url: {0}", Url); + _sw.WriteLine("ice-genre: {0}", Genre); + _sw.WriteLine("ice-bitrate: {0}", KBitrate); + _sw.WriteLine("ice-private: {0}", Public ? 0 : 1); + _sw.WriteLine("ice-public: {0}", Public ? 1 : 0); + _sw.WriteLine("ice-description: {0}", Description); + _sw.WriteLine("ice-audio-info: ice-bitrate={0}", KBitrate); + _sw.WriteLine(); + + // Authorized? + string statusLine = _sr.ReadLine(); + if (statusLine == null) + { + LogManager.GetLogger("IcecastWriter").ErrorFormat("Icecast socket error: No response"); + return false; + } + string[] status = statusLine.Split(' '); + + if (status[1] == "200") + // Now we can stream + return true; + + // Something went wrong + LogManager.GetLogger("IcecastWriter").ErrorFormat("Icecast HTTP error: {0} {1}", status[1], status[2]); + Close(); + return false; + } + catch (Exception error) + { + LogManager.GetLogger("IcecastWriter").ErrorFormat("BUG IN ICECASTWRITING: {0}", error); + // Something went wrong + Close(); + return false; + } + } + + public void Push(byte[] data) + { + try + { + if (_ns == null || _sr == null || _sw == null || _tcp == null || !_tcp.Connected) + return; + _sw.BaseStream.WriteAsync(data, 0, data.Length); + } + catch (Exception error) + { + LogManager.GetLogger("IcecastWriter").ErrorFormat("Icecast socket error while pushing data: {0}", error); + Close(); + } + } + + public bool SendMetadata(string song, bool tryOnce = false) + { + if (song == null) + song = string.Empty; + + var reqQuery = new Dictionary + { + //{"pass",Password}, + {"mode", "updinfo"}, + {"mount", Mountpoint}, + {"song", song} + }; + + var reqUriBuilder = new UriBuilder("http", Hostname, Port, "/admin/metadata") + { + Query = + string.Join("&", + reqQuery.Keys.Select( + key => + string.Format("{0}={1}", HttpUtility.UrlEncode(key), + HttpUtility.UrlEncode(reqQuery[key])))), + //UserName = Username, + //Password = Password + }; + + var req = (HttpWebRequest) WebRequest.Create(reqUriBuilder.Uri); + req.UserAgent = string.Format("ShoutcastBridge/{0}", Assembly.GetExecutingAssembly().GetName().Version); + req.Credentials = new NetworkCredential(Username, Password); + + try + { + req.GetResponse(); + return true; + } + catch (Exception error) + { + LogManager.GetLogger("IcecastWriter").WarnFormat("Uri: {0}", reqUriBuilder.Uri); + LogManager.GetLogger("IcecastWriter").WarnFormat("Could not send metadata: {0}", error.Message); + if (tryOnce) + return false; + + Thread.Sleep(1000); + return SendMetadata(song, true); + } + } + + public void Close() + { + LogManager.GetLogger("IcecastWriter").Info("Disconnecting"); + if (_ns == null || _sr == null || _sw == null || _tcp == null || !_tcp.Connected) + return; + _sr.Dispose(); + _sw.Dispose(); + _ns.Dispose(); + _tcp.Close(); + } + } +} \ No newline at end of file diff --git a/src/sc_bridge/Program.cs b/src/sc_bridge/Program.cs new file mode 100644 index 0000000..db0c3e7 --- /dev/null +++ b/src/sc_bridge/Program.cs @@ -0,0 +1,192 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Xml.Linq; +using log4net; +using log4net.Appender; +using log4net.Config; +using log4net.Core; + +namespace AFR.ShoutcastBridge +{ + class Program + { + private static ILog _log; + static int Main(string[] args) + { + var configFile = "Config.xml"; + + // Arguments + if (args.Any()) + { + configFile = args[0]; + } + + // Initialize logging + BasicConfigurator.Configure(new ConsoleAppender() + { + Layout = new log4net.Layout.PatternLayout(@"[%logger] %level: %message%newline"), + Threshold = Level.Debug + }); + _log = LogManager.GetLogger("bridgeapp"); + + // Configuration setup + XDocument xdoc; + if (!File.Exists(configFile)) + { + _log.Error("Invalid configuration file given (file not found)."); + return -2; + } + try + { + xdoc = XDocument.Load(configFile); + if (xdoc.Root == null) + { + _log.Error("Invalid configuration file given (could not parse file, no XML root found)."); + return -2; + } + } + catch + { + _log.Error("Invalid configuration file given (could not parse file, any typos in the XML code?)."); + return -2; + } + + // Get ip address to work with + var ip = IPAddress.Any; + var ipElements = xdoc.Root.Elements("ip").ToArray(); + if (ipElements.Any()) + { + if (!IPAddress.TryParse(ipElements.First().Value, out ip)) + { + _log.Error("Invalid IP address in configuration, please check the configuration file."); + return -1; + } + if (ipElements.Count() > 1) + _log.Warn("Multiple ip addresses defined, using first one. Please check the configuration file as soon as possible and fix this."); + } + + // Get port to work with + var port = (ushort)8000; + var portElements = xdoc.Root.Elements("port").ToArray(); + if (portElements.Any()) + { + if (!ushort.TryParse(portElements.First().Value, out port)) + { + _log.Error("Invalid port in configuration, please check the configuration file."); + return -1; + } + if (portElements.Count() > 1) + _log.Warn("Multiple ports defined, using first one. Please check the configuration file as soon as possible and fix this."); + } + + var sb = new ShoutcastBridgeServer(ip, port); + + // Mountpoint configuration + var mpElements = xdoc.Root.Descendants("stream").ToArray(); + if (!mpElements.Any()) + { + _log.Error("No mountpoints/streams configured. Please define at least one in the configuration file."); + return -1; + } + foreach (var mpElement in mpElements) + { + var pwAttr = mpElement.Attribute("password"); + string pw; + if (pwAttr == null || string.IsNullOrEmpty(pw = pwAttr.Value)) + { + _log.Error("Mountpoint without password defined. All mountpoints need a unique password."); + return -1; + } + if (mpElements.Count(m => m.Attribute("password").Value == pwAttr.Value) > 1) + { + _log.ErrorFormat("Too many mountpoints with same password ({0}) defined. All mountpoints need a unique password.", pwAttr.Value); + return -1; + } + + ushort parsedPort; + var mpPortElements = mpElement.Elements("port").ToArray(); + if (!mpPortElements.Any()) + { + //mpElement.Add(new XElement("port", 8000)); + //mpPortElements = mpElement.Elements("port").ToArray(); + parsedPort = 8000; + } + else if (mpPortElements.Count() > 1) + { + _log.WarnFormat("Too many icecast ports defined for mountpoint pw={0}, please only define one.", pw); + return -1; + } + else if (!ushort.TryParse(mpPortElements.Single().Value, out parsedPort)) + { + _log.ErrorFormat("Invalid icecast port defined for mountpoint pw={0}, please define a proper port number.", pw); + return -1; + } + + var mpHostElements = mpElement.Elements("host").ToArray(); + if (!mpHostElements.Any()) + { + mpElement.Add(new XElement("host", IPAddress.Loopback.ToString())); + mpHostElements = mpElement.Elements("host").ToArray(); + } + else if (mpHostElements.Count() > 1) + { + _log.WarnFormat("Too many icecast host names defined for mountpoint pw={0}, please only define one.", pw); + return -1; + } + + var mpMountpointElements = mpElement.Elements("mountpoint").ToArray(); + if (!mpMountpointElements.Any()) + { + mpElement.Add(new XElement("mountpoint", "/stream")); + mpMountpointElements = mpElement.Elements("mountpoint").ToArray(); // yes, this is rude. + } + else if (mpMountpointElements.Count() > 1) + { + _log.WarnFormat( + "Too many icecast mountpoint names defined for mountpoint pw={0}, please only define one.", pw); + return -1; + } + + var mpPasswordElements = mpElement.Elements("password").ToArray(); + if (!mpPasswordElements.Any()) + { + mpElement.Add(new XElement("password", "hackme")); + mpPasswordElements = mpElement.Elements("password").ToArray(); + } + else if (mpPasswordElements.Count() > 1) + { + _log.WarnFormat("Too many icecast passwords defined for mountpoint pw={0}, please only define one.", pw); + return -1; + } + + var mpUsernameElements = mpElement.Elements("username").ToArray(); + if (!mpUsernameElements.Any()) + { + mpElement.Add(new XElement("username", "source")); + mpUsernameElements = mpElement.Elements("username").ToArray(); + } + else if (mpUsernameElements.Count() > 1) + { + _log.WarnFormat("Too many icecast usernames defined for mountpoint pw={0}, please only define one.", pw); + return -1; + } + + sb.AddMountpoint(pw, new ShoutcastBridgeMountpoint() + { + IcecastHost = mpHostElements.Single().Value, + IcecastPort = parsedPort, + IcecastMountpoint = mpMountpointElements.Single().Value, + IcecastUsername = mpUsernameElements.Single().Value, + IcecastPassword = mpPasswordElements.Single().Value + }); + } + + _log.DebugFormat("Starting up Shoutcast bridge now."); + sb.Start(); + + return 0; // We'll never get here anyways... except on errors `-` + } + } +} diff --git a/src/sc_bridge/Properties/AssemblyInfo.cs b/src/sc_bridge/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..4ddc8fe --- /dev/null +++ b/src/sc_bridge/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// Allgemeine Informationen über eine Assembly werden über die folgenden +// Attribute gesteuert. Ändern Sie diese Attributwerte, um die Informationen zu ändern, +// die mit einer Assembly verknüpft sind. +[assembly: AssemblyTitle("AFR.ShoutcastBridge")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Hewlett-Packard")] +[assembly: AssemblyProduct("AFR.ShoutcastBridge")] +[assembly: AssemblyCopyright("Copyright © Hewlett-Packard 2014")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Durch Festlegen von ComVisible auf "false" werden die Typen in dieser Assembly unsichtbar +// für COM-Komponenten. Wenn Sie auf einen Typ in dieser Assembly von +// COM zugreifen müssen, legen Sie das ComVisible-Attribut für diesen Typ auf "true" fest. +[assembly: ComVisible(false)] + +// Die folgende GUID bestimmt die ID der Typbibliothek, wenn dieses Projekt für COM verfügbar gemacht wird +[assembly: Guid("bf523a9d-260e-4949-bbdd-0c0acccc84da")] + +// Versionsinformationen für eine Assembly bestehen aus den folgenden vier Werten: +// +// Hauptversion +// Nebenversion +// Buildnummer +// Revision +// +// Sie können alle Werte angeben oder die standardmäßigen Build- und Revisionsnummern +// übernehmen, indem Sie "*" eingeben: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/src/sc_bridge/ShoutcastAuthenticationEventHandler.cs b/src/sc_bridge/ShoutcastAuthenticationEventHandler.cs new file mode 100644 index 0000000..efc71ae --- /dev/null +++ b/src/sc_bridge/ShoutcastAuthenticationEventHandler.cs @@ -0,0 +1,4 @@ +namespace AFR.ShoutcastBridge +{ + public delegate string ShoutcastAuthenticationEventHandler(string password); +} \ No newline at end of file diff --git a/src/sc_bridge/ShoutcastBridge.csproj b/src/sc_bridge/ShoutcastBridge.csproj new file mode 100644 index 0000000..9ff6d53 --- /dev/null +++ b/src/sc_bridge/ShoutcastBridge.csproj @@ -0,0 +1,92 @@ + + + + + Debug + AnyCPU + {491CCFC0-2C51-451F-92AE-056B0474AD7E} + Exe + Properties + AFR.ShoutcastBridge + sc_bridge + v4.5 + 512 + + ..\..\ + true + + + AnyCPU + true + full + false + DEBUG;TRACE + prompt + 4 + false + + + AnyCPU + pdbonly + true + TRACE + prompt + 4 + false + + + $(SolutionDir)\bin\$(Configuration)\$(Platform)\ + $(SolutionDir)\obj\$(TargetName)\$(Configuration)\$(Platform)\ + $(SolutionDir)\obj\$(TargetName)\$(Configuration)\$(Platform)\ + $(SolutionDir)\obj\$(TargetName)\$(Configuration)\$(Platform)\ + $(SolutionDir)\bin\$(Configuration)\$(Platform)\ + + + + False + ..\packages\log4net.2.0.3\lib\net40-full\log4net.dll + + + False + ..\packages\Newtonsoft.Json.6.0.3\lib\net45\Newtonsoft.Json.dll + + + + + + + + ..\packages\uHttpSharp.0.1.4.7\lib\net40\uhttpsharp.dll + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + \ No newline at end of file diff --git a/src/sc_bridge/ShoutcastBridgeMountpoint.cs b/src/sc_bridge/ShoutcastBridgeMountpoint.cs new file mode 100644 index 0000000..9d80bf3 --- /dev/null +++ b/src/sc_bridge/ShoutcastBridgeMountpoint.cs @@ -0,0 +1,11 @@ +namespace AFR.ShoutcastBridge +{ + public class ShoutcastBridgeMountpoint + { + public string IcecastHost { get; set; } + public ushort IcecastPort { get; set; } + public string IcecastMountpoint { get; set; } + public string IcecastUsername { get; set; } + public string IcecastPassword { get; set; } + } +} \ No newline at end of file diff --git a/src/sc_bridge/ShoutcastBridgeServer.cs b/src/sc_bridge/ShoutcastBridgeServer.cs new file mode 100644 index 0000000..cc30fb7 --- /dev/null +++ b/src/sc_bridge/ShoutcastBridgeServer.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; + +namespace AFR.ShoutcastBridge +{ + public class ShoutcastBridgeServer + { + private readonly ILog _log; + private readonly ILog _adminlog; + private readonly ILog _sourcelog; + + private TcpListener _sourceServer; + + private HttpServer _adminServer; + + private readonly IPAddress _ip; + + private readonly ushort _port; + + private readonly Dictionary _mountpoints = + new Dictionary(); + + private readonly Dictionary> _connectedMountpoints = + new Dictionary>(); + + public ShoutcastBridgeServer(IPAddress ip, ushort port) + { + _ip = ip; + _port = port; + _log = LogManager.GetLogger("ShoutcastBridge"); + _adminlog = LogManager.GetLogger("Shout1Admin"); + _sourcelog = LogManager.GetLogger("Shout1Source"); + } + + public void Start() + { + _log.DebugFormat("Starting SCv1 source server on port {0}...", _port + 1); + _sourceServer = new TcpListener(_ip, _port + 1); + try + { + _sourceServer.Start(); + } + catch (Exception error) + { + _log.ErrorFormat("Could not start up source server. {0}", error.Message); + } + + _log.DebugFormat("Starting SC admin HTTP server on port {0}...", _port); + _adminServer = new HttpServer(new HttpRequestProvider()); + try + { + _adminServer.Use(new TcpListenerAdapter(new TcpListener(_ip, _port))); + } + catch (Exception error) + { + _log.ErrorFormat("Could not start up admin HTTP server. {0}", error.Message); + } + _adminServer.Use(new AdminHandler(this)); + try + { + _adminServer.Start(); + } + catch (Exception error) + { + _log.ErrorFormat("Could not start up admin HTTP server. {0}", error.Message); + } + + _log.Info("Shoutcast bridge started up successfully, now ready."); + + while (true) + { + Socket sock; + try + { + sock = _sourceServer.AcceptSocket(); + } + catch + { + continue; + } + + _log.Debug("Accepting incoming connection"); + var ns = new NetworkStream(sock, true); + var srs = new ShoutcastReadingStream((IPEndPoint)sock.RemoteEndPoint, ns); + srs.Authenticating += password => + { + if (!_mountpoints.ContainsKey(password)) + { + _sourcelog.DebugFormat("[{0}] Connection declined: Invalid password", srs.ClientEndPoint); + return "Invalid password"; + } + + if (_connectedMountpoints.ContainsKey(password)) + { + _sourcelog.DebugFormat("[{0}] Connection declined: Stream already in use", srs.ClientEndPoint); + return "Stream already in use"; + } + + var mp = _mountpoints[password]; + + var writer = new IcecastWriter() + { + Hostname = mp.IcecastHost, + Port = mp.IcecastPort, + Mountpoint = mp.IcecastMountpoint, + Username = string.IsNullOrEmpty(mp.IcecastUsername) ? "source" : mp.IcecastUsername, + Password = mp.IcecastPassword, + ContentType = "undefined" /* will be defined on first packet */ + }; + + _connectedMountpoints.Add(password, new Tuple(srs, writer)); + + + _sourcelog.DebugFormat("[{0}] Testing connection to master server...", srs.ClientEndPoint); + if (!writer.Open()) + { + _sourcelog.DebugFormat("[{0}] Connection declined: Master server was unavailable", srs.ClientEndPoint); + return "Master server unavailable, try again later."; + } + writer.Close(); + + _sourcelog.DebugFormat("[{0}] Connection accepted", srs.ClientEndPoint); + return "OK2"; + }; + srs.Disconnected += () => + { + _sourcelog.DebugFormat("[{0}] Disconnected", srs.ClientEndPoint); + var pws = _connectedMountpoints.Where(s => s.Value.Item1 == srs).Select(s => s.Key).ToArray(); + if (!pws.Any()) + { + _sourcelog.ErrorFormat("[{0}] Could not find any connected mountpoints associated with this IP, dangling connection! This is a bug!", srs.ClientEndPoint); + } + foreach (var pw in pws.ToArray()) + { + _connectedMountpoints[pw].Item2.Close(); // close Icecast connection + _connectedMountpoints.Remove(pw); // remove from registered connections + } + }; + srs.ReceivedData += data => + { + var conns = _connectedMountpoints.Where(s => s.Value.Item1 == srs).ToArray(); + if (!conns.Any()) + { + _sourcelog.ErrorFormat("Dangling source connection from {0} - nothing bound to it!", srs.ClientEndPoint); + } + foreach (var conn in conns) + { + var icecast = conn.Value.Item2; + var shoutcast = conn.Value.Item1; + + if (!icecast.Connected) + { + _sourcelog.InfoFormat("[{0}] Connecting to {1}:{2}{3}...", srs.ClientEndPoint, icecast.Hostname, icecast.Port, icecast.Mountpoint); + icecast.Name = icecast.Description = shoutcast.StreamName; + icecast.Genre = shoutcast.StreamGenre; + icecast.KBitrate = shoutcast.StreamKBitrate; + icecast.Url = shoutcast.StreamUrl; + icecast.Public = shoutcast.StreamPublic; + if (icecast.ContentType == "undefined") + switch (BitConverter.ToString(data.Take(2).ToArray()).Replace("-", "").ToLower()) + { + case "4f67": // OGG container + _sourcelog.DebugFormat("[{0}] Detected OGG audio container", srs.ClientEndPoint); + icecast.ContentType = "audio/ogg"; + break; + case "fff9": // AAC + _sourcelog.DebugFormat("[{0}] Detected AAC-LC data", srs.ClientEndPoint); + icecast.ContentType = "audio/aac"; + break; + default: + _sourcelog.DebugFormat("[{0}] Assuming MP3 codec", srs.ClientEndPoint); + icecast.ContentType = "audio/mpeg"; + break; + } + if (!icecast.Open()) + { + _sourcelog.ErrorFormat("[{0}] Could not connect with Icecast, retrying on next packet", srs.ClientEndPoint); + continue; + } + _sourcelog.InfoFormat("[{0}] Connected!", srs.ClientEndPoint); + // TODO: Sync metadata after connection immediately! + } + + icecast.Push(data); + } + }; + srs.Start(); + } + } + + public void Stop() + { + _adminServer.Dispose(); + _sourceServer.Stop(); + } + + public void AddMountpoint(string password, ShoutcastBridgeMountpoint mp) + { + _log.DebugFormat("Adding mountpoint: {0}:{1}, mountpoint \"{2}\"", mp.IcecastHost, mp.IcecastPort, mp.IcecastMountpoint); + _mountpoints.Add(password, mp); + } + + public bool UpdateMetadata(IPEndPoint remoteEndPoint, string password, string song) + { + // Is stream connected? + if (!_connectedMountpoints.ContainsKey(password)) + { + _adminlog.DebugFormat("[{0}] Metadata update declined: Invalid password", remoteEndPoint); + return false; + } + + // Authorize client - requirement: same IP + if (!_connectedMountpoints[password].Item1.ClientEndPoint.Address.Equals(remoteEndPoint.Address)) + { + _adminlog.DebugFormat("[{0}] Metadata update declined: IP mismatch", remoteEndPoint); + return false; + } + + // Finally update metadata + _adminlog.DebugFormat("[{0}] Metadata update: {1}", remoteEndPoint, song); + return _connectedMountpoints[password].Item2.SendMetadata(song); + } + } +} diff --git a/src/sc_bridge/ShoutcastDataEventHandler.cs b/src/sc_bridge/ShoutcastDataEventHandler.cs new file mode 100644 index 0000000..ffb0248 --- /dev/null +++ b/src/sc_bridge/ShoutcastDataEventHandler.cs @@ -0,0 +1,4 @@ +namespace AFR.ShoutcastBridge +{ + public delegate void ShoutcastDataEventHandler(byte[] data); +} \ No newline at end of file diff --git a/src/sc_bridge/ShoutcastDisconnectEventHandler.cs b/src/sc_bridge/ShoutcastDisconnectEventHandler.cs new file mode 100644 index 0000000..687b0f6 --- /dev/null +++ b/src/sc_bridge/ShoutcastDisconnectEventHandler.cs @@ -0,0 +1,4 @@ +namespace AFR.ShoutcastBridge +{ + public delegate void ShoutcastDisconnectEventHandler(); +} \ No newline at end of file diff --git a/src/sc_bridge/ShoutcastReadingStream.cs b/src/sc_bridge/ShoutcastReadingStream.cs new file mode 100644 index 0000000..80e8946 --- /dev/null +++ b/src/sc_bridge/ShoutcastReadingStream.cs @@ -0,0 +1,148 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace AFR.ShoutcastBridge +{ + public class ShoutcastReadingStream + { + private readonly NetworkStream _ns; + private readonly StreamReader _sr; + private readonly StreamWriter _sw; + private CancellationTokenSource _cancel; + private Task _task; + private readonly Dictionary _headers = new Dictionary(); + + public string StreamName + { + get { return _headers.ContainsKey("icy-name") ? _headers["icy-name"] : string.Empty; } + } + + public string StreamGenre + { + get { return _headers.ContainsKey("icy-genre") ? _headers["icy-genre"] : string.Empty; } + } + + public bool StreamPublic + { + get { return _headers.ContainsKey("icy-pub") && int.Parse(_headers["icy-pub"]) >= 1; } + } + + public ushort StreamKBitrate + { + get { return _headers.ContainsKey("icy-br") ? ushort.Parse(_headers["icy-br"]) : (ushort)0; } + } + + public string StreamUrl + { + get { return _headers.ContainsKey("icy-url") ? _headers["icy-url"] : string.Empty; } + } + + public string StreamIrc + { + get { return _headers.ContainsKey("icy-irc") ? _headers["icy-irc"] : string.Empty; } + } + + public IPEndPoint ClientEndPoint { get; private set; } + + public ShoutcastReadingStream(IPEndPoint ep, NetworkStream ns) + { + ClientEndPoint = ep; + _ns = ns; + _task = null; + _sr = new StreamReader(ns); + _sw = new StreamWriter(ns) {AutoFlush = true}; + } + + public void Start() + { + _cancel = new CancellationTokenSource(); + _task = Task.Factory.StartNew(_start, _cancel.Token).ContinueWith(task => + { + if (Disconnected != null) + Disconnected(); + }); + } + + private void _start() + { + var password = _sr.ReadLine(); + + // Check if any password given + if (password == null) + return; + + // Check if password correct + password = password.TrimEnd('\n', '\r'); + var status = Authenticating(password); + if (Authenticating != null && status != "OK2") + { + _sw.WriteLine(status); + _ns.Dispose(); + return; + } + + _sw.WriteLine(status); + + // Headers + var line = _sr.ReadLine(); + while (line != null && line.Trim().Any()) + { + var lineSplit = line.Split(':'); + if (lineSplit.Length < 2) + continue; // Invalid header line + + var name = lineSplit[0]; + var value = string.Join(":", lineSplit.Skip(1)); + + if (_headers.ContainsKey(name)) + _headers[name] = value; + else + _headers.Add(name, value); + + line = _sr.ReadLine(); + } + + // Content + var data = new byte[2048]; + try + { + int length; + while ((length = _ns.Read(data, 0, data.Length)) > 0) + { + _cancel.Token.ThrowIfCancellationRequested(); + if (ReceivedData != null) + ReceivedData(data.Take(length).ToArray()); + } + } + catch + { + LogManager.GetLogger("ShoutRead").Warn("Exception in reading loop while reading. Interpreting as disconnection."); + } + } + + public void Stop() + { + if (_cancel == null || _task == null) + return; + + _cancel.Cancel(); + _task.Wait(); + _task = null; + } + + ~ShoutcastReadingStream() + { + _sr.Dispose(); + _sw.Dispose(); + } + + public event ShoutcastDisconnectEventHandler Disconnected; + public event ShoutcastAuthenticationEventHandler Authenticating; + public event ShoutcastDataEventHandler ReceivedData; + } +} diff --git a/src/sc_bridge/app.config b/src/sc_bridge/app.config new file mode 100644 index 0000000..edb9a1d --- /dev/null +++ b/src/sc_bridge/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/sc_bridge/packages.config b/src/sc_bridge/packages.config new file mode 100644 index 0000000..cadf7cd --- /dev/null +++ b/src/sc_bridge/packages.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file