Merge branch 'dev'

This commit is contained in:
mtvpls
2026-02-13 22:40:49 +08:00
15 changed files with 420 additions and 34 deletions

View File

@@ -3621,11 +3621,6 @@ const EmbyConfigComponent = ({
// 删除源
const handleDelete = async (source: any) => {
if (sources.length === 1) {
showError('至少需要保留一个Emby源', showAlert);
return;
}
if (!confirm(`确定要删除 "${source.name}" 吗?`)) {
return;
}

View File

@@ -170,6 +170,14 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
// 检查存储类型
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') {

View File

@@ -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`;

View File

@@ -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;
}
/**

View File

@@ -125,14 +125,16 @@ async function replaceAudioUrlsWithOpenList(
quality: string,
cachePath: string
): Promise<any> {
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;

View File

@@ -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}/`,
},
});

View File

@@ -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<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
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() {
<div className="bg-zinc-800/95 backdrop-blur-sm rounded-lg p-3 shadow-xl border border-white/10">
<div className="flex flex-col items-center gap-2">
<span className="text-xs text-zinc-400 font-mono">{volume}</span>
<div className="h-24 w-1 bg-white/10 rounded-full relative">
<div
className="h-24 w-6 bg-white/10 rounded-full relative cursor-pointer select-none"
onMouseDown={handleVolumeSliderInteraction}
onTouchStart={handleVolumeSliderInteraction}
>
<div
className="absolute bottom-0 left-0 right-0 bg-green-500 rounded-full transition-all pointer-events-none"
style={{ height: `${volume}%` }}
/>
<input
type="range"
min="0"
max="100"
value={volume}
onChange={handleVolumeChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer [writing-mode:bt-lr] [-webkit-appearance:slider-vertical]"
/>
</div>
</div>
</div>

View File

@@ -579,6 +579,7 @@ export default function PrivateLibraryPage() {
<button
onClick={() => router.push('/movie-request')}
className='flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'
style={{ marginTop: '10px' }}
>
<Film size={20} />
<span></span>

View File

@@ -62,6 +62,8 @@ function SearchPageClient() {
const [forceRefresh, setForceRefresh] = useState(false);
// 是否使用了缓存结果
const [isFromCache, setIsFromCache] = useState(false);
// 精确搜索开关
const [exactSearch, setExactSearch] = useState(true);
// 生成缓存键
const getCacheKey = (query: string) => {
@@ -257,12 +259,28 @@ function SearchPageClient() {
// 2.4 兜底:使用 episodes.length最不可靠
return item.episodes.length === 1 ? 'movie' : 'tv';
};
// 辅助函数:检查标题是否包含搜索词(用于精确搜索)
const titleContainsQuery = (title: string, query: string): boolean => {
if (!exactSearch) return true; // 如果未开启精确搜索,不过滤
if (!query || !title) return true; // 如果没有搜索词或标题,不过滤
const normalizedTitle = title.toLowerCase();
const normalizedQuery = query.toLowerCase();
return normalizedTitle.includes(normalizedQuery);
};
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
// 首先应用精确搜索过滤
const filteredResults = exactSearch
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
: searchResults;
//===== 阶段1按 normalizedTitle-type 初步分组 =====
const preliminaryMap = new Map<string, SearchResult[]>();
searchResults.forEach((item) => {
filteredResults.forEach((item) => {
const normalizedTitle = normalizeTitle(item.title);
const type = getType(item);
const preliminaryKey = `${normalizedTitle}-${type}`;
@@ -316,7 +334,7 @@ function SearchPageClient() {
// 按出现顺序返回聚合结果
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
}, [searchResults]);
}, [searchResults, exactSearch]);
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
useEffect(() => {
@@ -422,7 +440,13 @@ function SearchPageClient() {
// 非聚合:应用筛选与排序
const filteredAllResults = useMemo(() => {
const { source, title, year, yearOrder } = filterAll;
const filtered = searchResults.filter((item) => {
// 首先应用精确搜索过滤
const exactSearchFiltered = exactSearch
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
: searchResults;
const filtered = exactSearchFiltered.filter((item) => {
if (source !== 'all' && item.source !== source) return false;
if (title !== 'all' && item.title !== title) return false;
if (year !== 'all' && item.year !== year) return false;
@@ -451,7 +475,7 @@ function SearchPageClient() {
a.title.localeCompare(b.title) :
b.title.localeCompare(a.title);
});
}, [searchResults, filterAll, searchQuery]);
}, [searchResults, filterAll, searchQuery, exactSearch]);
// 聚合:应用筛选与排序
const filteredAggResults = useMemo(() => {
@@ -575,6 +599,12 @@ function SearchPageClient() {
} else if (defaultFluidSearch !== undefined) {
setUseFluidSearch(defaultFluidSearch);
}
// 读取精确搜索设置
const savedExactSearch = localStorage.getItem('exactSearch');
if (savedExactSearch !== null) {
setExactSearch(savedExactSearch === 'true');
}
}
// 监听搜索历史更新事件

View File

@@ -22,6 +22,7 @@ import {
Monitor,
MoveDown,
MoveUp,
Package,
Rss,
Settings,
Shield,
@@ -65,6 +66,7 @@ export const UserMenu: React.FC = () => {
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false);
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
@@ -78,7 +80,7 @@ export const UserMenu: React.FC = () => {
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen) {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen) {
const body = document.body;
const html = document.documentElement;
@@ -97,7 +99,7 @@ export const UserMenu: React.FC = () => {
html.style.overflow = originalHtmlOverflow;
};
}
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen]);
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen, isEcoAppsOpen]);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
@@ -121,6 +123,7 @@ export const UserMenu: React.FC = () => {
const [danmakuMaxCount, setDanmakuMaxCount] = useState(0);
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
const [exactSearch, setExactSearch] = useState(true);
// 邮件通知设置
const [userEmail, setUserEmail] = useState('');
@@ -470,6 +473,12 @@ export const UserMenu: React.FC = () => {
if (savedSearchTraditionalToSimplified !== null) {
setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true');
}
// 加载精确搜索设置
const savedExactSearch = localStorage.getItem('exactSearch');
if (savedExactSearch !== null) {
setExactSearch(savedExactSearch === 'true');
}
}
}, []);
@@ -929,6 +938,13 @@ export const UserMenu: React.FC = () => {
}
};
const handleExactSearchToggle = (value: boolean) => {
setExactSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('exactSearch', String(value));
}
};
const handleHomeBannerToggle = (value: boolean) => {
setHomeBannerEnabled(value);
if (typeof window !== 'undefined') {
@@ -1285,6 +1301,18 @@ export const UserMenu: React.FC = () => {
</button>
)}
{/* 生态应用按钮 */}
<button
onClick={() => {
setIsOpen(false);
setIsEcoAppsOpen(true);
}}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Package className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
{/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
@@ -1885,6 +1913,30 @@ export const UserMenu: React.FC = () => {
</div>
</label>
</div>
{/* 精确搜索 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={exactSearch}
onChange={(e) => handleExactSearchToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
</div>
)}
</div>
@@ -2859,6 +2911,194 @@ export const UserMenu: React.FC = () => {
</>
);
// 生态应用面板内容
const ecoAppsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={() => setIsEcoAppsOpen(false)}
onTouchMove={(e) => {
e.preventDefault();
}}
onWheel={(e) => {
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 生态应用面板 */}
<div
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
>
<div
className='h-full max-h-[85vh] flex flex-col'
data-panel-content
onTouchMove={(e) => {
e.stopPropagation();
}}
style={{
touchAction: 'auto',
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={() => setIsEcoAppsOpen(false)}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 应用列表 */}
<div className='flex-1 overflow-y-auto p-6'>
<div className='grid gap-6 md:grid-cols-1'>
{/* MoonTVPlus-PC 客户端 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
<div className='flex items-start gap-4'>
<div className='flex-shrink-0 relative'>
<img
src='/logo.png'
alt='MoonTVPlus-PC'
className='w-16 h-16 rounded-xl object-cover'
/>
<div className='absolute -bottom-1 -right-1 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center shadow-lg'>
<Monitor className='w-3.5 h-3.5 text-white' />
</div>
</div>
<div className='flex-1 min-w-0'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
MoonTVPlus-PC客户端
</h4>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
Windows开发的客户端mkv视频
</p>
<a
href='https://github.com/mtvpls/MoonTVPlus-PC/releases'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
<ExternalLink className='w-3 h-3' />
</a>
</div>
</div>
</div>
{/* Selene 跨平台客户端 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
<div className='flex items-start gap-4'>
<div className='flex-shrink-0 relative'>
<img
src='/icons/Selene.png'
alt='Selene'
className='w-16 h-16 rounded-xl object-cover'
/>
<span className='absolute -top-1 -right-1 px-1.5 py-0.5 bg-orange-500 text-white text-[10px] font-bold rounded'>
</span>
</div>
<div className='flex-1 min-w-0'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
Selene
</h4>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
</p>
<div className='flex flex-wrap gap-2'>
<a
href='https://github.com/mtvpls/MoonTVPlus/releases/tag/Selene_Beta4'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
<ExternalLink className='w-3 h-3' />
</a>
<button
onClick={() => {
const ua = navigator.userAgent.toLowerCase();
let targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
// 根据 UA 判断平台
if (ua.includes('windows')) {
targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
} else if (ua.includes('mac')) {
targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
} else if (ua.includes('linux')) {
targetUrl = 'https://github.com/mtvpls/Selene-Build/actions/workflows/build.yml';
}
window.open(targetUrl, '_blank');
}}
className='inline-flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
<ExternalLink className='w-3 h-3' />
</button>
</div>
</div>
</div>
</div>
{/* OrionTV TV专用客户端 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700'>
<div className='flex items-start gap-4'>
<div className='flex-shrink-0 relative'>
<img
src='/icons/OrionTV.png'
alt='OrionTV'
className='w-16 h-16 rounded-xl object-cover'
/>
<span className='absolute -top-1 -right-1 px-1.5 py-0.5 bg-orange-500 text-white text-[10px] font-bold rounded'>
</span>
</div>
<div className='flex-1 min-w-0'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
OrionTV TV专用客户端
</h4>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
tv专用
</p>
<a
href='https://github.com/mtvpls/MoonTVPlus/releases/tag/OrionTV%E9%80%82%E9%85%8D%E7%89%882'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors'
>
<Download className='w-4 h-4' />
<ExternalLink className='w-3 h-3' />
</a>
</div>
</div>
</div>
</div>
</div>
{/* 底部说明 */}
<div className='p-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
使
</p>
</div>
</div>
</div>
</>
);
return (
<>
<div className='relative'>
@@ -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 &&

View File

@@ -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;
}