从网易云音乐到 Walkman 媒体播放器

July 2018 · 4 minute read

今年六月份突然脑抽想买个很“传统”的 MP3 回来玩玩,于是就从同学那收了这么一个大法的播放器:NW-A45。到手之后检查了一下机器,几乎是全新成色,带一个 64GB 的 TF 卡,非常兴奋。但当我接上了电脑准备开始快乐地复制音乐时,我突然意识到一个问题:我从哪里复制呢?

在高中的时候,我还是保持着从网上收集音乐并整理好的习惯,原因是当时手里的播放器是 Apple iPod nano 6,整理妥当的音乐在使用 iTunes 同步时会方便很多很多。不过后来被人安利了网易云音乐之后,这个收集和整理的习惯就彻底停下来了,原因同样很简单,在线的音乐服务使用起来比自己整理要便捷太多太多。也因为如此,iPod nano 正式退休,开始养(吃)老(灰)。这之后我都是一直使用着网易云音乐,为了解决手机 16GB 存不下音乐的问题还购买了一个 iPod Touch,64G 的版本拿来听歌显然时毫无问题的。就这样,网易云音乐 + iPod Touch 的组合用到了现在。

回到从前的整理音乐+使用工具同步肯定是不会去做的,这辈子都不可能。然而 Walkman 买都买了,不把这个设备用起来就很不服气。仔细思考了一下,网易云音乐上的歌曲其实是可以随意下载的(会员才能下无损,但这不是重点),不过下载了歌曲是会丢失当时整理歌单时的顺序和分类,这一点说重要也不怎么重要,但是我很不愿意半夜听歌时随机到一些奇怪的音乐(比如血源诅咒的 OST,怕不是半夜直接吓醒),所以歌单这一信息还是有必要保留下来的。

说到保留歌单,第一个想到的办法是,准备好一组文件夹,每个文件夹存放对应歌单的内容。然后逐个将网易云音乐的歌单下载,每下载完一个歌单之后,将下载目录的歌全部复制到对应的歌单中。这个方法刚开始觉得似乎还能用,但是稍微想一下就不难发现,该方法会导致文件系统里有很多的冗余数据(比如某首歌同时存在于若干个歌单中),同时也无法很好的增量下载新歌曲。

那么换个思路,网易云音乐是能够离线播放音乐的,歌单数据离线时也是可以访问的,那么就能说明至少他在本地会保存一些数据文件来存储歌单啊什么的信息。这样我只要想办法找到他们,从中解析出我所需要的信息即可。对着 AppData 目录找了一下果然不出意外,在 Local\Netease\CloudMusic\Library 内找到了一些看起来非常像那么一回事儿的文件:

PS C:\Users\ntzyz\AppData\Local\Netease\CloudMusic\Library> Get-ChildItem .


    目录: C:\Users\ntzyz\AppData\Local\Netease\CloudMusic\Library


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----         2018/7/8     23:24       13161472 library.dat
-a----         2018/7/8     23:24          16384 library.dat-journal
-a----         2018/7/7     12:40       13161472 library_bak.dat
-a----         2018/7/9     14:12       15628288 webdb.dat

打开 Cygwin 64 对着 webdb.dat 执行一发 file 命令,结果是令人意外的惊喜(赞美 libmagic):

% file webdb.dat
webdb.dat: SQLite 3.x database, last written using SQLite version 3007006

那可就好办了啊,直接打开 SQLiteDatabaseBrowser 看着表结构,然后对着 Visual Studio 2017 一顿输出,所需要的导出功能就能很轻松地实现了。代码放在最后,稍微有点长。

解决完导出问题后,剩下的问题就是,如何增量同步数据和解决网易云音乐新出现的 .ncm 版权保护文件了。增量同步本身没有难度,毕竟现成的工具非常多,听说 Windows 的 RoboCopy 可以完成,隔壁 Rsync 也不是不能做,所以这个问题最后解决。

.ncm 这一问题就稍稍蛋疼了,从 SQLite 数据库中不难看出,他其实是常用媒体格式的一个封装(数据库中有 real_suffix 这么个字段),但是按照网易的尿性,这个封装肯定是对整个文件进行了加密。不过既然能离线播放,那么必然是将解密相关的密钥啥的保存在了客户端里,而这个客户端又是一个 Qt Webkit 套壳的 Web 应用程序,用脚想也觉得网易肯定把密钥保存到了 Qt 的那一部分里了,感觉上反编译不可避。逆向工程这个方面我是一窍不通,于是就……尝试在 Google 上寻求帮助(丢人!),发现了一个神秘仓库,实现了对这一格式的解封装。这里按照仓库 README 的要求就不贴链接了,但是简单的提一下那段代码里有的 bug。

  1. OpenSSL 创建 cipher 的接口稍有变更,改成这个样式就行了:
    EVP_CIPHER_CTX* x = EVP_CIPHER_CTX_new();
    
  2. 文件操作,这个哥们用的是 ISO C 提供的文件 IO 方式,在打开的时候描述打开方式的字符串使用了 "r",按照标准就是文本文件而不是二进制文件,在 GNU C Library 上使用 fread 读取一个 uint32_t 后文件流只会向后移动四个字节,但是在 mingw 编译出来的程序表现则是一直读取到了换行符,文件流位置挪了一大截,后续操作也就全都爆炸了。@ZephRay 表明标准库并没有明确规定文件在使用 "r" 模式打开时,使用 fread 函数读取数据后文件流的变化,也就是吃了个 UB 的亏。修起来也是简单,全都加个 b 就行了。

解决完这个 dump 之后,就需要解决增量同步的问题了。我的预期同步逻辑大概是这样的:

  1. 同步数据
  2. 解密并删除 .ncm 文件
  3. 生成 m3u8 播放列表文件

问题就在第二部的解密并删除了。如果删除 ncm 文件,那么下一次同步又会把他们全都拷过来,如果不删这个空间又浪费了很多(英文歌很多都是这个格式)。想来想去还是干脆自己写一段脚本来完成吧,没啥难度的事。

$srcDir = "D:\CloudMusic\*";
$destDir = "F:\MUSIC\CloudMusic";

$fileList = Get-Item -Path $srcDir;

foreach ($fileItem in $fileList) {
    $destPath = $($destDir + "\" + $fileItem.Name);

    if (Test-Path -LiteralPath $destPath) {
        $destFile = Get-Item -LiteralPath $($destDir + "\" + $fileItem.Name);

        if ($destFile.Length -eq $fileItem.Length) {
            continue;
        }

        Write-Output $("[WARN]: File Size Mismatch: " + $fileItem.Name);
        continue;
    } else {
        if ($fileItem.Name -match '\.ncm$') {
            $fileNameWithoutSuffix = $fileItem.Name -replace '.ncm';
            # Write-Output $("[INFO]: NCM File Found: " + $fileNameWithoutSuffix);
            $matchedFileList = Get-Item -Path $($destDir + "\" + $fileNameWithoutSuffix + ".*");
            if ($matchedFileList.Length -eq 0) {
                Write-Output $("[INFO]: File Not Found: " + $fileItem.Name + ", Copying...");
                Copy-Item $fileItem -Destination $destDir
            }
        } else {
            Write-Output $("[INFO]: File Not Found: " + $fileItem.Name + ", Copying...");
            Copy-Item $fileItem -Destination $destDir
        }
    }
}

这份代码逻辑很简单,如果文件不存在,同时相同文件名、不同后缀名的文件也不存在的话,就去复制这个文件,其他情况就跳过。需要注意的是这个脚本不提供递归复制的支持和文件一致性的检验,毕竟没打算实现一个全面的同步工具(

有了这些东西就能很顺利的使用 Walkman 听歌了呢。折腾折腾其实也挺有趣的((


导出 M3U8 代码:

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ncm_playlist_export
{
    class PlayList
    {
        public Int64 pid;
        public dynamic playlist;
    }

    class SongDetail
    {
        public dynamic detail;
        public string relative_path;
        public string real_suffix;
    }

    class FailedRecorods
    {
        public Int64 tid;
        public string reason;

        public FailedRecorods(Int64 _in_tid, string _in_reason)
        {
            tid = _in_tid;
            reason = _in_reason;
        }
    }

    class Program
    {
        static SQLiteConnection conn = null;

        static List<PlayList> FetchPlaylist()
        {
            var ret = new List<PlayList>();

            var cmd = conn.CreateCommand();
            cmd.CommandText = "SELECT * FROM web_playlist";

            var reader = cmd.ExecuteReader();

            while (reader.Read())
            {
                var elem = new PlayList();
                elem.pid = Convert.ToInt64(reader["pid"]);
                elem.playlist = JObject.Parse(reader["playlist"].ToString());

                ret.Add(elem);
            }

            return ret;
        }

        static List<Int64> FetchPlaylistSongs(Int64 pid)
        {
            var ret = new List<Int64>();

            var cmd = conn.CreateCommand();
            cmd.CommandText = "SELECT pid, tid FROM web_playlist_track WHERE pid = " + pid + " ORDER BY `order`";

            var reader = cmd.ExecuteReader();

            while (reader.Read())
            {
                ret.Add(Convert.ToInt64(reader["tid"]));
            }

            return ret;
        }

        static SongDetail FetchSongDetail (Int64 tid)
        {
            var ret = new SongDetail();
            var cmd = conn.CreateCommand();
            cmd.CommandText = "SELECT detail, relative_path, real_suffix FROM web_offline_track WHERE track_id =" + tid;

            var reader = cmd.ExecuteReader();

            if (reader.Read())
            {
                ret.detail = JObject.Parse(reader["detail"].ToString());
                ret.relative_path = reader["relative_path"].ToString();
                ret.real_suffix = reader["real_suffix"].ToString();

                return ret;
            }

            return null;
        }

        static string GetSafeFilename(string filename)
        {
            return string.Join("_", filename.Split(Path.GetInvalidFileNameChars()));
        }

        static void Main(string[] args)
        {
            var localAppData = Environment.GetEnvironmentVariable("LocalAppData");
            v arlrcPath = Environment.GetEnvironmentVariable("LocalAppData") + "\\Netease\\CloudMusic\\webdata\\lyric";
            conn = new SQLiteConnection("Data Source=" + localAppData + "\\Netease\\CloudMusic\\Library\\webdb.dat;Version=3;New=True;Compress=True;");
            conn.Open();

            var playlists = FetchPlaylist();
            int n = 1;

            foreach (var playlist in playlists)
            {
                int songsCount = 0, skippedCount = 0;
                string m3u8 = "";
                var songs = FetchPlaylistSongs(playlist.pid);
                var skipeedReasons = new List<FailedRecorods>();

                if (songs.Count == 0)
                {
                    continue;
                }

                for (var i = 0; i < songs.Count; i++)
                {
                    var detail = FetchSongDetail(songs[i]);

                    if (detail == null)
                    {
                        skipeedReasons.Add(new FailedRecorods(songs[i], "Detail data not found."));
                        skippedCount++;
                        continue;
                    }

                    if (detail.relative_path == "")
                    {
                        skipeedReasons.Add(new FailedRecorods(songs[i], "Relative path is empty."));
                        skippedCount++;
                        continue;
                    }

                    if (detail.relative_path.Substring(detail.relative_path.Length - 3) == "ncm")
                    {
                        detail.relative_path = detail.relative_path.Replace("ncm", detail.real_suffix);
                    }

                    if (false && File.Exists(lrcPath + "\\" + songs[i]))
                    {
                        var lrcJson = File.ReadAllText(lrcPath + "\\" + songs[i]);
                        dynamic lrc = JObject.Parse(lrcJson);

                        if (lrc["nolyric"] != true)
                        {
                            File.WriteAllText(Path.ChangeExtension(detail.relative_path, "lrc"), (string)lrc.lrc.lyric);
                        }
                    }

                    songsCount++;
                    m3u8 += "#EXTINF:" + detail.detail.duration + "," + detail.detail.name + "\n";
                    m3u8 += "Cloudmusic/" + detail.relative_path.Replace('\\', '/') + "\n\n";
                }

                if (songsCount == 0)
                {
                    continue;
                }

                System.IO.File.WriteAllText(n.ToString("00") + ". " + GetSafeFilename("" + playlist.playlist.name) + ".m3u8", m3u8);
                Console.WriteLine("Finish generating playlist: " + playlist.playlist.name + ", Songs count: " + songsCount + ", Skipped count: " + skippedCount);
                if (skipeedReasons.Count != 0)
                {
                    for (int i = 0; i < (skippedCount < 5 ? skippedCount : 5); i++)
                    {
                        Console.WriteLine("Track ID: " + skipeedReasons[i].tid + ", Reason: " + skipeedReasons[i].reason);
                    }
                }
                n++;
                Console.WriteLine("");
            }

            Console.WriteLine("Finished!");
        }
    }
}
Copyright © 2016-2023 ntzyz. All rights reserved.
Except where otherwise noted, content on this blog is licensed under CC-BY 2.0.