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 @@ +