diff --git a/.gitea/workflows/deploy-piano-sheet.yml b/.gitea/workflows/deploy-music.yml
similarity index 79%
rename from .gitea/workflows/deploy-piano-sheet.yml
rename to .gitea/workflows/deploy-music.yml
index 4b8f38e..f8e59d8 100644
--- a/.gitea/workflows/deploy-piano-sheet.yml
+++ b/.gitea/workflows/deploy-music.yml
@@ -1,24 +1,24 @@
-name: deploy piano-sheet
-# piano.famzheng.me — 钢琴谱管理 / 阅读。host shell runner(fam 用户)。
+name: deploy music
+# music.famzheng.me — 听歌 + 练琴 曲目管理。host shell runner(fam 用户)。
on:
push:
branches: [master]
paths:
- - 'apps/piano-sheet/**'
+ - 'apps/music/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- - '.gitea/workflows/deploy-piano-sheet.yml'
+ - '.gitea/workflows/deploy-music.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
- APP: piano-sheet
- NS: cube-piano
- IMAGE: registry.famzheng.me/mochi/piano-sheet
+ APP: music
+ NS: cube-music
+ IMAGE: registry.famzheng.me/mochi/music
steps:
- uses: actions/checkout@v4
@@ -48,7 +48,7 @@ jobs:
- name: Initialize K8s resources
run: |
- kubectl apply -f apps/piano-sheet/k8s/all.yaml
+ kubectl apply -f apps/music/k8s/all.yaml
- name: Roll out to k3s
run: |
diff --git a/Cargo.lock b/Cargo.lock
index 24b57a6..69b951e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -441,6 +441,21 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "music"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "cube-core",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tracing",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -485,20 +500,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-[[package]]
-name = "piano-sheet"
-version = "0.1.0"
-dependencies = [
- "axum",
- "cube-core",
- "rusqlite",
- "serde",
- "serde_json",
- "tokio",
- "tower-http",
- "tracing",
-]
-
[[package]]
name = "pin-project-lite"
version = "0.2.17"
diff --git a/Cargo.toml b/Cargo.toml
index 761089c..dbade88 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = [
"crates/cube-core",
"apps/cube",
"apps/simpleasm",
- "apps/piano-sheet",
+ "apps/music",
]
[workspace.package]
diff --git a/apps/piano-sheet/Cargo.toml b/apps/music/Cargo.toml
similarity index 74%
rename from apps/piano-sheet/Cargo.toml
rename to apps/music/Cargo.toml
index a9c8440..60ec3b0 100644
--- a/apps/piano-sheet/Cargo.toml
+++ b/apps/music/Cargo.toml
@@ -1,15 +1,16 @@
[package]
-name = "piano-sheet"
+name = "music"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
-description = "piano.famzheng.me — 钢琴谱管理 / 阅读 app,多图谱面 BLOB 存 sqlite"
+description = "music.famzheng.me — 听歌 + 练琴 曲目管理 (video / audio / pdf / png)"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true, features = ["multipart"] }
tokio = { workspace = true }
+tower = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
diff --git a/apps/music/Dockerfile b/apps/music/Dockerfile
new file mode 100644
index 0000000..21f9bd8
--- /dev/null
+++ b/apps/music/Dockerfile
@@ -0,0 +1,5 @@
+FROM scratch
+COPY target/x86_64-unknown-linux-musl/release/music /music
+COPY apps/music/frontend/dist /dist
+EXPOSE 8080
+ENTRYPOINT ["/music"]
diff --git a/apps/piano-sheet/frontend/index.html b/apps/music/frontend/index.html
similarity index 79%
rename from apps/piano-sheet/frontend/index.html
rename to apps/music/frontend/index.html
index 368b2b7..260c98a 100644
--- a/apps/piano-sheet/frontend/index.html
+++ b/apps/music/frontend/index.html
@@ -2,9 +2,9 @@
-
-
- Piano Sheet
+
+
+ Music · Euphon
diff --git a/apps/piano-sheet/frontend/package-lock.json b/apps/music/frontend/package-lock.json
similarity index 93%
rename from apps/piano-sheet/frontend/package-lock.json
rename to apps/music/frontend/package-lock.json
index 78f7e4d..46b171d 100644
--- a/apps/piano-sheet/frontend/package-lock.json
+++ b/apps/music/frontend/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "piano-sheet",
+ "name": "music",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "piano-sheet",
+ "name": "music",
"version": "1.0.0",
"dependencies": {
"pinia": "^2.1.7",
@@ -832,53 +832,53 @@
}
},
"node_modules/@vue/compiler-core": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
- "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+ "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.2",
- "@vue/shared": "3.5.33",
+ "@babel/parser": "^7.29.3",
+ "@vue/shared": "3.5.34",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
- "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+ "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-core": "3.5.33",
- "@vue/shared": "3.5.33"
+ "@vue/compiler-core": "3.5.34",
+ "@vue/shared": "3.5.34"
}
},
"node_modules/@vue/compiler-sfc": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
- "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+ "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.2",
- "@vue/compiler-core": "3.5.33",
- "@vue/compiler-dom": "3.5.33",
- "@vue/compiler-ssr": "3.5.33",
- "@vue/shared": "3.5.33",
+ "@babel/parser": "^7.29.3",
+ "@vue/compiler-core": "3.5.34",
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
- "postcss": "^8.5.10",
+ "postcss": "^8.5.14",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
- "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+ "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-dom": "3.5.33",
- "@vue/shared": "3.5.33"
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/shared": "3.5.34"
}
},
"node_modules/@vue/devtools-api": {
@@ -888,53 +888,53 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
- "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
+ "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
"license": "MIT",
"dependencies": {
- "@vue/shared": "3.5.33"
+ "@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-core": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
- "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+ "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
"license": "MIT",
"dependencies": {
- "@vue/reactivity": "3.5.33",
- "@vue/shared": "3.5.33"
+ "@vue/reactivity": "3.5.34",
+ "@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-dom": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
- "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+ "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
"license": "MIT",
"dependencies": {
- "@vue/reactivity": "3.5.33",
- "@vue/runtime-core": "3.5.33",
- "@vue/shared": "3.5.33",
+ "@vue/reactivity": "3.5.34",
+ "@vue/runtime-core": "3.5.34",
+ "@vue/shared": "3.5.34",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
- "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+ "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-ssr": "3.5.33",
- "@vue/shared": "3.5.33"
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34"
},
"peerDependencies": {
- "vue": "3.5.33"
+ "vue": "3.5.34"
}
},
"node_modules/@vue/shared": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
- "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"license": "MIT"
},
"node_modules/csstype": {
@@ -1213,16 +1213,16 @@
}
},
"node_modules/vue": {
- "version": "3.5.33",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
- "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
+ "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-dom": "3.5.33",
- "@vue/compiler-sfc": "3.5.33",
- "@vue/runtime-dom": "3.5.33",
- "@vue/server-renderer": "3.5.33",
- "@vue/shared": "3.5.33"
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-sfc": "3.5.34",
+ "@vue/runtime-dom": "3.5.34",
+ "@vue/server-renderer": "3.5.34",
+ "@vue/shared": "3.5.34"
},
"peerDependencies": {
"typescript": "*"
diff --git a/apps/piano-sheet/frontend/package.json b/apps/music/frontend/package.json
similarity index 92%
rename from apps/piano-sheet/frontend/package.json
rename to apps/music/frontend/package.json
index 006d572..94a3eb2 100644
--- a/apps/piano-sheet/frontend/package.json
+++ b/apps/music/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "piano-sheet",
+ "name": "music",
"private": true,
"version": "1.0.0",
"scripts": {
diff --git a/apps/music/frontend/src/App.vue b/apps/music/frontend/src/App.vue
new file mode 100644
index 0000000..4445550
--- /dev/null
+++ b/apps/music/frontend/src/App.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
diff --git a/apps/music/frontend/src/lib/api.js b/apps/music/frontend/src/lib/api.js
new file mode 100644
index 0000000..002ec06
--- /dev/null
+++ b/apps/music/frontend/src/lib/api.js
@@ -0,0 +1,59 @@
+// 薄薄一层 fetch 封装。错误统一抛 Error(message)。
+
+async function jsonOrThrow(res) {
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ throw new Error(text || `${res.status} ${res.statusText}`)
+ }
+ return res.json()
+}
+
+export function listPieces() {
+ return fetch('/api/pieces').then(jsonOrThrow)
+}
+
+export function getPiece(id) {
+ return fetch(`/api/pieces/${id}`).then(jsonOrThrow)
+}
+
+export function createPiece(body) {
+ return fetch('/api/pieces', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }).then(jsonOrThrow)
+}
+
+export function patchPiece(id, body) {
+ return fetch(`/api/pieces/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }).then(jsonOrThrow)
+}
+
+export function deletePiece(id) {
+ return fetch(`/api/pieces/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
+}
+
+export function recordPlay(id) {
+ return fetch(`/api/pieces/${id}/play`, { method: 'POST' }).then(jsonOrThrow)
+}
+
+// `role`: null | 'chord' | 'numbered' | 'staff'
+export function uploadAttachments(pieceId, files, role) {
+ const fd = new FormData()
+ for (const f of files) fd.append('files', f, f.name)
+ const url = role
+ ? `/api/pieces/${pieceId}/attachments?role=${encodeURIComponent(role)}`
+ : `/api/pieces/${pieceId}/attachments`
+ return fetch(url, { method: 'POST', body: fd }).then(jsonOrThrow)
+}
+
+export function deleteAttachment(id) {
+ return fetch(`/api/attachments/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
+}
+
+export function attachmentUrl(id) {
+ return `/api/attachments/${id}`
+}
diff --git a/apps/music/frontend/src/lib/lrc.js b/apps/music/frontend/src/lib/lrc.js
new file mode 100644
index 0000000..8c3bdc7
--- /dev/null
+++ b/apps/music/frontend/src/lib/lrc.js
@@ -0,0 +1,27 @@
+// LRC parser: takes raw text → [{ time: seconds, text }] sorted by time.
+
+export function parseLrc(text) {
+ if (!text) return []
+ const out = []
+ for (const raw of text.split(/\r?\n/)) {
+ // 一行可能有多个 time tag:[00:12.34][00:30.00]歌词
+ const tags = []
+ let rest = raw
+ const re = /^\[(\d+):(\d+)(?:[.:](\d+))?\]/
+ while (true) {
+ const m = re.exec(rest)
+ if (!m) break
+ const min = parseInt(m[1], 10)
+ const sec = parseInt(m[2], 10)
+ const fracRaw = m[3] || '0'
+ const frac = parseInt(fracRaw, 10) / Math.pow(10, fracRaw.length)
+ tags.push(min * 60 + sec + frac)
+ rest = rest.slice(m[0].length)
+ }
+ const txt = rest.trim()
+ if (!txt || tags.length === 0) continue
+ for (const t of tags) out.push({ time: t, text: txt })
+ }
+ out.sort((a, b) => a.time - b.time)
+ return out
+}
diff --git a/apps/piano-sheet/frontend/src/main.js b/apps/music/frontend/src/main.js
similarity index 100%
rename from apps/piano-sheet/frontend/src/main.js
rename to apps/music/frontend/src/main.js
diff --git a/apps/music/frontend/src/router/index.js b/apps/music/frontend/src/router/index.js
new file mode 100644
index 0000000..21faef0
--- /dev/null
+++ b/apps/music/frontend/src/router/index.js
@@ -0,0 +1,31 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const routes = [
+ {
+ path: '/',
+ name: 'player',
+ component: () => import('../views/PlayerView.vue'),
+ },
+ {
+ path: '/piece/:id',
+ name: 'piece',
+ component: () => import('../views/PlayerView.vue'),
+ props: (route) => ({ id: Number(route.params.id) }),
+ },
+ {
+ path: '/upload',
+ name: 'upload',
+ component: () => import('../views/UploadView.vue'),
+ },
+ {
+ path: '/piece/:id/edit',
+ name: 'edit',
+ component: () => import('../views/EditView.vue'),
+ props: (route) => ({ id: Number(route.params.id) }),
+ },
+]
+
+export default createRouter({
+ history: createWebHistory(),
+ routes,
+})
diff --git a/apps/music/frontend/src/views/EditView.vue b/apps/music/frontend/src/views/EditView.vue
new file mode 100644
index 0000000..a4864dd
--- /dev/null
+++ b/apps/music/frontend/src/views/EditView.vue
@@ -0,0 +1,396 @@
+
+
+
+ ← 列表
+ ▶ 播放
+ 编辑:{{ piece.title }}
+ 编辑曲目
+
+
+
+ 加载中…
+ {{ loadErr }}
+
+
+
+
+
+ 添加附件
+
+ 一次可选多个文件。video / audio / pdf / image 自动识别。
+ 图片需要选 角色(吉他谱 / 简谱 / 五线谱),其它类型角色无效。
+
+
+
+
+
+
+ {{ uploadErr }}
+
+
+
+ 已有附件 ({{ piece.attachments.length }})
+ 还没有附件。
+
+ -
+
+
{{ kindLabel(att.kind) }}{{ att.role ? '·' + roleLabel(att.role) : '' }}
+
{{ att.filename }}
+
{{ fmtSize(att.size_bytes) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/music/frontend/src/views/PlayerView.vue b/apps/music/frontend/src/views/PlayerView.vue
new file mode 100644
index 0000000..b819707
--- /dev/null
+++ b/apps/music/frontend/src/views/PlayerView.vue
@@ -0,0 +1,919 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
这首歌的歌词不是 LRC 格式
+
暂无歌词,用心感受 🎶
+
{{ selected.lyrics }}
+
+
{{ line.text }}
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/music/frontend/src/views/UploadView.vue b/apps/music/frontend/src/views/UploadView.vue
new file mode 100644
index 0000000..34572cc
--- /dev/null
+++ b/apps/music/frontend/src/views/UploadView.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
diff --git a/apps/piano-sheet/frontend/vite.config.js b/apps/music/frontend/vite.config.js
similarity index 100%
rename from apps/piano-sheet/frontend/vite.config.js
rename to apps/music/frontend/vite.config.js
diff --git a/apps/piano-sheet/k8s/all.yaml b/apps/music/k8s/all.yaml
similarity index 67%
rename from apps/piano-sheet/k8s/all.yaml
rename to apps/music/k8s/all.yaml
index 47c12f6..b0d178e 100644
--- a/apps/piano-sheet/k8s/all.yaml
+++ b/apps/music/k8s/all.yaml
@@ -1,29 +1,29 @@
apiVersion: v1
kind: Namespace
metadata:
- name: cube-piano
+ name: cube-music
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
- name: piano-sheet-data
- namespace: cube-piano
+ name: music-data
+ namespace: cube-music
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
- # 图片直存 sqlite,留出宽裕空间
- storage: 10Gi
- # storageClassName 留空 → 走 k3s 默认 local-path(hostPath,单节点足够)
+ # video / audio 大附件 + 初始 guitar 曲库(~? GB),50Gi 起步
+ storage: 50Gi
+ # storageClassName 留空 → k3s 默认 local-path
---
apiVersion: apps/v1
kind: Deployment
metadata:
- name: piano-sheet
- namespace: cube-piano
+ name: music
+ namespace: cube-music
labels:
- app: piano-sheet
+ app: music
spec:
replicas: 1
strategy:
@@ -31,17 +31,17 @@ spec:
type: Recreate
selector:
matchLabels:
- app: piano-sheet
+ app: music
template:
metadata:
labels:
- app: piano-sheet
+ app: music
spec:
imagePullSecrets:
- name: registry-creds
containers:
- - name: piano-sheet
- image: registry.famzheng.me/mochi/piano-sheet:latest
+ - name: music
+ image: registry.famzheng.me/mochi/music:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
@@ -49,6 +49,8 @@ spec:
env:
- name: DB_PATH
value: /data/app.db
+ - name: BLOBS_DIR
+ value: /data/blobs
readinessProbe:
httpGet:
path: /healthz
@@ -66,24 +68,24 @@ spec:
cpu: 10m
memory: 32Mi
limits:
- cpu: 500m
- memory: 256Mi
+ cpu: 1000m
+ memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
- claimName: piano-sheet-data
+ claimName: music-data
---
apiVersion: v1
kind: Service
metadata:
- name: piano-sheet
- namespace: cube-piano
+ name: music
+ namespace: cube-music
spec:
selector:
- app: piano-sheet
+ app: music
ports:
- name: http
port: 80
@@ -93,29 +95,29 @@ apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: bodylimit
- namespace: cube-piano
+ namespace: cube-music
spec:
buffering:
- maxRequestBodyBytes: 700000000
+ # 单文件最大 1GiB(main.rs 里 SINGLE_FILE_BYTES),multipart 总和留 5GiB 余量
+ maxRequestBodyBytes: 5368709120
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
- name: piano-sheet
- namespace: cube-piano
+ name: music
+ namespace: cube-music
annotations:
- # 上传整组图片可能 ~600MB,调高 traefik 默认上限
- traefik.ingress.kubernetes.io/router.middlewares: cube-piano-bodylimit@kubernetescrd
+ traefik.ingress.kubernetes.io/router.middlewares: cube-music-bodylimit@kubernetescrd
spec:
ingressClassName: traefik
rules:
- - host: piano.famzheng.me
+ - host: music.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
- name: piano-sheet
+ name: music
port:
number: 80
diff --git a/apps/music/scripts/import_guitar.py b/apps/music/scripts/import_guitar.py
new file mode 100644
index 0000000..f9b7506
--- /dev/null
+++ b/apps/music/scripts/import_guitar.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""
+从 /data/guitar/ 把旧 guitar app 的曲库导入到 music API。
+
+跑法(在 oci 上,源数据原地):
+ python3 import_guitar.py --src /data/guitar --api https://music.famzheng.me
+
+每个 mp3 = 一个 piece:
+- title / artist 从文件名 "{title} - {artist}.mp3" 拆
+- lyrics 来自同名 .lrc(UTF-8)
+- chord png 来自 chords/{sanitize(artist)}-{sanitize(title)}.png(如有)
+- play_count 来自 playcounts.json[mp3 文件名]
+- category 设为 "流行"(旧 guitar 里都是流行/吉他歌)
+
+幂等:以 (title, artist) 作主键去重,已有 piece 跳过附件重传。
+"""
+
+import argparse
+import json
+import re
+import sys
+import urllib.request
+import urllib.parse
+import urllib.error
+import os
+from pathlib import Path
+
+CATEGORY = "流行"
+
+
+def sanitize(text: str) -> str:
+ """匹配旧 chord_server.py 的 sanitize:替换非法文件名字符为 -"""
+ return re.sub(r'[<>:"/\\|?*]', '-', text).strip()
+
+
+def parse_filename(stem: str) -> tuple[str, str]:
+ """'{title} - {artist}' → (title, artist)"""
+ parts = stem.split(' - ')
+ title = parts[0].strip()
+ artist = ' - '.join(parts[1:]).strip() if len(parts) > 1 else ''
+ return title, artist
+
+
+def http_request(method: str, url: str, *, json_body=None, file_field=None,
+ timeout: int = 600) -> dict:
+ """简单 HTTP 客户端,return JSON dict。失败抛异常。"""
+ headers = {}
+ data = None
+ if json_body is not None:
+ data = json.dumps(json_body).encode('utf-8')
+ headers['Content-Type'] = 'application/json'
+ elif file_field is not None:
+ boundary = '----music-import-' + os.urandom(8).hex()
+ body = []
+ for field_name, filename, content, mime in file_field:
+ body.append(f'--{boundary}\r\n'.encode())
+ disp = (f'Content-Disposition: form-data; name="{field_name}"; '
+ f'filename="{filename}"\r\n').encode()
+ body.append(disp)
+ body.append(f'Content-Type: {mime}\r\n\r\n'.encode())
+ body.append(content)
+ body.append(b'\r\n')
+ body.append(f'--{boundary}--\r\n'.encode())
+ data = b''.join(body)
+ headers['Content-Type'] = f'multipart/form-data; boundary={boundary}'
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return json.loads(resp.read().decode('utf-8'))
+
+
+def list_pieces(api: str) -> list:
+ return http_request('GET', f'{api}/api/pieces')
+
+
+def create_piece(api: str, title: str, artist: str, lyrics: str | None) -> int:
+ body = {
+ 'title': title,
+ 'artist': artist or None,
+ 'category': CATEGORY,
+ 'lyrics': lyrics or None,
+ }
+ r = http_request('POST', f'{api}/api/pieces', json_body=body)
+ return int(r['id'])
+
+
+def patch_play_count(api: str, piece_id: int, count: int):
+ http_request('PATCH', f'{api}/api/pieces/{piece_id}',
+ json_body={'play_count': count})
+
+
+def upload_attachment(api: str, piece_id: int, path: Path,
+ mime: str, role: str | None = None) -> dict:
+ url = f'{api}/api/pieces/{piece_id}/attachments'
+ if role:
+ url += '?role=' + urllib.parse.quote(role)
+ bytes_content = path.read_bytes()
+ return http_request('POST', url, file_field=[
+ ('files', path.name, bytes_content, mime),
+ ])
+
+
+def detect_audio_mime(path: Path) -> str:
+ ext = path.suffix.lower()
+ return {
+ '.mp3': 'audio/mpeg',
+ '.m4a': 'audio/mp4',
+ '.flac': 'audio/flac',
+ '.ogg': 'audio/ogg',
+ '.wav': 'audio/wav',
+ }.get(ext, 'application/octet-stream')
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--src', default='/data/guitar', help='guitar 数据根目录')
+ ap.add_argument('--api', required=True, help='music API base,e.g. https://music.famzheng.me')
+ ap.add_argument('--dry-run', action='store_true', help='只打印不实际调用')
+ ap.add_argument('--limit', type=int, default=0, help='测试用,最多导入 N 首(0=全部)')
+ args = ap.parse_args()
+
+ src = Path(args.src)
+ if not src.exists():
+ print(f'[!] {src} 不存在', file=sys.stderr)
+ sys.exit(1)
+
+ chords_dir = src / 'chords'
+ playcounts_file = src / 'playcounts.json'
+ playcounts = {}
+ if playcounts_file.exists():
+ try:
+ playcounts = json.loads(playcounts_file.read_text())
+ except Exception as e:
+ print(f'[!] 读 playcounts.json 失败: {e}', file=sys.stderr)
+
+ # 已有 pieces 去重
+ existing = {}
+ if not args.dry_run:
+ try:
+ for p in list_pieces(args.api):
+ key = (p['title'], p.get('artist') or '')
+ existing[key] = p['id']
+ print(f'[i] 远端已有 {len(existing)} 首曲目,重复的会跳过创建')
+ except urllib.error.URLError as e:
+ print(f'[!] 连不上 {args.api}: {e}', file=sys.stderr)
+ sys.exit(1)
+
+ mp3s = sorted(src.glob('*.mp3'))
+ if args.limit:
+ mp3s = mp3s[:args.limit]
+
+ total = len(mp3s)
+ ok = skipped = failed = 0
+
+ for i, mp3 in enumerate(mp3s, 1):
+ stem = mp3.stem
+ title, artist = parse_filename(stem)
+ if not title:
+ print(f'[!] [{i}/{total}] 无法解析文件名: {mp3.name}', file=sys.stderr)
+ failed += 1
+ continue
+
+ key = (title, artist)
+ prefix = f'[{i}/{total}] {title} - {artist}'
+
+ if key in existing:
+ print(f' ↻ {prefix} (已存在 id={existing[key]}, 跳过)')
+ skipped += 1
+ continue
+
+ # lrc
+ lrc_path = mp3.with_suffix('.lrc')
+ lyrics = None
+ if lrc_path.exists():
+ try:
+ lyrics = lrc_path.read_text(encoding='utf-8')
+ except UnicodeDecodeError:
+ lyrics = lrc_path.read_text(encoding='gbk', errors='replace')
+
+ # chord png
+ chord_path = chords_dir / f'{sanitize(artist)}-{sanitize(title)}.png'
+ if not chord_path.exists():
+ chord_path = None
+
+ play_count = int(playcounts.get(mp3.name, 0))
+
+ if args.dry_run:
+ print(f' + {prefix}'
+ f'{" [词]" if lyrics else ""}'
+ f'{" [谱]" if chord_path else ""}'
+ f'{f" [{play_count}次]" if play_count else ""}')
+ ok += 1
+ continue
+
+ try:
+ piece_id = create_piece(args.api, title, artist, lyrics)
+ print(f' + {prefix} → id={piece_id}', end='', flush=True)
+
+ upload_attachment(args.api, piece_id, mp3,
+ detect_audio_mime(mp3))
+ print(' [audio]', end='', flush=True)
+
+ if chord_path:
+ upload_attachment(args.api, piece_id, chord_path,
+ 'image/png', role='chord')
+ print(' [chord]', end='', flush=True)
+
+ if play_count:
+ patch_play_count(args.api, piece_id, play_count)
+ print(f' [{play_count}次]', end='', flush=True)
+
+ print()
+ ok += 1
+ except Exception as e:
+ print(f'\n[!] {prefix} 失败: {e}', file=sys.stderr)
+ failed += 1
+
+ print()
+ print(f'完成: ok={ok} skipped={skipped} failed={failed} total={total}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/apps/music/src/main.rs b/apps/music/src/main.rs
new file mode 100644
index 0000000..6f65ca0
--- /dev/null
+++ b/apps/music/src/main.rs
@@ -0,0 +1,684 @@
+//! music.famzheng.me — 听歌 + 练琴。
+//!
+//! 数据模型:曲目 (piece) → 附件 (attachment, 类型 video/audio/pdf/image)。
+//! 元数据走 sqlite,附件 bytes 落 `/data/blobs/`,Range 下载交给 tower-http ServeFile。
+//!
+//! API:
+//! - `GET /api/pieces` 列表(含附件计数 + 简要类型分布)
+//! - `POST /api/pieces` 创建(json: title, category?, notes?)
+//! - `GET /api/pieces/:id` 详情(含 attachments 列表)
+//! - `PATCH /api/pieces/:id` 改 title / category / notes
+//! - `DELETE /api/pieces/:id` 删曲目 + 级联删附件 + 同步删磁盘
+//! - `POST /api/pieces/:id/attachments` multipart 流式上传,可一次多文件
+//! - `GET /api/attachments/:id` 下载(带 Range,video/audio 拖动用)
+//! - `DELETE /api/attachments/:id` 删单个附件 + 磁盘文件
+
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+use axum::{
+ body::Body,
+ extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State},
+ http::{header, StatusCode},
+ response::{IntoResponse, Json as JsonResp, Response},
+ routing::{get, post},
+ Router,
+};
+use rusqlite::{params, Connection, OptionalExtension};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use tokio::io::AsyncWriteExt;
+use tower::ServiceExt;
+
+const SINGLE_FILE_BYTES: usize = 1024 * 1024 * 1024; // 1 GiB / 单附件
+const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传
+
+#[derive(Clone)]
+struct AppState {
+ db: Arc>,
+ blobs_dir: PathBuf,
+}
+
+#[tokio::main]
+async fn main() -> std::io::Result<()> {
+ cube_core::init_tracing();
+
+ let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
+ let blobs_dir =
+ PathBuf::from(std::env::var("BLOBS_DIR").unwrap_or_else(|_| "/data/blobs".into()));
+ let dist = std::env::var("MUSIC_DIST_DIR").unwrap_or_else(|_| "/dist".into());
+
+ std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir");
+
+ let conn = Connection::open(&db_path).expect("open sqlite");
+ conn.execute_batch(
+ "PRAGMA journal_mode=WAL;
+ PRAGMA foreign_keys=ON;
+ CREATE TABLE IF NOT EXISTS pieces (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ artist TEXT,
+ category TEXT,
+ notes TEXT,
+ lyrics TEXT,
+ play_count INTEGER NOT NULL DEFAULT 0,
+ last_played_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS attachments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ piece_id INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ role TEXT,
+ mime TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ size_bytes INTEGER NOT NULL DEFAULT 0,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);",
+ )
+ .expect("init schema");
+ tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
+
+ let state = AppState {
+ db: Arc::new(Mutex::new(conn)),
+ blobs_dir,
+ };
+
+ let api = Router::new()
+ .route("/pieces", get(list_pieces).post(create_piece))
+ .route(
+ "/pieces/:id",
+ get(get_piece).patch(patch_piece).delete(delete_piece),
+ )
+ .route("/pieces/:id/play", post(record_play))
+ .route(
+ "/pieces/:id/attachments",
+ post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
+ )
+ .route(
+ "/attachments/:id",
+ get(get_attachment).delete(delete_attachment),
+ )
+ .with_state(state);
+
+ let app = cube_core::base(dist).nest("/api", api);
+ cube_core::serve(app, 8080).await
+}
+
+// ---------- 类型 ----------
+
+#[derive(Serialize)]
+struct PieceSummary {
+ id: i64,
+ title: String,
+ artist: Option,
+ category: Option,
+ play_count: i64,
+ last_played_at: Option,
+ attachments: i64,
+ kinds: Vec,
+ has_lyrics: bool,
+ created_at: String,
+}
+
+#[derive(Serialize)]
+struct PieceDetail {
+ id: i64,
+ title: String,
+ artist: Option,
+ category: Option,
+ notes: Option,
+ lyrics: Option,
+ play_count: i64,
+ last_played_at: Option,
+ created_at: String,
+ attachments: Vec,
+}
+
+#[derive(Serialize)]
+struct Attachment {
+ id: i64,
+ kind: String,
+ role: Option,
+ mime: String,
+ filename: String,
+ size_bytes: i64,
+ sort_order: i64,
+ created_at: String,
+}
+
+#[derive(Deserialize)]
+struct UploadQuery {
+ role: Option,
+}
+
+#[derive(Deserialize)]
+struct CreatePiece {
+ title: String,
+ artist: Option,
+ category: Option,
+ notes: Option,
+ lyrics: Option,
+}
+
+#[derive(Deserialize)]
+struct PatchPiece {
+ title: Option,
+ artist: Option