Windows Runtime 播放自定义网络媒体

我的新项目,インターネットラジオステーション<音泉> 的第三方客户端,开发代号 Onsen Tamako,已经到了收尾的时候了。

音泉有个特点就是屏蔽非日本 IP,日本国外用户虽然能登录官网并且查看各自信息,但是想要收听或者收看广播那是不行的,服务器会给你 403。

但是在一个月前开始这项目时进行的测试发现,音泉会被国产视频网站几百年前就玩烂了的 X-Forwarded-For 给迷惑。

X-Forwarded-For HTTP Header

简单的说,X-Forwarded-For HTTP header 是告诉服务器,我是个透明代理,我是帮这个 IP 转发信息的;或者用在负载均衡器内部,用于标识外部 IP。这本来是个非标准 HTTP Header,但是这么久过去了也就变公认标准了。总之音泉不知道是 Amazon AWS 就用了这个,还是自己的 Apache 配置不到位,只要发请求时带上这个 header,给一个虚假的日本 IP,就可以顺利得到媒体文件了。

Onsen Tamako

回到 Onsen Tamako 的话题上来。这个 UWP 需要使用自定义 HTTP 请求,加上 X-Forwarded-For header 来绕过 IP 限制,然而不管是 XAML 的 MediaElement (MSDN) 还是用于后台播放的 MediaPlayer (MSDN),本身都是不支持自定义 HTTP 请求的。

于是经历了以下几个阶段

  1. 最初的实现是依靠下载,依然不能在线看,那我就下载下来,但是这个方法太丑了,让它作为历史过去吧。

  2. 和 Soymilk 一样,依靠 FFMpeg 进行解码,制作自定义 MediaStreamSource (MSDN) 进行播放,但是这需要带上 7MB+ 的 FFMpeg 库,所以它是历史。

  3. 自己实现 MP3 和 MP4 分离。这个其实还是 MediaStreamSource,MP3 的部分我参考 NAudio 的代码,很快就完成了,但是 MP4 格式比 FLV 还复杂的多,没敢尝试,暂时还是 FFMpeg 顶上。

  4. 大吃一惊,原来 MediaElementBackgroundMediaPlayer 本身就支持播放一个 Stream!

播放自定义 Stream

废话这么多,最终需要实现的就只有一个 IRandomAccessStream (MSDN),直接贴代码。

using System;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.Foundation;
using Windows.Storage.Streams;
using Windows.Web.Http;

namespace OnsenTamako
{
    public sealed class HttpStreamingStream : IRandomAccessStreamWithContentType
    {
        public bool CanRead => true;

        public bool CanWrite => false;

        public ulong Position { get; private set; }

        private readonly ulong _size;
        public ulong Size
        {
            get { return _size; }
            set { throw new NotSupportedException(); }
        }

        public string ContentType { get; }

        readonly HttpClient _client;
        readonly Uri _uri;

        private HttpStreamingStream(HttpClient client, Uri uri, ulong size, string contentType)
        {
            _client = client;
            _uri = uri;
            _size = size;
            ContentType = contentType;
        }

        public IRandomAccessStream CloneStream() { throw new NotImplementedException(); }

        public void Dispose() { }

        public IAsyncOperation<bool> FlushAsync() { throw new NotSupportedException(); }

        public IInputStream GetInputStreamAt(ulong position) { throw new NotImplementedException(); }

        public IOutputStream GetOutputStreamAt(ulong position) { throw new NotSupportedException(); }

        public IAsyncOperationWithProgress<IBuffer, uint> ReadAsync(IBuffer buffer, uint count, InputStreamOptions options)
        {
            return AsyncInfo.Run<IBuffer, uint>(async (cancelToken, progress) =>
            {
                progress.Report(0);

                using (var request = new HttpRequestMessage(HttpMethod.Get, _uri))
                {
                    request.Headers.TryAppendWithoutValidation("Range", $"bytes={Position}-{Position + count}");

                    using (var response = await _client.SendRequestAsync(request, HttpCompletionOption.ResponseHeadersRead))
                    {
                        response.EnsureSuccessStatusCode();

                        using (var content = response.Content)
                        using (var stream = await content.ReadAsInputStreamAsync())
                            return await stream.ReadAsync(buffer, count, options).AsTask(cancelToken, progress);
                    }
                }
            });
        }

        public void Seek(ulong position)
        {
            Position = position;
        }

        public IAsyncOperationWithProgress<uint, uint> WriteAsync(IBuffer buffer) { throw new NotSupportedException(); }

        static async Task<HttpStreamingStream> CreateAsyncInternal(HttpClient client, Uri uri)
        {
            using (var request = new HttpRequestMessage(HttpMethod.Head, uri))
            using (var response = await client.SendRequestAsync(request))
            {
                response.EnsureSuccessStatusCode();

                using (var content = response.Content)
                {
                    var size = content.Headers.ContentLength;
                    if (size == null)
                        throw new NotSupportedException("The requested Uri doesn't support Content-Length");

                    string acceptRanges;
                    if (!response.Headers.TryGetValue("Accept-Ranges", out acceptRanges) || acceptRanges != "bytes")
                        throw new NotSupportedException("The requested Uri may not support ranged request.");

                    var contentType = response.Content.Headers.ContentType?.MediaType;

                    return new HttpStreamingStream(client, uri, size.Value, contentType);
                }
            }
        }

        public static IAsyncOperation<HttpStreamingStream> CreateAsync(HttpClient client, Uri uri)
        {
            return CreateAsyncInternal(client, uri).AsAsyncOperation();
        }
    }
}

(代码是 C# 6,我目测 code highlighing 又要死的很惨了这个不知名的 code highlighter 居然做得很漂亮嘛,基本能看。另外恭喜 Web Essentials 的 Markdown 高亮,不管是 Github 式 code block 还是每行前面加 tab,全都死了)

可以看到很多方法不是未实现就是不支持,但是这样就够了,系统类库很智能,用这些就能 buffer 和 seek 了。详细解释起来的话很复杂,涉及 HTTP 通讯;UWP/.NET 互操作;一些文档没说的内容 等方面,懒得写。

总之用这个代码就可以自定义一个 HttpClient (MSDN),然后 CreateAsync() 出一个 Stream,然后 MediaElement.SetSource(IRandomAccessStream, string) (MSDN) 或者 MediaPlayer.SetStreamSource(IRandomAccessStream) (MSDN) 来播放了。

不过奇怪的是现在后台播放器有内存泄漏,大概 1MB/100s,因为后台是有很严格的 RAM 限制的,不知道哪天就会爆炸,Profiler 插进去也没发现什么。

其它的代码不多了,内存泄漏的可能性很小,但是以上代码我已经每个 IDisposable 都 using 了,不能确定问题是我的还是系统自己的。

或许下次可以给系统一个来自文件的 IRandomAccessStream 试试看,下次

最后博客新增了 Google+1 按钮(下面)和 Disqus 评论(更下面)(当然我觉得你们不瞎的话自己应该看的见),喜欢的话可以用一下,评论是可以不登录 Disqus 直接发表的。