diff --git a/Cargo.lock b/Cargo.lock index 3e21f73..780a699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -225,6 +240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -233,6 +249,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -251,8 +295,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -671,11 +720,13 @@ version = "0.1.0" dependencies = [ "axum", "cube-core", + "futures", "reqwest", "rusqlite", "serde", "serde_json", "tokio", + "tokio-stream", "tower", "tower-http", "tracing", @@ -898,6 +949,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -917,12 +969,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -1281,6 +1335,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1564,6 +1629,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.98" diff --git a/Cargo.toml b/Cargo.toml index 4e7e540..02a935a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +futures = "0.3" +tokio-stream = "0.1" [profile.release] opt-level = "z" diff --git a/apps/music/Cargo.toml b/apps/music/Cargo.toml index 7a34143..9e8eea1 100644 --- a/apps/music/Cargo.toml +++ b/apps/music/Cargo.toml @@ -17,3 +17,5 @@ serde = { workspace = true } serde_json = { workspace = true } rusqlite = { workspace = true } reqwest = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } diff --git a/apps/music/frontend/src/lib/api.js b/apps/music/frontend/src/lib/api.js index cf9a710..0327969 100644 --- a/apps/music/frontend/src/lib/api.js +++ b/apps/music/frontend/src/lib/api.js @@ -8,8 +8,12 @@ async function jsonOrThrow(res) { return res.json() } -export function listPieces() { - return fetch('/api/pieces').then(jsonOrThrow) +export function listPieces({ tag, playlist } = {}) { + const qs = new URLSearchParams() + if (tag) qs.set('tag', tag) + if (playlist) qs.set('playlist', String(playlist)) + const q = qs.toString() + return fetch('/api/pieces' + (q ? '?' + q : '')).then(jsonOrThrow) } export function getPiece(id) { @@ -65,3 +69,126 @@ export function chordFetch(pieceId) { export function chordStatus(pieceId) { return fetch(`/api/pieces/${pieceId}/chord/status`).then(jsonOrThrow) } + +// ---- chat ---- + +export function listChat(pieceId) { + return fetch(`/api/pieces/${pieceId}/chat`).then(jsonOrThrow) +} + +export function clearChat(pieceId) { + return fetch(`/api/pieces/${pieceId}/chat`, { method: 'DELETE' }).then(jsonOrThrow) +} + +/** + * stream a chat reply. + * @param {number} pieceId + * @param {string} message + * @param {(delta: string) => void} onDelta + * @param {AbortSignal} signal + * @returns {Promise<{ok: boolean, error?: string}>} + */ +export async function streamChat(pieceId, message, onDelta, signal) { + const resp = await fetch(`/api/pieces/${pieceId}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + signal, + }) + if (!resp.ok) { + const text = await resp.text().catch(() => '') + return { ok: false, error: text || `${resp.status}` } + } + const reader = resp.body.getReader() + const dec = new TextDecoder() + let buf = '' + let lastEvent = 'message' + let errorMsg = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + let idx + while ((idx = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, idx) + buf = buf.slice(idx + 1) + // SSE 协议:空行 = event 边界(已在内容中处理);event:/data: 各自一行 + if (line.startsWith('event:')) { + lastEvent = line.slice(6).trim() + } else if (line.startsWith('data:')) { + const data = line.slice(5).replace(/^ /, '') + if (lastEvent === 'error') { + errorMsg = data + } else if (lastEvent === 'done') { + // 不带 data 也算结束 + } else { + onDelta(data) + } + } else if (line === '') { + lastEvent = 'message' + } + } + } + if (errorMsg) return { ok: false, error: errorMsg } + return { ok: true } +} + +// ---- tags ---- + +export function listTags() { + return fetch('/api/tags').then(jsonOrThrow) +} + +export function createTag(name) { + return fetch('/api/tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }).then(jsonOrThrow) +} + +export function deleteTag(id) { + return fetch(`/api/tags/${id}`, { method: 'DELETE' }).then(jsonOrThrow) +} + +// ---- playlists ---- + +export function listPlaylists() { + return fetch('/api/playlists').then(jsonOrThrow) +} + +export function createPlaylist(name, description) { + return fetch('/api/playlists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description: description || null }), + }).then(jsonOrThrow) +} + +export function getPlaylist(id) { + return fetch(`/api/playlists/${id}`).then(jsonOrThrow) +} + +export function patchPlaylist(id, body) { + return fetch(`/api/playlists/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(jsonOrThrow) +} + +export function deletePlaylist(id) { + return fetch(`/api/playlists/${id}`, { method: 'DELETE' }).then(jsonOrThrow) +} + +export function playlistAddPiece(playlistId, pieceId) { + return fetch(`/api/playlists/${playlistId}/pieces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ piece_id: pieceId }), + }).then(jsonOrThrow) +} + +export function playlistRemovePiece(playlistId, pieceId) { + return fetch(`/api/playlists/${playlistId}/pieces/${pieceId}`, { method: 'DELETE' }).then(jsonOrThrow) +} diff --git a/apps/music/frontend/src/views/EditView.vue b/apps/music/frontend/src/views/EditView.vue index a4864dd..559a3f2 100644 --- a/apps/music/frontend/src/views/EditView.vue +++ b/apps/music/frontend/src/views/EditView.vue @@ -41,6 +41,26 @@ 笔记 +
这首歌还没在任何歌单里。
+
@@ -119,6 +165,11 @@ import {
deletePiece,
uploadAttachments,
deleteAttachment,
+ listTags,
+ listPlaylists,
+ playlistAddPiece,
+ playlistRemovePiece,
+ getPlaylist,
} from '../lib/api.js'
const props = defineProps({ id: { type: Number, required: true } })
@@ -138,6 +189,14 @@ const form = reactive({
const savingMeta = ref(false)
const savedFlash = ref(false)
+const tags = ref([]) // 当前 piece 的 tags 数组
+const allTags = ref([]) // 全局所有 tags(用于 datalist 提示)
+const tagInput = ref('')
+
+const playlists = ref([]) // 全部 playlists
+const memberPlaylists = ref([]) // 当前 piece 在哪些 playlists 里(id 集合)
+const addToPlaylistId = ref('')
+
const fileInputEl = ref(null)
const pendingFiles = ref([])
const uploadRole = ref(null)
@@ -156,6 +215,20 @@ async function load() {
form.category = p.category || ''
form.lyrics = p.lyrics || ''
form.notes = p.notes || ''
+ tags.value = [...(p.tags || [])]
+ // 全局 tag 列表 + 我所属的 playlists
+ const [allT, allPl] = await Promise.all([listTags(), listPlaylists()])
+ allTags.value = allT || []
+ playlists.value = allPl || []
+ // 反查所属:每个 playlist 拿 detail,看 pieces 包含 props.id 否
+ const memberships = await Promise.all(
+ (allPl || []).map(pl =>
+ getPlaylist(pl.id)
+ .then(d => (d.pieces || []).some(x => x.id === props.id) ? pl.id : null)
+ .catch(() => null)
+ )
+ )
+ memberPlaylists.value = memberships.filter(x => x != null)
} catch (e) {
loadErr.value = e.message || String(e)
} finally {
@@ -163,6 +236,39 @@ async function load() {
}
}
+function addTag() {
+ const v = tagInput.value.trim()
+ if (!v) return
+ if (!tags.value.includes(v)) tags.value.push(v)
+ tagInput.value = ''
+}
+
+function removeTag(t) {
+ tags.value = tags.value.filter(x => x !== t)
+}
+
+async function joinPlaylist() {
+ const pid = parseInt(addToPlaylistId.value)
+ if (!pid) return
+ try {
+ await playlistAddPiece(pid, props.id)
+ addToPlaylistId.value = ''
+ await load()
+ } catch (e) {
+ alert(e.message || String(e))
+ }
+}
+
+async function leavePlaylist(plId) {
+ if (!confirm('从这个歌单移除?')) return
+ try {
+ await playlistRemovePiece(plId, props.id)
+ await load()
+ } catch (e) {
+ alert(e.message || String(e))
+ }
+}
+
async function saveMeta() {
savingMeta.value = true
try {
@@ -172,6 +278,7 @@ async function saveMeta() {
category: form.category.trim() || null,
lyrics: form.lyrics || null,
notes: form.notes || null,
+ tags: tags.value,
})
savedFlash.value = true
setTimeout(() => (savedFlash.value = false), 1500)
@@ -393,4 +500,59 @@ onMounted(load)
.danger-block { background: rgba(239, 68, 68, 0.05); }
.danger-block .btn-danger { padding: 8px 18px; font-size: 13px; }
+
+.tag-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 8px;
+}
+.tag-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: rgba(192, 132, 252, 0.12);
+ color: var(--accent);
+ border-radius: 12px;
+ padding: 2px 4px 2px 10px;
+ font-size: 12px;
+}
+.tag-x {
+ background: none;
+ color: var(--accent);
+ font-size: 14px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.tag-x:hover { background: rgba(192, 132, 252, 0.2); }
+.tag-input {
+ flex: 1;
+ min-width: 200px;
+ background: transparent;
+ border: none;
+ font-size: 13px;
+ color: var(--text);
+ padding: 4px 6px;
+}
+.tag-input:focus { outline: none; }
+
+.pl-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
+.join-row { display: flex; gap: 8px; align-items: center; }
+.join-row select {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 10px;
+ font-size: 13px;
+ color: var(--text);
+ min-width: 220px;
+}
diff --git a/apps/music/frontend/src/views/PlayerView.vue b/apps/music/frontend/src/views/PlayerView.vue
index 0cfed45..1470fb1 100644
--- a/apps/music/frontend/src/views/PlayerView.vue
+++ b/apps/music/frontend/src/views/PlayerView.vue
@@ -12,6 +12,38 @@