Such Music

完整示例

Such 插件完整代码示例:NCM 文件解析器与歌单导入

完整示例

本章提供两个完整的 Such 插件示例,覆盖常见使用场景。

示例 1:NCM 文件解析器

一个通过调用外部解密器解析网易云音乐 .ncm 文件的插件,使用 JSON UI Schema 定义界面。

/**
 * Such 插件 — 网易云音乐 NCM 解析器
 * 基于 ncmdump-go 批量解析 .ncm 文件并输出到 Such 音乐文件夹
 */
window.source = {
  id: 'SUCH_NETEASE_NCM_WIDGET',
  name: '网易云音乐 NCM 解析器',
  version: '1.0.0',
  author: 'enzymeym',
  icon: 'https://example.com/ncm-icon.svg',
  description: '基于 ncmdump-go 批量解析 .ncm 文件并输出到 Such 音乐文件夹',
  isUIWidget: true,
  permissions: ['local_program', 'file_system', 'app_info', 'fetch', 'playlist'],

  // ==================== JSON UI Schema ====================
  uiSchema: {
    sections: [
      {
        title: '解密器设置',
        fields: [
          {
            type: 'input',
            key: 'decryptorPath',
            label: '解密器路径 (ncmdump-go)',
            placeholder: '例如: D:\\tools\\ncmdump-go.exe',
            action: 'browse'
          },
          {
            type: 'input',
            key: 'ncmDir',
            label: 'NCM 文件目录',
            placeholder: '选择包含 .ncm 文件的目录',
            readonly: true,
            action: 'browseDir'
          },
          { type: 'text', key: 'musicDir', label: '音乐输出目录' }
        ]
      },
      {
        title: '状态',
        fields: [
          { type: 'tag', key: 'decryptorStatusText' },
          { type: 'tag', key: 'statusText' }
        ]
      },
      {
        title: '操作',
        actions: [
          {
            type: 'button',
            label: '下载解密器',
            method: 'downloadDecryptor',
            variant: 'default'
          },
          { type: 'button', label: '检测解密器', method: 'checkDecryptorStatus' },
          {
            type: 'button',
            label: '开始解密',
            method: 'startDecrypt',
            variant: 'primary'
          }
        ]
      },
      {
        title: '输出日志',
        fields: [{ type: 'log', key: 'outputLog' }]
      }
    ]
  },

  // ==================== 初始化 ====================
  initialization: function () {
    var musicFolder = suchmusic.getSetting('musicFolder')
    this.setProps({
      decryptorPath: '',
      ncmDir: '',
      musicDir: musicFolder || '',
      decryptorStatusText: '未检测',
      statusText: '就绪',
      outputLog: ''
    })
  },

  // ==================== 检测解密器状态 ====================
  checkDecryptorStatus: async function () {
    if (!this._decryptorPath) {
      window.notify('请先配置 ncmdump-go 解密器路径', 'warning')
      return { code: false, message: '未配置路径' }
    }
    try {
      var result = await suchmusic.execProgram(this._decryptorPath, ['--version'])
      if (result.code === 0) {
        var ver = (result.stdout || '').trim()
        this._decryptorStatus = true
        this.setProps({ decryptorStatusText: '有效: ' + ver })
        window.notify('ncmdump-go 解密器就绪 (' + ver + ')', 'success')
        return { code: true, version: ver }
      } else {
        this._decryptorStatus = false
        this.setProps({ decryptorStatusText: '无效' })
        window.notify('ncmdump-go 解密器无效,请检查路径', 'error')
        return { code: false, message: '解密器无响应' }
      }
    } catch (err) {
      this._decryptorStatus = false
      this.setProps({ decryptorStatusText: '未找到' })
      window.notify('未找到 ncmdump-go 解密器', 'warning')
      return { code: false, message: String(err.message || err) }
    }
  },

  // ==================== 下载解密器 ====================
  downloadDecryptor: async function () {
    var self = this
    self.setProps({ statusText: '下载中...', outputLog: '正在获取最新版本信息...\n' })
    window.notify('正在获取 ncmdump-go 最新版本...', 'info')

    try {
      // 获取最新 release 信息
      var apiUrl = 'https://api.example.com/releases/latest'
      var response = await suchmusic.fetch(apiUrl)
      var release = await response.json()

      // 查找 Windows 版本下载链接
      var downloadUrl = ''
      var assets = release.assets || []
      for (var i = 0; i < assets.length; i++) {
        if (assets[i].name && assets[i].name.indexOf('windows_amd64') >= 0) {
          downloadUrl = assets[i].browser_download_url
          break
        }
      }
      if (!downloadUrl) {
        window.notify('未找到 Windows 版本', 'error')
        return { code: false, message: '未找到 Windows 版本' }
      }

      // 下载到音乐目录
      var musicDir = self.musicDir
      var exePath = musicDir + '\\ncmdump-go.exe'

      var downloadResult = await suchmusic.downloadFile(downloadUrl, exePath)
      if (!downloadResult || !downloadResult.success) {
        window.notify('下载失败', 'error')
        return { code: false, message: '下载失败' }
      }

      self._decryptorPath = exePath
      self.setProps({
        decryptorPath: exePath,
        statusText: '下载完成',
        outputLog: '下载完成: ' + exePath,
        decryptorStatusText: '已下载,请检测'
      })
      window.notify('ncmdump-go 下载完成,请点击"检测解密器"验证', 'success')
      return { code: true, filePath: exePath }
    } catch (err) {
      var errMsg = String(err.message || err)
      self.setProps({ statusText: '下载失败', outputLog: '下载出错:\n' + errMsg })
      window.notify('下载失败: ' + errMsg, 'error')
      return { code: false, message: errMsg }
    }
  },

  // ==================== NCM 批量解析 ====================
  startDecrypt: async function () {
    if (!this._decryptorPath) {
      window.notify('请先配置 ncmdump-go 解密器路径', 'error')
      return { code: false, message: '未配置解密器路径' }
    }
    if (!this._ncmDir) {
      window.notify('请先选择 NCM 文件目录', 'error')
      return { code: false, message: '未选择 NCM 目录' }
    }

    this.setProps({ statusText: '解密中...' })
    window.notify('开始解密 NCM 文件...', 'info')

    try {
      var command = '"' + this._decryptorPath + '" -d "' + this._ncmDir + '" -o "' + this.musicDir + '" -r'
      var output = await suchmusic.execTerminal(command)

      this.setProps({ statusText: '完成', outputLog: output })
      window.notify('NCM 文件解析完成,已输出到音乐文件夹', 'success')
      return { code: true, message: '解密完成' }
    } catch (err) {
      var errMsg = String(err.message || err)
      this.setProps({ statusText: '失败', outputLog: '错误:\n' + errMsg })
      window.notify('解密失败: ' + errMsg, 'error')
      return { code: false, message: errMsg }
    }
  }
}

关键知识点:

  • JSON UI Schema 声明式 UI(4 个 sections,含 input / tag / log / button)
  • suchmusic.execProgram 检测外部工具
  • suchmusic.execTerminal 执行批量解密命令
  • suchmusic.downloadFile 下载可执行文件
  • this.setProps 驱动 UI 状态更新
  • window.notify 推送用户通知
  • 完整的错误处理和状态反馈

示例 2:歌单导入

通过远程 API 获取歌单数据,下载音乐文件并导入到 Such。

/**
 * Such 插件 — 网易云歌单导入
 * 通过歌单 ID 和 Cookie 导入歌单到 Such
 */
window.source = {
  id: 'SUCH_NETEASE_IMPORT',
  name: '网易云歌单导入',
  version: '1.0.0',
  author: 'enzymeym',
  description: '输入网易云歌单 ID 和 Cookie,一键导入歌单到 Such',
  isUIWidget: true,
  permissions: ['fetch', 'playlist', 'file_system', 'app_info'],

  // ==================== JSON UI Schema ====================
  uiSchema: {
    sections: [
      {
        title: '导入设置',
        fields: [
          {
            type: 'input',
            key: 'playlistId',
            label: '歌单 ID',
            placeholder: '输入网易云歌单 ID,例如 18034007303'
          },
          {
            type: 'input',
            key: 'cookie',
            label: 'Cookie',
            placeholder: '登录网页版后获取 Cookie'
          }
        ],
        actions: [
          {
            type: 'button',
            label: '开始导入',
            method: 'startImport',
            variant: 'primary'
          }
        ]
      },
      { title: '状态', fields: [{ type: 'tag', key: 'statusText' }] },
      { title: '导入日志', fields: [{ type: 'log', key: 'importLog' }] }
    ]
  },

  // ==================== 初始化 ====================
  initialization: function () {
    this.setProps({ playlistId: '', cookie: '', statusText: '就绪', importLog: '' })
  },

  // ==================== 开始导入 ====================
  startImport: async function () {
    var self = this
    if (!self._playlistId) {
      window.notify('请输入歌单 ID', 'warning')
      return { code: false, message: '未输入歌单 ID' }
    }

    self._logLines = []
    self._setStatus('导入中...')

    try {
      // 1. 获取歌单详情
      self._log('正在获取歌单详情...')
      var detailUrl = 'https://api.example.com/playlist/detail?id=' + self._playlistId
      var detail = await self._fetchWithCookie(detailUrl)

      var playlistName = detail.playlist.name
      self._log('歌单名称: ' + playlistName)

      // 2. 获取歌曲列表
      self._log('正在获取歌曲列表...')
      var tracks = await self._fetchAllTracks(self._playlistId, detail.playlist.trackCount)

      // 3. 构建曲目并导入
      self._log('正在导入到 Such...')
      var trackEntries = tracks.map(function (t) {
        return {
          id: 'custom-' + t.id,
          title: t.name,
          artist: t.ar.map(function (a) { return a.name }).join('/'),
          album: t.al ? t.al.name : '',
          cover: t.al ? t.al.picUrl : '',
          durationMs: t.dt || 0,
          source: 'import',
          sourceSongId: String(t.id)
        }
      })

      var newPlaylist = await suchmusic.createPlaylist(playlistName, trackEntries)
      self._log('歌单创建成功!ID: ' + newPlaylist.id)
      self._setStatus('导入完成: ' + playlistName)
      window.notify('歌单 "' + playlistName + '" 导入成功!', 'success')
      return { code: true, playlistId: newPlaylist.id, trackCount: trackEntries.length }

    } catch (err) {
      var errMsg = String(err.message || err)
      self._log('错误: ' + errMsg)
      self._setStatus('导入失败')
      window.notify('导入失败: ' + errMsg, 'error')
      return { code: false, message: errMsg }
    }
  },

  // ==================== 内部方法 ====================
  _logLines: [],
  _playlistId: '',
  _cookie: '',

  _log: function (msg) {
    this._logLines.push('[' + new Date().toLocaleTimeString() + '] ' + msg)
    this.setProps({ importLog: this._logLines.join('\n') })
  },

  _setStatus: function (text) {
    this.setProps({ statusText: text })
  },

  _fetchWithCookie: async function (url) {
    var options = this._cookie ? { headers: { 'Cookie': this._cookie } } : {}
    var resp = await suchmusic.fetch(url, options)
    if (!resp.ok) throw new Error('HTTP ' + resp.status)
    return resp.json()
  },

  _fetchAllTracks: async function (playlistId, totalCount) {
    var self = this
    var allTracks = []
    var offset = 0
    var limit = 100

    while (offset < totalCount) {
      var pageNum = Math.floor(offset / limit) + 1
      var totalPages = Math.ceil(totalCount / limit)
      self._log('获取歌曲: 第 ' + pageNum + '/' + totalPages + ' 页...')

      var url = 'https://api.example.com/playlist/track/all?id=' + playlistId + '&offset=' + offset + '&limit=' + limit
      var resp = await self._fetchWithCookie(url)

      var tracks = resp.songs || []
      allTracks = allTracks.concat(tracks)
      offset += limit

      if (tracks.length < limit) break
    }

    return allTracks
  }
}

关键知识点:

  • suchmusic.fetch + Cookie 做需要认证的 HTTP 请求
  • suchmusic.createPlaylist 创建歌单并将远程数据导入
  • 分页获取大量数据(while 循环 + offset/limit)
  • 日志系统的实现模式(_log + setProps
  • JSON UI Schema 定义简洁的输入+展示界面

On this page