在前面的水文中,一方面,老周向各位同學介紹了通過 TCP 連線來訪問 MPD 服務;另一方面,也簡單演示了 ASP.NET Core 的“極簡 API”(Mini API)。本篇老周就簡單說一下如何用 Mini API 來封裝對 MPD 伺服器的訪問。內容僅供參考,也許你會想到更好的方案。
你可能會問:老周,你這個懶B,上次寫完之後,咋等了這麼久才寫這一篇?實不相瞞,因為遇到問題了……這問題主要出在了“add”命令上。
這個命令的功能是把某一曲目新增到當前播放列表中(不管你是否載入以前儲存的列表,總之就是當前正在用的播放列表),其格式為:
add <音訊檔案URL>
還記得前面的內容乎?我們們在配置 MPD 時,會指定一個專門放置音樂檔案的目錄,因此,這個音訊URL一般使用相對路徑,即相對音樂目錄的相對路徑。
比如,你配置的音樂目錄是 /home/pi/mpd/music,然後,你在 music 目錄下放了一個子目錄叫“裝逼2021全新專輯”,裡面有三個檔案,結構大致如下:
裝逼2021全新專輯 |-- 千年裝逼魂.wav |-- 每天裝一逼.wav |-- 裝逼的人兒真無奈.wav
即,“千年裝逼魂.wav”的全路徑是 /home/pi/mpd/music/裝逼2021全新專輯/千年裝逼魂.wav,但是,使用 add 命令時,只使用相對路徑即可,相對於音樂目錄。
add "裝逼2021全新專輯/千年裝逼魂.wav"
URL最好加上雙引號,因為路徑中帶有空格的概率很高。
那麼,老周遇到的問題是啥?因為這個 add 命令會引用音訊檔案路徑,這文字中避免不了會出現漢字字元。說到這裡你估計明白了,對的,讓人頭痛的老問題——文字編碼問題。有漢字字元就不能使用 ASCII 編碼了,但顯式使用 UTF-8 編碼也不行,經多次嘗試,還是報錯。
終於,被老周測出一個完美解決方法——直接使用 Encoding.Default,讓執行時自動使用與系統一致的編碼。真 TM 沒想到,這一招居然把所有問題全解決了,不再報錯了。果然,預設的是最使的。
----------------------------------------------------------------------------------------------------
既然問題解決了,那麼這篇水文就能寫了。
為了方便操作,我們們不妨先單獨封裝一個類,這個類專用來與 MPD 服務程式通訊。現在我把整個類的程式碼先放出來,然後老周再說一下核心部分。
namespace MpcApi { using System; using System.IO; using System.Net; using System.Collections.ObjectModel; using System.Net.Sockets; using static System.Text.Encoding; using System.Text; internal class MPDTCPClient : IDisposable { const string LOCAL_HOST = "localhost"; // 本機地址 const int LOCAL_PORT = 6600; // 預設埠 TcpClient _client; /// <summary> /// 建構函式 /// </summary> public MPDTCPClient() { _client = new TcpClient(LOCAL_HOST, LOCAL_PORT); // 判斷MPD伺服器是否有應答 using StreamReader sr = new StreamReader( stream: _client.GetStream(), encoding: UTF8, leaveOpen: true ); string resp = sr.ReadLine(); if (resp == null || !resp.StartsWith("OK MPD")) { throw new Exception("伺服器未正確響應"); } } public void Dispose() { _client?.Close(); } private TextReader SendCommand(string cmd) { StreamWriter wr = new( stream: _client.GetStream(), encoding: Default, leaveOpen: true); wr.NewLine = "\n"; //換行符避免出現“\r\n” // 寫命令 wr.WriteLine(cmd); wr.Flush(); wr.Dispose(); // 讀響應 StreamReader sr = new StreamReader( stream: _client.GetStream(), encoding: Default, leaveOpen: true); return sr; //留給其他方法進一步處理 } #region 以下方法為公共成員 /* * 為了用起來方便,封裝一下 */ /// <summary> /// 獲取可用命令 /// </summary> public async Task<IReadOnlyList<string>> GetAvalidCommands() { List<string> files = new(); using TextReader reader = SendCommand("commands"); string msg = await reader.ReadLineAsync(); while (msg != null && msg != "OK") { files.Add(msg); msg = await reader.ReadLineAsync(); } return new ReadOnlyCollection<string>(files); } /// <summary> /// 獲取所有歌曲列表 /// </summary> public async Task<IReadOnlyList<string>> GetAllSongs() { List<string> list = new(); using TextReader reader = SendCommand("listall"); string line = await reader.ReadLineAsync(); while(line != null && line != "OK") { // 這裡我們只需要檔案,不需要目錄 if (line.StartsWith("file:")) { list.Add(line); } line = await reader.ReadLineAsync(); } return new ReadOnlyCollection<string>(list); } /// <summary> /// 播放(指定曲目) /// </summary> /// <param name="n">曲目編號,-1表示省略</param> /// <returns>true:成功;否則失敗S</returns> public async Task<bool> Play(int n = -1) { string c = "play"; if(n >= 0) { c += $" {n}"; } using TextReader reader = SendCommand(c); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 暫停 /// </summary> /// <returns></returns> public async Task<bool> Pause() { using TextReader reader = SendCommand("pause"); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 下一首 /// </summary> /// <returns></returns> public async Task<bool> Next() { using TextReader reader = SendCommand("next"); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 上一首 /// </summary> /// <returns></returns> public async Task<bool> Previous() { using TextReader reader = SendCommand("previous"); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 停止播放 /// </summary> /// <returns></returns> public async Task<bool> Stop() { using TextReader reader = SendCommand("stop"); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 設定音量 /// </summary> /// <param name="v">音量值,可以為正負值</param> /// <returns></returns> public async Task<bool> SetVolume(string v) { string c = $"volume {v}"; using TextReader reader = SendCommand(c); if(await reader.ReadLineAsync() == "OK") { return true; } return false; } /// <summary> /// 顯示播放列表中的曲目 /// </summary> /// <returns></returns> public async Task<IReadOnlyList<string>> ShowPlaylist() { string c = "playlist"; using TextReader reader = SendCommand(c); string msg = await reader.ReadLineAsync(); List<string> items = new(); while(msg != null && msg != "OK") { items.Add(msg); msg = await reader.ReadLineAsync(); } return new ReadOnlyCollection<string>(items); } /// <summary> /// 清空當前正在播放的列表 /// </summary> /// <returns></returns> public async Task<bool> ClearList() { using TextReader reader = SendCommand("clear"); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 載入以前儲存的播放列表 /// </summary> /// <param name="lsname">播放列表的名稱</param> /// <returns></returns> /// <exception cref="Exception"></exception> public async Task<bool> LoadList(string lsname) { if (string.IsNullOrWhiteSpace(lsname)) throw new Exception("列表名稱無效"); // 列表名稱一定要有效 string c = $"load {lsname}"; using TextReader reader = SendCommand(c); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 將當前播放列表儲存 /// </summary> /// <param name="newname">新列表的名稱</param> /// <returns></returns> public async Task<bool> SaveList(string newname) { if (string.IsNullOrWhiteSpace(newname)) throw new Exception("新列表名無效"); string cmd = $"save {newname}"; using TextReader rd = SendCommand(cmd); if (await rd.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 刪除播放列表 /// </summary> /// <param name="lsname">要刪除的播放列表名稱</param> /// <returns></returns> public async Task<bool> DeleteList(string lsname) { if(string.IsNullOrWhiteSpace(lsname)) { throw new Exception("播放列表名稱是必要引數"); } using TextReader reader = SendCommand($"rm {lsname}"); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 將歌曲新增到當前播放列表 /// </summary> /// <param name="url">歌曲URL</param> /// <returns></returns> /// <exception cref="Exception"></exception> public async Task<bool> AddToList(string url) { if (url == null) throw new Exception("URL無效"); using TextReader rd = SendCommand($"add {url}"); if (await rd.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 獲取正在播放的曲目 /// </summary> /// <returns></returns> public async Task<IReadOnlyList<string>> GetCurrent() { List<string> results = new(); using TextReader rd = SendCommand("currentsong"); string line = await rd.ReadLineAsync(); while (line != null && line != "OK") { results.Add(line); line = await rd.ReadLineAsync(); } return new ReadOnlyCollection<string>(results); } #endregion } }
我這個類並沒有實現所有的命令,只包裝了常用的命令,你只要明白其原理後,你自己也可以擴充套件。
對了,這裡得糾正一點:老周在前面的文章中演示TCP協議訪問 MPD,是直接傳送文字的。由於前面我演示的只有 listall 一個命令,所以在連線 MPD 伺服器後就馬上傳送 listall 命令,然後就接收伺服器回應。上次老周說的是:伺服器先回復了一句 OK + MPD 版本號,再發檔案列表,最後一句 OK。
其實這裡老周弄錯了,MPD 伺服器回覆的第一句 OK + MPD版本號並不是響應 listall 命令的,而是當客戶端與它建立TCP連線成功後就馬上回復的,所以,MPD 對 listall 命令的回覆的檔案列表 + OK。
所以,再回過頭來看剛剛那個類,在建構函式中,我讓 TcpClient 物件連線MPD服務(伺服器在本機)。
// new 之後會自動呼叫 Connect 方法請求連線 _client = new TcpClient(LOCAL_HOST, LOCAL_PORT); // 判斷MPD伺服器是否有應答 using StreamReader sr = new StreamReader( stream: _client.GetStream(), encoding: UTF8, leaveOpen: true ); // 一旦連線成功,MPD 會馬上回你一句“OK MPD <版本號>” // 只要判斷“OK MPD”開頭就行,版本號可以不管它,這裡我們不關心 string resp = sr.ReadLine(); if (resp == null || !resp.StartsWith("OK MPD")) { throw new Exception("伺服器未正確響應"); }
另一個核心方法是 SendCommand,它的功能是向 MPD 伺服器傳送命令,然後返回一個 TextReader 物件,這個 reader 可以讀取 MPD 伺服器的響應訊息。
private TextReader SendCommand(string cmd) { StreamWriter wr = new( stream: _client.GetStream(), encoding: Default,//預設編碼能解萬般憂愁 leaveOpen: true); wr.NewLine = "\n"; //換行符避免出現“\r\n” // 寫命令 wr.WriteLine(cmd); wr.Flush(); //一定要這句,不然不會傳送 wr.Dispose(); // 讀響應 StreamReader sr = new StreamReader( stream: _client.GetStream(), encoding: Default,//預設編碼 leaveOpen: true); return sr; //留給其他方法進一步處理 }
接著,各種控制方法都是呼叫這個方法與 MPD 伺服器難信,封裝後對外公開。
/// <summary> /// 獲取所有歌曲列表 /// </summary> public async Task<IReadOnlyList<string>> GetAllSongs() { List<string> list = new(); using TextReader reader = SendCommand("listall"); string line = await reader.ReadLineAsync(); while(line != null && line != "OK") { // 這裡我們只需要檔案,不需要目錄 if (line.StartsWith("file:")) { list.Add(line); } line = await reader.ReadLineAsync(); } return new ReadOnlyCollection<string>(list); } /// <summary> /// 播放(指定曲目) /// </summary> /// <param name="n">曲目編號,-1表示省略</param> /// <returns>true:成功;否則失敗S</returns> public async Task<bool> Play(int n = -1) { string c = "play"; if(n >= 0) { c += $" {n}"; } using TextReader reader = SendCommand(c); if (await reader.ReadLineAsync() == "OK") return true; return false; } /// <summary> /// 暫停 /// </summary> /// <returns></returns> public async Task<bool> Pause() { using TextReader reader = SendCommand("pause"); if (await reader.ReadLineAsync() == "OK") return true; return false; } …………
這個 MPDTCPClient 封裝類在例項化時建立連線,在釋放/清理時關閉連線。接著我們把這個類註冊為依賴注入服務,並且是短暫例項模式(每次注入時都例項化,用完就釋放),這可以避免 TCP 連線被長期佔用導致環境汙染。
var builder = WebApplication.CreateBuilder(args); // 新增服務 builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddTransient<MPDTCPClient>(); builder.WebHost.UseUrls("http://*:888", "http://*:886"); var app = builder.Build();
接下來,我們們就可以使用 MapXXX 擴充套件方法來定義 Mini API。
/** 列出所有可用命令 **/ app.MapGet("/commands", async (MPDTCPClient client) => { return await client.GetAvalidCommands(); }); /** 列出所有歌曲 **/ app.MapGet("/listall", async (MPDTCPClient client) => { return await client.GetAllSongs(); }); /** 列出某個播放列表中的曲目 **/ app.MapGet("/lsplaylist", async (MPDTCPClient client) => { return await client.ShowPlaylist(); }); /** 新增到當前播放列表 */ app.MapPost("/add", async (string url, MPDTCPClient cl) => { var res = await cl.AddToList(url); return res ? Results.Ok() : Results.StatusCode(500); }); /** 播放 **/ app.MapGet("/play", async (MPDTCPClient cl) => { bool res = await cl.Play(); return res ? Results.Ok() : Results.StatusCode(500); }); /** 暫停 **/ app.MapGet("/pause", async (MPDTCPClient client) => { bool res = await client.Pause(); return res ? Results.Ok() : Results.StatusCode(500); }); /** 停止播放 **/ app.MapGet("/stop", async (MPDTCPClient client) => { bool r = await client.Stop(); return r ? Results.Ok() : Results.StatusCode(500); }); /** 上一首 **/ app.MapGet("/prev", async (MPDTCPClient cl) => { bool procres = await cl.Previous(); if (procres) return Results.Ok(); return Results.StatusCode(500); }); /** 下一首 **/ app.MapGet("/next", async (MPDTCPClient client) => { return (await client.Next()) ? Results.Ok() : Results.StatusCode(500); }); /** 設定音量 **/ app.MapPost("/setvol", async (string vol, MPDTCPClient client) => { bool res = await client.SetVolume(vol); return res ? Results.Ok() : Results.StatusCode(500); }); /** 清空當前播放列表 **/ app.MapGet("/clear", async (MPDTCPClient cl) => { return (await cl.ClearList()) ? Results.Ok() : Results.StatusCode(500); }); /** 載入指定列表 **/ app.MapPost("/loadlist", async (string lsname, MPDTCPClient client) => { bool r = await client.LoadList(lsname); if (r) return Results.Ok(); return Results.StatusCode(500); }); /** 刪除播放列表 **/ app.MapGet("/rmlist", async (string lsname, MPDTCPClient cl) => { bool r = await cl.DeleteList(lsname); return r ? Results.Ok() : Results.StatusCode(500); }); /** 儲存當前列表 **/ app.MapPost("/savelist", async (string listname, MPDTCPClient cl) => { bool res = await cl.SaveList(listname); return res ? Results.Ok() : Results.StatusCode(500); });
這個 API 的基本套路就是:若成功執行,返回 200(OK);若執行失敗,返回 500。
MapXXX 方法的第二個引數是一個【萬能】委託物件,注意在定義委託時,需要一個 MPDTCPClient 型別的引數,這個引數會自動獲取到依賴注入進來的物件引用。
大體就是這樣,你可以根據需要,自行補充其他 MPD 命令的封裝。
有了這個 API 的封裝,實現 MPD 客戶端就靈活多了,你可以做移動App,也可以做成 Web App,也可以做成桌面程式。反正你愛咋整就咋整,不管用啥做客戶端程式,只要呼叫這些 Web API 即可。
最後,拿幾個 API 測試一下。
先測一下列出所有命令的 API。
返回的結果如下:
[ "command: add", "command: addid", "command: addtagid", "command: albumart", "command: binarylimit", "command: channels", "command: clear", "command: clearerror", "command: cleartagid", "command: close", "command: commands", "command: config", "command: consume", "command: count", "command: crossfade", "command: currentsong", "command: decoders", "command: delete", "command: deleteid", "command: delpartition", "command: disableoutput", "command: enableoutput", "command: find", "command: findadd", "command: getfingerprint", "command: idle", "command: kill", "command: list", "command: listall", "command: listallinfo", "command: listfiles", "command: listmounts", "command: listpartitions", "command: listplaylist", "command: listplaylistinfo", "command: listplaylists", "command: load", "command: lsinfo", "command: mixrampdb", "command: mixrampdelay", "command: mount", "command: move", "command: moveid", "command: moveoutput", "command: newpartition", "command: next", "command: notcommands", "command: outputs", "command: outputset", "command: partition", "command: password", "command: pause", "command: ping", "command: play", "command: playid", "command: playlist", "command: playlistadd", "command: playlistclear", "command: playlistdelete", "command: playlistfind", "command: playlistid", "command: playlistinfo", "command: playlistmove", "command: playlistsearch", "command: plchanges", "command: plchangesposid", "command: previous", "command: prio", "command: prioid", "command: random", "command: rangeid", "command: readcomments", "command: readmessages", "command: readpicture", "command: rename", "command: repeat", "command: replay_gain_mode", "command: replay_gain_status", "command: rescan", "command: rm", "command: save", "command: search", "command: searchadd", "command: searchaddpl", "command: seek", "command: seekcur", "command: seekid", "command: sendmessage", "command: setvol", "command: shuffle", "command: single", "command: stats", "command: status", "command: sticker", "command: stop", "command: subscribe", "command: swap", "command: swapid", "command: tagtypes", "command: toggleoutput", "command: unmount", "command: unsubscribe", "command: update", "command: urlhandlers", "command: volume" ]
再測一下 listall 命令。
向當前播放列表中新增一首曲子,注意:MPD 服務返回的檔名是有“file: ”開頭的,而我們們傳遞給 add 命令時,不需要"file:",直接用相對路徑即可(建議加上雙引號)。
再測試一下 playlist 介面,列出當前播放列表中的曲目。
返回的播放列表如下:
[ "0:file: 化蝶/1/卓依婷vs周偉傑 - 化蝶.wav", "1:file: 我的中國心/張明敏 - 龍的傳人.wav", "2:file: 化蝶/1/卓依婷 - 花好月圓.wav" ]
“file:”前面的數字是曲目在播放列表中的位置,從 0 開始計算,這樣一來,在使用 play 命令時就可以通過這個數字來指定要播放的曲目,比如要播放第二首(位置1)。
不過,剛才老周寫的 play API是沒有引數的,預設播放整個列表,我們們可以改一下。
app.MapGet("/play", async (int? pos, MPDTCPClient cl) => { bool res = await cl.Play(pos ?? -1); return res ? Results.Ok() : Results.StatusCode(500); });
如果 pos 引數為 -1,表示從頭播放整個列表。
現在,可以呼叫了,播放第二首曲子。
好了,今天的文章就水到這裡了。預告一下,下一篇水文中,我們們玩玩 LED 彩色燈帶。