【.NET 與樹莓派】MPD 的 Mini-API 封裝

東邪獨孤發表於2021-12-01

在前面的水文中,一方面,老周向各位同學介紹了通過 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 彩色燈帶。

相關文章