新增下载接口

This commit is contained in:
anian
2026-07-02 15:59:08 +08:00
parent b169838443
commit 609db74a62
3 changed files with 529 additions and 1 deletions
+3
View File
@@ -2,6 +2,9 @@
> 基于Python的本地Web API项目 > 基于Python的本地Web API项目
#### v260702
1. 新增下载接口,同时提供web播放器anian-music.js下载
#### v260605 #### v260605
1. 新增期刊查询接口(数据源easyscholar) 1. 新增期刊查询接口(数据源easyscholar)
+460
View File
@@ -0,0 +1,460 @@
(() => {
let songs, audio, currentIndex = 0;
let floatingButton, progressRing, playerPanel, coverImg, popupBgImg, animationFrameId;
let lyricLines = [];
let isPlayerClosed = false;
const playlistApiUrl = "https://api.i-meto.com/meting/api?server=netease&type=playlist&id=8034031191";
// =========================
// 工具
// =========================
const getRandomSongIndex = () => {
if (!songs || !songs.length) return 0;
return Math.floor(Math.random() * songs.length);
};
const getCoverSrc = () => {
if (!songs || !songs.length) return "/logo.webp";
const pic = songs[currentIndex % songs.length]?.pic;
return pic?.trim() ? pic.trim() : "/logo.webp";
};
const setPopupBackground = (src) => {
if (!popupBgImg) return;
const fallback = "/logo.webp";
const nextSrc = src?.trim() ? src.trim() : fallback;
popupBgImg.src = nextSrc;
};
// =========================
// 封面
// =========================
const setCover = () => {
if (!coverImg) return;
const cover = getCoverSrc();
coverImg.src = cover;
setPopupBackground(cover);
};
// =========================
// audio
// =========================
const initAudio = () => {
if (!audio) {
audio = new Audio();
audio.volume = 0.5;
audio.onplay = () => { syncUI(); setRotate(); };
audio.onpause = () => { syncUI(); setRotate(); };
audio.onended = () => next();
}
};
// =========================
// LRC解析
// =========================
const parseLrc = (text) => {
const lines = text.split("\n");
const res = [];
const reg = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
for (let line of lines) {
const timeMatch = line.match(reg);
if (!timeMatch) continue;
const time =
(+timeMatch[1]) * 60 +
(+timeMatch[2]) +
(+timeMatch[3]) / (timeMatch[3].length === 3 ? 1000 : 100);
const txt = line.replace(reg, "").trim();
if (txt) res.push({ time, text: txt });
}
return res.sort((left, right) => left.time - right.time);
};
// =========================
// 歌词渲染
// =========================
const renderLrc = (top, mid, bottom) => {
const box = playerPanel?.querySelector(".lrc");
if (!box) return;
box.innerHTML = `
<div style="opacity:.4;font-size:12px;line-height:1.2;min-height:16px">${top}</div>
<div style="font-size:14px;font-weight:600;margin:2px 0;line-height:1.2;min-height:18px">${mid}</div>
<div style="opacity:.4;font-size:12px;line-height:1.2;min-height:16px">${bottom}</div>
`;
};
const renderLoadingLrc = () => {
renderLrc("加", "载", "中");
};
// =========================
// loading歌词
// =========================
const loadLyric = (song) => {
lyricLines = [];
renderLoadingLrc();
if (!song?.lrc) {
renderLrc("-", "暂无歌词", "-");
return;
}
fetch(song.lrc)
.then(response => response.text())
.then(text => {
lyricLines = parseLrc(text);
})
.catch(() => {
lyricLines = [];
renderLrc("-", "加载失败", "-");
});
};
// =========================
// load song
// =========================
const loadSong = () => {
if (!songs || !songs.length) return;
const currentSong = songs[currentIndex % songs.length];
initAudio();
audio.src = currentSong.url;
loadLyric(currentSong);
};
// =========================
// UI(标题)
// =========================
const syncUI = () => {
if (!playerPanel) return;
const titleBox = playerPanel.querySelector(".t");
if (!titleBox) return;
const currentSong = songs?.length ? songs[currentIndex % songs.length] : null;
titleBox.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:6px">
<div style="flex:1;line-height:1.2">
<div style="font-size:13px">${currentSong?.title || "加载中"}</div>
<div style="font-size:11px;opacity:.7;margin-top:2px">${currentSong?.author || "请稍候"}</div>
</div>
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0;">
<button class="minPlayerBtn" style="
width:24px;height:24px;
border:none;
border-radius:6px;
background:rgba(255,255,255,.08);
color:#fff;
font-size:14px;
cursor:pointer;
"></button>
<button class="closePlayerBtn" style="
width:24px;height:24px;
border:none;
border-radius:6px;
background:rgba(255,255,255,.08);
color:#fff;
font-size:14px;
cursor:pointer;
">×</button>
</div>
</div>
`;
const isPlaying = audio && !audio.paused;
playerPanel.querySelector(".pp").textContent = isPlaying ? "暂停" : "播放";
// 关闭(彻底关闭)
titleBox.querySelector(".closePlayerBtn").onclick = (event) => {
event.stopPropagation();
if (audio) {
audio.pause();
audio.src = "";
}
isPlayerClosed = true;
floatingButton.style.display = "none";
playerPanel.style.display = "none";
};
// 最小化(只收起面板)
titleBox.querySelector(".minPlayerBtn").onclick = (event) => {
event.stopPropagation();
if (playerPanel) playerPanel.style.display = "none";
};
};
// =========================
// rotate
// =========================
const setRotate = () => {
if (!coverImg) return;
coverImg.style.animationPlayState = (audio && !audio.paused) ? "running" : "paused";
};
// =========================
// 歌词更新
// =========================
const updateLrc = () => {
if (!lyricLines.length || !audio) return;
const currentTime = audio.currentTime;
let lyricIndex = 0;
for (let lyricLineIndex = 0; lyricLineIndex < lyricLines.length; lyricLineIndex++) {
if (currentTime >= lyricLines[lyricLineIndex].time) lyricIndex = lyricLineIndex;
else break;
}
renderLrc(
lyricLines[lyricIndex - 1]?.text || "-",
lyricLines[lyricIndex]?.text || "-",
lyricLines[lyricIndex + 1]?.text || "-"
);
};
// =========================
// progress
// =========================
const progress = () => {
if (!audio || !audio.duration) return;
const progressPercent = audio.currentTime / audio.duration;
progressRing.style.background =
`conic-gradient(#fff ${progressPercent * 360}deg,rgba(255,255,255,.15) 0)`;
const seek = playerPanel?.querySelector(".seek");
if (seek && !seek.matches(":active")) {
seek.value = Math.floor(progressPercent * 1000);
}
updateLrc();
};
// =========================
// play / pause / next
// =========================
const play = async () => {
initAudio();
if (!audio.src) loadSong();
try { await audio.play(); } catch { }
syncUI();
setRotate();
cancelAnimationFrame(animationFrameId);
loop();
};
const pause = () => {
if (audio) audio.pause();
syncUI();
setRotate();
};
const next = () => {
if (!songs || !songs.length) return;
let newIndex = getRandomSongIndex();
if (songs.length > 1) {
while (newIndex === currentIndex) newIndex = getRandomSongIndex();
}
currentIndex = newIndex;
const currentSong = songs[currentIndex % songs.length];
loadSong();
syncUI();
setRotate();
setCover();
loadLyric(currentSong);
audio.play().catch(() => { });
};
// =========================
// volume
// =========================
const volUp = () => {
if (!audio) return;
audio.volume = Math.min(1, audio.volume + 0.1);
};
const volDown = () => {
if (!audio) return;
audio.volume = Math.max(0, audio.volume - 0.1);
};
// =========================
// loop
// =========================
const loop = () => {
progress();
animationFrameId = requestAnimationFrame(loop);
};
// =========================
// UI init
// =========================
const initUI = () => {
floatingButton = document.createElement("div");
floatingButton.style.cssText =
"position:fixed;right:18px;bottom:18px;width:58px;height:58px;border-radius:50%;background:#111;z-index:99999;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden";
progressRing = document.createElement("div");
progressRing.style.cssText =
"position:absolute;inset:0;border-radius:50%;background:conic-gradient(rgba(255,255,255,.15) 0deg,transparent 0);z-index:0";
coverImg = document.createElement("img");
coverImg.src = "/logo.webp";
coverImg.style.cssText =
"width:46px;height:46px;border-radius:50%;object-fit:cover;z-index:2;position:relative;animation:spin 6s linear infinite;animation-play-state:paused";
floatingButton.appendChild(progressRing);
floatingButton.appendChild(coverImg);
playerPanel = document.createElement("div");
playerPanel.style.cssText =
"position:fixed;right:18px;bottom:90px;width:240px;background:#000;color:#fff;border-radius:12px;display:none;z-index:99999;overflow:hidden";
playerPanel.innerHTML = `
<div class="t"></div>
<div class="lrc" style="margin:10px 0 8px 0;text-align:center"></div>
<input class="seek" type="range" min="0" max="1000" value="0"
style="width:100%;margin:0 0 8px 0;accent-color:#fff">
<div style="display:flex;justify-content:space-evenly;align-items:center">
<button class="pp">播放</button>
<button class="cc">切歌</button>
<button class="vu">音量+</button>
<button class="vd">音量-</button>
</div>`;
const popupBg = document.createElement("img");
popupBg.className = "popupBg";
popupBg.alt = "";
const popupShade = document.createElement("div");
popupShade.className = "popupShade";
const popupContent = document.createElement("div");
popupContent.className = "popupContent";
while (playerPanel.firstChild) popupContent.appendChild(playerPanel.firstChild);
playerPanel.appendChild(popupBg);
playerPanel.appendChild(popupShade);
playerPanel.appendChild(popupContent);
popupBgImg = popupBg;
setPopupBackground("/logo.webp");
document.body.appendChild(floatingButton);
document.body.appendChild(playerPanel);
const style = document.createElement("style");
style.textContent = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
button {
transition: transform .05s ease, background .05s ease;
}
button:active {
transform: scale(0.92);
}
.popupBg {
position:absolute;
inset:0;
width:100%;
height:100%;
object-fit:cover;
z-index:0;
}
.popupShade {
position:absolute;
inset:0;
background:rgba(0,0,0,.58);
z-index:1;
}
.popupContent {
position:relative;
z-index:2;
padding:10px;
}
`;
document.head.appendChild(style);
floatingButton.onclick = () => {
if (isPlayerClosed) return;
playerPanel.style.display = playerPanel.style.display === "none" ? "block" : "none";
};
playerPanel.querySelector(".pp").onclick = (event) => {
event.stopPropagation();
if (!audio || audio.paused) play();
else pause();
};
playerPanel.querySelector(".cc").onclick = () => next();
playerPanel.querySelector(".vu").onclick = () => volUp();
playerPanel.querySelector(".vd").onclick = () => volDown();
const seek = playerPanel.querySelector(".seek");
seek.addEventListener("input", () => {
if (!audio || !audio.duration) return;
audio.currentTime = (seek.value / 1000) * audio.duration;
});
setInterval(progress, 100);
};
// =========================
// init
// =========================
initUI();
renderLoadingLrc();
syncUI();
fetch(playlistApiUrl)
.then(response => response.json())
.then(responseSongs => {
songs = responseSongs;
if (songs && songs.length) {
currentIndex = getRandomSongIndex();
setCover();
syncUI();
loadSong();
} else {
syncUI();
}
});
})();
+66 -1
View File
@@ -1,4 +1,4 @@
from flask import Flask, jsonify, request, redirect from flask import Flask, jsonify, request, redirect, send_file
from datetime import datetime, date from datetime import datetime, date
import ipaddress import ipaddress
from zoneinfo import ZoneInfo, available_timezones from zoneinfo import ZoneInfo, available_timezones
@@ -113,6 +113,7 @@ a:hover{text-decoration:underline;}</style>
<tr><td>时间接口</td><td><a href="/time/api">/time/api</a></td><td><a href="/time/help">/time/help</a></td></tr> <tr><td>时间接口</td><td><a href="/time/api">/time/api</a></td><td><a href="/time/help">/time/help</a></td></tr>
<tr><td>谜语接口</td><td><a href="/riddle/api">/riddle/api</a></td><td><a href="/riddle/help">/riddle/help</a></td></tr> <tr><td>谜语接口</td><td><a href="/riddle/api">/riddle/api</a></td><td><a href="/riddle/help">/riddle/help</a></td></tr>
<tr><td>期刊接口</td><td><a href="/sci/api">/sci/api</a></td><td><a href="/sci/help">/sci/help</a></td></tr> <tr><td>期刊接口</td><td><a href="/sci/api">/sci/api</a></td><td><a href="/sci/help">/sci/help</a></td></tr>
<tr><td>下载接口</td><td><a href="/pan/api">/pan/api</a></td><td><a href="/pan/help">/pan/help</a></td></tr>
</tbody></table></body></html> </tbody></table></body></html>
""" """
@@ -583,6 +584,70 @@ def sci():
return jsonify(resp) return jsonify(resp)
@app.route("/pan/help")
def pan_help():
return jsonify({
"路径": "/pan/api",
"功能": "下载文件",
"参数": {
"name=xxx": "必填,文件名",
"download=true/false": "下载/预览",
},
"列表": ["anian-music.js"]
})
@app.route("/pan/api")
def pan_file_download():
args_lower = {k.lower(): v for k, v in request.args.items()}
allowed_keys = {"name", "download"}
extra_params = set(args_lower.keys()) - allowed_keys
if extra_params:
return jsonify({
"error": f"{extra_params}参数非法",
"allowed_params": ["name=xxx", "download=true/false"],
})
file_name = args_lower.get("name", "").strip()
download_param = args_lower.get("download", "").strip()
if download_param and download_param.lower() not in {"true", "false"}:
resp = {
"error": f"download参数[{download_param}]不为true/false",
"params": {"name": file_name, "download": download_param}
}
return jsonify(resp)
force_download = download_param.lower() == "true"
params = {"name": file_name, "download": download_param if download_param else "false(默认)"}
if not file_name:
resp = {
"error": "name参数不能为空",
"params": params
}
return jsonify(resp)
pan_dir = Path(__file__).parent / "data" / "pan"
target_file = pan_dir / file_name
real_pan = pan_dir.resolve()
real_target = target_file.resolve()
if not str(real_target).startswith(str(real_pan)):
resp = {
"error": "文件名非法",
"params": params
}
return jsonify(resp)
if not target_file.is_file():
resp = {
"error": f"文件 {file_name} 不存在",
"help": "请前往/pan/help查看文件列表",
"params": params
}
return jsonify(resp)
# 在线预览/播放核心:as_attachment=False
return send_file(
target_file,
as_attachment=force_download,
download_name=file_name
)
@app.errorhandler(404) @app.errorhandler(404)
def not_found(_error): def not_found(_error):
return redirect("/"), 302 return redirect("/"), 302