diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6039e99 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +.gitignore +.github +npm-debug.log +Dockerfile +docker-compose.yml +test/ +src/ +providers/ +*.log +.DS_Store +coverage/ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..bbde4fa --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,32 @@ +name: Build and publish Docker image + +on: + push: + branches: [ main, master ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository_owner }}/meting-api:latest,ghcr.io/${{ github.repository_owner }}/meting-api:${{ github.sha }} + platforms: linux/amd64,linux/arm64 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4428640 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Builder stage: install deps + build +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --legacy-peer-deps +COPY . . +RUN npm run build + +# Runner stage: production runtime only +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY package*.json ./ +RUN npm ci --omit=dev --production --legacy-peer-deps +COPY --from=builder /app/lib ./lib +COPY --from=builder /app/server ./server +COPY --from=builder /app/README.md ./README.md +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "server/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e047d57 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.8" +services: + meting: + build: . + image: meting-api:latest + ports: + - "3200:3000" # 主机 3200 -> 容器 3000 + environment: + - PORT=3000 + restart: unless-stopped diff --git a/package-lock.json b/package-lock.json index 6a5f8c2..2e93358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meting/core", - "version": "1.5.12", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meting/core", - "version": "1.5.12", + "version": "1.6.0", "license": "MIT", "devDependencies": { "@rollup/plugin-babel": "^6.0.4", diff --git a/package.json b/package.json index 3f0b422..6bd56dd 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "prepublishOnly": "npm run build", "test": "npm run build && node test/test.js", "example": "npm run build && node test/example.js", + "api": "npm run build && node server/index.js", "start": "npm run build && node test/example.js" }, "keywords": [ diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..429980c --- /dev/null +++ b/server/index.js @@ -0,0 +1,180 @@ +import http from 'node:http'; +import Meting from '../lib/meting.esm.js'; + +const PORT = Number(process.env.PORT || 3000); + +/** + * Minimal CORS helper (no external deps) + */ +function setCors(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-Requested-With' + ); +} + +function sendJsonString(res, jsonString) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(jsonString); +} + +function sendError(res, statusCode, payload) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +function pick(obj, keys) { + const out = {}; + for (const k of keys) { + if (obj[k] !== undefined) out[k] = obj[k]; + } + return out; +} + +/** + * Query format compatible with meting-api style: + * /?server=netease&type=playlist&id=8724039279 + * Also supports: + * /api?server=netease&type=search&keyword=xxx&limit=10&page=1 + * + * Supported type: + * - search (keyword required; supports page, limit, type(category)) + * - song, album, artist, playlist (id required; artist supports limit) + * - url (id required; br optional) + * - lyric (id required) + * - pic (id required; size optional) + */ +async function handleMeting(query) { + const server = query.server || 'netease'; + const type = (query.type || '').toString().toLowerCase(); + + const meting = new Meting(server); + meting.format(query.format === 'true' || query.format === '1' || query.format === true); + + // Normalize some common aliases + const t = + type === 'songs' ? 'song' + : type === 'artists' ? 'artist' + : type === 'albums' ? 'album' + : type; + + if (!t) { + throw new Error('Missing query param: type'); + } + + if (t === 'search') { + const keyword = query.keyword ?? query.s; + if (!keyword) throw new Error('Missing query param: keyword'); + const option = { + ...pick(query, ['type', 'page', 'limit']), + }; + // cast numeric + if (option.type !== undefined) option.type = Number(option.type); + if (option.page !== undefined) option.page = Number(option.page); + if (option.limit !== undefined) option.limit = Number(option.limit); + + return await meting.search(String(keyword), option); + } + + if (t === 'song') { + const id = query.id; + if (!id) throw new Error('Missing query param: id'); + return await meting.song(id); + } + + if (t === 'album') { + const id = query.id; + if (!id) throw new Error('Missing query param: id'); + return await meting.album(id); + } + + if (t === 'artist') { + const id = query.id; + if (!id) throw new Error('Missing query param: id'); + const limit = query.limit !== undefined ? Number(query.limit) : undefined; + return await meting.artist(id, limit); + } + + if (t === 'playlist') { + const id = query.id; + if (!id) throw new Error('Missing query param: id'); + return await meting.playlist(id); + } + + if (t === 'url') { + const id = query.id; + if (!id) throw new Error('Missing query param: id'); + const br = query.br !== undefined ? Number(query.br) : undefined; + return await meting.url(id, br); + } + + if (t === 'lyric') { + const id = query.id; + if (!id) throw new Error('Missing query param: id'); + return await meting.lyric(id); + } + + if (t === 'pic') { + const id = query.id; + if (!id) throw new Error('Missing query param: id'); + const size = query.size !== undefined ? Number(query.size) : undefined; + return await meting.pic(id, size); + } + + throw new Error(`Unsupported type: ${t}`); +} + +const server = http.createServer(async (req, res) => { + setCors(res); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + try { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + // Only support GET for now + if (req.method !== 'GET') { + sendError(res, 405, { error: 'Method Not Allowed' }); + return; + } + + // Health check + if (url.pathname === '/health') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ ok: true })); + return; + } + + // Accept both "/" and "/api" + if (url.pathname !== '/' && url.pathname !== '/api') { + sendError(res, 404, { error: 'Not Found' }); + return; + } + + const query = Object.fromEntries(url.searchParams.entries()); + const result = await handleMeting(query); + sendJsonString(res, result); + } catch (e) { + sendError(res, 500, { + error: 'Meting API Error', + message: e?.message || String(e), + }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + // eslint-disable-next-line no-console + console.log(`[meting-api] listening on http://0.0.0.0:${PORT}`); + console.log(`[meting-api] examples:`); + console.log(` - http://localhost:${PORT}/?server=netease&type=playlist&id=8724039279`); + console.log(` - http://localhost:${PORT}/api?server=netease&type=search&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&limit=5`); +});