diff --git a/.github/workflows/cloudflare-deploy.yml b/.github/workflows/cloudflare-deploy.yml index c855243..66987b0 100644 --- a/.github/workflows/cloudflare-deploy.yml +++ b/.github/workflows/cloudflare-deploy.yml @@ -67,13 +67,23 @@ jobs: echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV - - name: Replace D1 Database ID in wrangler.toml + - name: Configure D1 Database in wrangler.toml run: | - if [ -n "${{ secrets.D1_DATABASE_ID }}" ]; then + STORAGE_TYPE="${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}" + + if [ "$STORAGE_TYPE" != "d1" ]; then + echo "Storage type is not d1 (current: $STORAGE_TYPE), removing D1 database configuration" + # Remove the entire [[d1_databases]] section including the comment + sed -i '/^# D1 数据库绑定$/,/^database_id = .*$/d' wrangler.toml + # Also remove any remaining [[d1_databases]] block + sed -i '/^\[\[d1_databases\]\]$/,/^$/{ /^\[\[d1_databases\]\]$/d; /^binding = /d; /^database_name = /d; /^database_id = /d; }' wrangler.toml + echo "D1 configuration removed from wrangler.toml" + elif [ -n "${{ secrets.D1_DATABASE_ID }}" ]; then sed -i 's/REPLACE_WITH_YOUR_D1_DATABASE_ID/${{ secrets.D1_DATABASE_ID }}/g' wrangler.toml echo "D1 Database ID replaced successfully" else - echo "D1_DATABASE_ID secret not set, skipping replacement" + echo "⚠️ Storage type is d1 but D1_DATABASE_ID secret not set" + exit 1 fi - name: Update wrangler.toml with environment variables @@ -130,6 +140,13 @@ jobs: - name: Run D1 Database Migrations run: | + STORAGE_TYPE="${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}" + + if [ "$STORAGE_TYPE" != "d1" ]; then + echo "Storage type is not d1 (current: $STORAGE_TYPE), skipping D1 migrations" + exit 0 + fi + if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then echo "D1_DATABASE_ID not set, skipping migrations" exit 0 diff --git a/README.md b/README.md index d2c7cf5..c3db67d 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Cloudflare Workers 提供免费的边缘计算服务,通过 GitHub Actions 可 - 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板 - 或使用自定义 Token,需要以下权限: - Account - Cloudflare Workers Scripts - Edit - - Account - Cloudflare Workers KV Storage - Edit + - Account - D1 - Edit(仅在使用 D1 数据库时需要) - 创建后复制生成的 API Token - 在 Dashboard 首页右侧可以看到你的 Account ID diff --git a/public/icons/OrionTV.png b/public/icons/OrionTV.png new file mode 100644 index 0000000..2bfd5ab Binary files /dev/null and b/public/icons/OrionTV.png differ diff --git a/public/icons/Selene.png b/public/icons/Selene.png new file mode 100644 index 0000000..0d13318 Binary files /dev/null and b/public/icons/Selene.png differ diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8e6a8cd..201a5a7 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3621,11 +3621,6 @@ const EmbyConfigComponent = ({ // 删除源 const handleDelete = async (source: any) => { - if (sources.length === 1) { - showError('至少需要保留一个Emby源', showAlert); - return; - } - if (!confirm(`确定要删除 "${source.name}" 吗?`)) { return; } diff --git a/src/app/api/admin/data_migration/export/route.ts b/src/app/api/admin/data_migration/export/route.ts index c28fd56..0f023e4 100644 --- a/src/app/api/admin/data_migration/export/route.ts +++ b/src/app/api/admin/data_migration/export/route.ts @@ -170,6 +170,14 @@ async function getUserPasswordV2(username: string): Promise { // 检查存储类型 const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + // PostgreSQL 存储:使用 getUserPasswordHash 方法 + if (storageType === 'postgres') { + if (typeof storage.getUserPasswordHash === 'function') { + return await storage.getUserPasswordHash(username); + } + return null; + } + // D1 存储:使用 getUserPasswordHash 方法 if (storageType === 'd1') { if (typeof storage.getUserPasswordHash === 'function') { diff --git a/src/app/api/admin/data_migration/import/route.ts b/src/app/api/admin/data_migration/import/route.ts index 96b33d0..c8c6c1d 100644 --- a/src/app/api/admin/data_migration/import/route.ts +++ b/src/app/api/admin/data_migration/import/route.ts @@ -151,6 +151,28 @@ export async function POST(req: NextRequest) { } catch (err) { console.error(`导入用户 ${username} 失败:`, err); } + } else if (storageType === 'postgres') { + // Postgres 存储:使用 createUserWithHashedPassword 方法 + try { + if (typeof storage.createUserWithHashedPassword === 'function') { + await storage.createUserWithHashedPassword( + username, + user.passwordV2, // 已经是hash过的密码 + role, + createdAt, + userV2?.tags, + userV2?.oidcSub, + userV2?.enabledApis, + userV2?.banned + ); + importedCount++; + console.log(`用户 ${username} 导入成功 (Postgres)`); + } else { + console.error(`Postgres storage 缺少 createUserWithHashedPassword 方法`); + } + } catch (err) { + console.error(`导入用户 ${username} 失败:`, err); + } } else { // Redis 存储:直接设置用户信息 const userInfoKey = `user:${username}:info`; diff --git a/src/app/api/admin/openlist/route.ts b/src/app/api/admin/openlist/route.ts index 0728b23..b70afe5 100644 --- a/src/app/api/admin/openlist/route.ts +++ b/src/app/api/admin/openlist/route.ts @@ -14,11 +14,18 @@ export const runtime = 'nodejs'; */ function cleanPath(path: string): string { // 移除 UTF-8 BOM (U+FEFF) 和其他零宽度字符 - return path + let cleaned = path .replace(/^\uFEFF/, '') // 移除开头的 BOM .replace(/\uFEFF/g, '') // 移除所有 BOM .replace(/[\u200B-\u200D\uFEFF]/g, '') // 移除零宽度字符 .trim(); // 移除首尾空白 + + // 移除末尾的 /(除非路径就是 /) + if (cleaned.length > 1 && cleaned.endsWith('/')) { + cleaned = cleaned.slice(0, -1); + } + + return cleaned; } /** diff --git a/src/app/api/music/route.ts b/src/app/api/music/route.ts index 70634c6..588f3f6 100644 --- a/src/app/api/music/route.ts +++ b/src/app/api/music/route.ts @@ -125,14 +125,16 @@ async function replaceAudioUrlsWithOpenList( quality: string, cachePath: string ): Promise { - if (!openListClient || !data?.data) { + // 获取配置,检查是否启用 OpenList 缓存 + const config = await getConfig(); + const cacheEnabled = config?.MusicConfig?.OpenListCacheEnabled ?? false; + const cacheProxyEnabled = config?.MusicConfig?.OpenListCacheProxyEnabled ?? true; + + // 如果没有启用 OpenList 缓存,直接返回原数据 + if (!cacheEnabled || !openListClient || !data?.data) { return data; } - // 获取配置,检查是否启用缓存代理 - const config = await getConfig(); - const cacheProxyEnabled = config?.MusicConfig?.OpenListCacheProxyEnabled ?? true; - // TuneHub 返回的数据结构是 { code: 0, data: { data: [...], total: 1 } } // 需要提取内层的 data 数组 const songsData = data.data.data || data.data; diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts index 1c86565..6d52a03 100644 --- a/src/app/api/proxy-m3u8/route.ts +++ b/src/app/api/proxy-m3u8/route.ts @@ -35,13 +35,22 @@ export async function GET(request: Request) { } // 获取当前请求的 origin - const requestUrl = new URL(request.url); - const origin = `${requestUrl.protocol}//${requestUrl.host}`; + // 优先级:SITE_BASE 环境变量 > 从请求头构建 + let origin = process.env.SITE_BASE; + if (!origin) { + const requestUrl = new URL(request.url); + origin = `${requestUrl.protocol}//${requestUrl.host}`; + } // 获取原始 m3u8 内容 + const m3u8UrlObj = new URL(m3u8Url); const response = await fetch(m3u8Url, { headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Referer': `${m3u8UrlObj.protocol}//${m3u8UrlObj.host}/`, }, }); diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 60d0e52..1c62400 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -359,6 +359,13 @@ export default function MusicPage() { } }, [playRecords, pendingSongToPlay]); + // 同步音量状态到 audio 元素 + useEffect(() => { + if (audioRef.current) { + audioRef.current.volume = volume / 100; + } + }, [volume]); + // 执行前端 transform(用于 Cloudflare 环境) const executeTransform = (data: any) => { if (data && typeof data === 'object' && data.__transform) { @@ -1171,6 +1178,52 @@ export default function MusicPage() { } }; + // 触摸/鼠标滑动音量调节(移动端兼容) + const handleVolumeSliderInteraction = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const slider = e.currentTarget; + const rect = slider.getBoundingClientRect(); + + const updateVolume = (clientY: number) => { + // 计算相对于滑块顶部的位置 + const y = clientY - rect.top; + // 限制在滑块范围内 + const clampedY = Math.max(0, Math.min(rect.height, y)); + // 从上到下:0% -> 100%,从下到上:100% -> 0% + const percentage = 100 - (clampedY / rect.height) * 100; + const newVolume = Math.round(percentage); + + setVolume(newVolume); + if (audioRef.current) { + audioRef.current.volume = newVolume / 100; + } + }; + + // 获取初始触摸/点击位置 + const clientY = 'touches' in e ? e.touches[0]?.clientY || 0 : e.clientY; + updateVolume(clientY); + + const handleMove = (moveEvent: MouseEvent | TouchEvent) => { + moveEvent.preventDefault(); + const moveClientY = 'touches' in moveEvent ? moveEvent.touches[0]?.clientY || 0 : moveEvent.clientY; + updateVolume(moveClientY); + }; + + const handleEnd = () => { + document.removeEventListener('mousemove', handleMove); + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('touchmove', handleMove); + document.removeEventListener('touchend', handleEnd); + }; + + document.addEventListener('mousemove', handleMove); + document.addEventListener('mouseup', handleEnd); + document.addEventListener('touchmove', handleMove, { passive: false }); + document.addEventListener('touchend', handleEnd); + }; + // PiP 窗口管理 const togglePiPLyrics = () => { if (!('documentPictureInPicture' in window)) { @@ -1939,19 +1992,15 @@ export default function MusicPage() {
{volume} -
+
-
diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx index 730b2a5..8b4d94b 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -579,6 +579,7 @@ export default function PrivateLibraryPage() { )} + {/* 生态应用按钮 */} + + {/* 分割线 */}
@@ -1885,6 +1913,30 @@ export const UserMenu: React.FC = () => {
+ + {/* 精确搜索 */} +
+
+

+ 精确搜索 +

+

+ 开启后,搜索结果将过滤掉不包含搜索词的内容 +

+
+ +
)} @@ -2859,6 +2911,194 @@ export const UserMenu: React.FC = () => { ); + // 生态应用面板内容 + const ecoAppsPanel = ( + <> + {/* 背景遮罩 */} +
setIsEcoAppsOpen(false)} + onTouchMove={(e) => { + e.preventDefault(); + }} + onWheel={(e) => { + e.preventDefault(); + }} + style={{ + touchAction: 'none', + }} + /> + + {/* 生态应用面板 */} +
+
{ + e.stopPropagation(); + }} + style={{ + touchAction: 'auto', + }} + > + {/* 标题栏 */} +
+

+ 生态应用 +

+ +
+ + {/* 应用列表 */} +
+
+ {/* MoonTVPlus-PC 客户端 */} +
+
+
+ MoonTVPlus-PC +
+ +
+
+
+

+ MoonTVPlus-PC客户端 +

+

+ 专为Windows开发的客户端,完美支持私人影库mkv视频 +

+ + + 下载 + + +
+
+
+ + {/* Selene 跨平台客户端 */} +
+
+
+ Selene + + 二开 + +
+
+

+ Selene 跨平台客户端 +

+

+ 多平台客户端 +

+
+ + + 安卓下载 + + + +
+
+
+
+ + {/* OrionTV TV专用客户端 */} +
+
+
+ OrionTV + + 二开 + +
+
+

+ OrionTV TV专用客户端 +

+

+ tv专用 +

+ + + 下载 + + +
+
+
+
+
+ + {/* 底部说明 */} +
+

+ 选择适合您设备的客户端下载使用 +

+
+
+
+ + ); + return ( <>
@@ -2942,6 +3182,11 @@ export const UserMenu: React.FC = () => { mounted && createPortal(deviceManagementPanel, document.body)} + {/* 使用 Portal 将生态应用面板渲染到 document.body */} + {isEcoAppsOpen && + mounted && + createPortal(ecoAppsPanel, document.body)} + {/* 确认对话框 */} {confirmDialog.isOpen && mounted && diff --git a/src/lib/openlist-refresh.ts b/src/lib/openlist-refresh.ts index 48b3f14..816cfdb 100644 --- a/src/lib/openlist-refresh.ts +++ b/src/lib/openlist-refresh.ts @@ -254,7 +254,8 @@ async function performScan( const pageFolders = listResponse.data.content.filter((item) => item.is_dir); folders.push(...pageFolders); - if (folders.length >= total) { + // 判断是否还有更多数据:当前页为 null 或数据量小于 pageSize 说明已经是最后一页 + if (!listResponse.data.content || listResponse.data.content.length < pageSize) { break; }