|
@@ -0,0 +1,799 @@
|
|
|
|
|
+import { Context } from '@kit.AbilityKit';
|
|
|
|
|
+import { http } from '@kit.NetworkKit';
|
|
|
|
|
+import { fileIo } from '@kit.CoreFileKit';
|
|
|
|
|
+import Logger from './Logger';
|
|
|
|
|
+import { PreferencesUtil, ArrayUtil } from '@pura/harmony-utils';
|
|
|
|
|
+import { VideoItem } from '../../viewmodel/VideoItem';
|
|
|
|
|
+import { RemoteDriveManager } from './RemoteDriveManager';
|
|
|
|
|
+import { navidromeApi } from '../network/NavidromeApi';
|
|
|
|
|
+import { jellyfinApi } from '../network/JellyfinApi';
|
|
|
|
|
+import { embyApi } from '../network/EmbyApi';
|
|
|
|
|
+import { ensureNavidromeFileCached } from '../network/NavidromeFileCache';
|
|
|
|
|
+import { ensureJellyfinFileCached } from '../network/JellyfinFileCache';
|
|
|
|
|
+import { ensureEmbyFileCached } from '../network/EmbyFileCache';
|
|
|
|
|
+import FileManager from './FileManager';
|
|
|
|
|
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from '../network/RemoteSongCache';
|
|
|
|
|
+import { RemoteCacheManager } from '../network/RemoteCacheManager';
|
|
|
|
|
+import { ensureSmbFileStreaming, SmbStreamingMeta } from '../network/SmbFileCache';
|
|
|
|
|
+import { ensureBaiduFileCached } from '../network/BaiduFileCache';
|
|
|
|
|
+import { findWebDavCacheIfExists, triggerWebDavCacheDownload } from '../network/WebDavFileCache';
|
|
|
|
|
+import { WebDavUrlUtil } from './WebDavUrlUtil';
|
|
|
|
|
+import { Song } from '../../viewmodel/Song';
|
|
|
|
|
+import { repairAudioMetadata } from './MusicTagUtils';
|
|
|
|
|
+import { ServerLogUtil } from './ServerLogUtil';
|
|
|
|
|
+import { CommonConstants } from '../constants/CommonConstants';
|
|
|
|
|
+
|
|
|
|
|
+const TAG = 'heanup RemotePlayerUtil';
|
|
|
|
|
+
|
|
|
|
|
+export interface MetadataExtractionOptions {
|
|
|
|
|
+ context?: Context;
|
|
|
|
|
+ autoParseMusicName?: boolean;
|
|
|
|
|
+ extractCover?: boolean;
|
|
|
|
|
+ extractLyric?: boolean;
|
|
|
|
|
+ extractAudioInfo?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface WorkerEditMusicResult {
|
|
|
|
|
+ success: boolean;
|
|
|
|
|
+ error?: string;
|
|
|
|
|
+ coverResult?: boolean;
|
|
|
|
|
+ needRefreshList?: boolean;
|
|
|
|
|
+ metadataResult?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface LocalEditMusicPayload {
|
|
|
|
|
+ context: Context;
|
|
|
|
|
+ item: VideoItem;
|
|
|
|
|
+ titleStr: string;
|
|
|
|
|
+ artistStr: string;
|
|
|
|
|
+ ablumStr: string;
|
|
|
|
|
+ yearStr: string;
|
|
|
|
|
+ genreStr: string;
|
|
|
|
|
+ trackStr: string;
|
|
|
|
|
+ albumArtistStr: string;
|
|
|
|
|
+ composerStr: string;
|
|
|
|
|
+ lyricistStr: string;
|
|
|
|
|
+ commentStr: string;
|
|
|
|
|
+ discStr: string;
|
|
|
|
|
+ lyricConStr: string;
|
|
|
|
|
+ imagePathStr: string;
|
|
|
|
|
+ currentPath: string;
|
|
|
|
|
+ packName: string;
|
|
|
|
|
+ modeType: number;
|
|
|
|
|
+ autoParseMusicName: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+export interface WebDavMetadataUpdatePayload {
|
|
|
|
|
+ filePath: string;
|
|
|
|
|
+ pixelMapPath?: string;
|
|
|
|
|
+ name?: string;
|
|
|
|
|
+ artist?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 克隆 VideoItem 对象
|
|
|
|
|
+ * @param item 要克隆的 VideoItem
|
|
|
|
|
+ * @returns 新的 VideoItem 副本
|
|
|
|
|
+ */
|
|
|
|
|
+export function cloneVideoItem(item: VideoItem): VideoItem {
|
|
|
|
|
+ return JSON.parse(JSON.stringify(item)) as VideoItem;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 提前缓存下一首歌曲
|
|
|
|
|
+ * @param songList 当前播放列表
|
|
|
|
|
+ * @param currentIndex 当前播放索引
|
|
|
|
|
+ * @param playType 播放模式 (0:连续播放 1:单片重复播放 2:正常播放 3:随机播放)
|
|
|
|
|
+ */
|
|
|
|
|
+export async function preloadNextSongIfNeeded(
|
|
|
|
|
+ songList: VideoItem[],
|
|
|
|
|
+ currentIndex: number,
|
|
|
|
|
+ playType: number
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ const preloadEnabled = PreferencesUtil.getBooleanSync('preload_next_song', true);
|
|
|
|
|
+ if (!preloadEnabled) {
|
|
|
|
|
+ Logger.info(TAG, '提前缓存下一首功能未启用');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!ArrayUtil.isNotEmpty(songList) || songList.length <= 1) {
|
|
|
|
|
+ Logger.info(TAG, '歌曲列表为空或只有一首,无需提前缓存');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 计算下一首索引
|
|
|
|
|
+ let nextIndex = 0;
|
|
|
|
|
+ if (playType === 3) {
|
|
|
|
|
+ // 随机播放模式,随机选择一首
|
|
|
|
|
+ nextIndex = Math.floor(Math.random() * songList.length);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 顺序或循环播放模式
|
|
|
|
|
+ if (currentIndex === songList.length - 1) {
|
|
|
|
|
+ nextIndex = 0; // 列表末尾,回到开头
|
|
|
|
|
+ } else {
|
|
|
|
|
+ nextIndex = currentIndex + 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果下一首就是当前正在播放的(单曲循环),则不缓存
|
|
|
|
|
+ if (nextIndex === currentIndex && playType === 1) {
|
|
|
|
|
+ Logger.info(TAG, '单曲循环模式,无需提前缓存');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const nextSong = songList[nextIndex];
|
|
|
|
|
+ Logger.info(TAG, `准备提前缓存下一首: ${nextSong.name}, type: ${nextSong.type}`);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Navidrome
|
|
|
|
|
+ if (isNavidromeType(nextSong.type) && nextSong.webdav_account_id) {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
|
|
|
|
|
+ if (account) {
|
|
|
|
|
+ const navSongId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
|
|
|
|
|
+ const streamUrl = await navidromeApi.buildStreamUrl(account, navSongId);
|
|
|
|
|
+
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `后台提前缓存Navidrome下一首: ${navSongId}`);
|
|
|
|
|
+ await ensureNavidromeFileCached(account, navSongId, streamUrl, nextSong.videoSize);
|
|
|
|
|
+ Logger.info(TAG, `Navidrome下一首提前缓存完成`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `Navidrome下一首提前缓存失败: ${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Jellyfin
|
|
|
|
|
+ if (isJellyfinType(nextSong.type) && nextSong.webdav_account_id) {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
|
|
|
|
|
+ if (account) {
|
|
|
|
|
+ const itemId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
|
|
|
|
|
+ const streamUrl = await jellyfinApi.buildStreamUrl(account, itemId);
|
|
|
|
|
+
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `后台提前缓存Jellyfin下一首: ${itemId}`);
|
|
|
|
|
+ await ensureJellyfinFileCached(account, itemId, streamUrl, nextSong.videoSize);
|
|
|
|
|
+ Logger.info(TAG, `Jellyfin下一首提前缓存完成`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `Jellyfin下一首提前缓存失败: ${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Emby
|
|
|
|
|
+ if (isEmbyType(nextSong.type) && nextSong.webdav_account_id) {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
|
|
|
|
|
+ if (account) {
|
|
|
|
|
+ const itemId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
|
|
|
|
|
+ const streamUrl = await embyApi.buildStreamUrl(account, itemId);
|
|
|
|
|
+
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `后台提前缓存Emby下一首: ${itemId}`);
|
|
|
|
|
+ await ensureEmbyFileCached(account, itemId, streamUrl, nextSong.videoSize);
|
|
|
|
|
+ Logger.info(TAG, `Emby下一首提前缓存完成`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `Emby下一首提前缓存失败: ${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `提前缓存下一首失败: ${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 为歌曲设置播放URL
|
|
|
|
|
+ * @param song 歌曲对象
|
|
|
|
|
+ * @param options 元数据提取选项
|
|
|
|
|
+ * @returns 播放URL
|
|
|
|
|
+ */
|
|
|
|
|
+export async function setVideoUrlForSong(
|
|
|
|
|
+ song: VideoItem,
|
|
|
|
|
+ options?: MetadataExtractionOptions
|
|
|
|
|
+): Promise<string> {
|
|
|
|
|
+ const metadataOptions = options ?? {};
|
|
|
|
|
+ Logger.info(TAG, `setVideoUrlForSong start -> name=${song.name}, type=${song.type}, path=${song.filePath}, fsId=${song.baiduFsId || song.id}`);
|
|
|
|
|
+
|
|
|
|
|
+ const scheduleMetadataExtractionFromCache = async (
|
|
|
|
|
+ targetSong: VideoItem,
|
|
|
|
|
+ cachePath: string,
|
|
|
|
|
+ opts: MetadataExtractionOptions,
|
|
|
|
|
+ expectedSize?: number
|
|
|
|
|
+ ): Promise<void> => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const size = await FileManager.getFileSize(cachePath);
|
|
|
|
|
+ if (expectedSize && size < expectedSize * 0.95) {
|
|
|
|
|
+ Logger.warn(TAG, `缓存文件大小不匹配,跳过元数据提取: ${cachePath}`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ Logger.info(TAG, `从缓存提取元数据: ${cachePath}`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `从缓存提取元数据失败: ${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // WebDAV 类型处理
|
|
|
|
|
+ if (isWebDavType(song.type) && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `WebDAV 路径信息 remote_rel_path: ${song.remote_rel_path}`);
|
|
|
|
|
+ Logger.info(TAG, `WebDAV 路径信息 filePath: ${song.filePath}`);
|
|
|
|
|
+ const relativePath = song.remote_rel_path || song.filePath;
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+
|
|
|
|
|
+ if (account) {
|
|
|
|
|
+ const cachePath = await findWebDavCacheIfExists(account, relativePath);
|
|
|
|
|
+ if (cachePath) {
|
|
|
|
|
+ Logger.info(TAG, `WebDAV 缓存命中,直接播放: ${cachePath}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ return cachePath;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const fullUrlFromAccount = WebDavUrlUtil.buildFullUrl(account, relativePath);
|
|
|
|
|
+ if (fullUrlFromAccount) {
|
|
|
|
|
+ const sanitizedUrl = sanitizePlaybackUrl(fullUrlFromAccount);
|
|
|
|
|
+ const downloadPromise = triggerWebDavCacheDownload(account, relativePath, sanitizedUrl);
|
|
|
|
|
+ if (downloadPromise) {
|
|
|
|
|
+ downloadPromise.then((completedPath?: string | null) => {
|
|
|
|
|
+ if (completedPath) {
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, completedPath, metadataOptions, song.videoSize);
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch((error: Error) => {
|
|
|
|
|
+ Logger.error(TAG, `WebDAV 缓存后台下载失败: ${error.message}`);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ Logger.info(TAG, `WebDAV 启动异步缓存: ${relativePath}`);
|
|
|
|
|
+ return sanitizedUrl;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const fallbackUrl = await WebDavUrlUtil.buildFullUrlByAccountId(song.webdav_account_id, relativePath);
|
|
|
|
|
+ if (fallbackUrl) {
|
|
|
|
|
+ Logger.warn(TAG, `WebDAV 使用全局查询到的URL: ${fallbackUrl}`);
|
|
|
|
|
+ return sanitizePlaybackUrl(fallbackUrl);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Logger.error(TAG, `WebDAV URL构建失败,使用原始路径: ${relativePath}`);
|
|
|
|
|
+ return sanitizePlaybackUrl(relativePath);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ Logger.error(TAG, `WebDAV URL构建出错: ${(error as Error).message}`);
|
|
|
|
|
+ const fallbackPath = song.remote_rel_path || song.filePath;
|
|
|
|
|
+ return sanitizePlaybackUrl(fallbackPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Navidrome 类型处理
|
|
|
|
|
+ if (isNavidromeType(song.type) && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+ if (!account) {
|
|
|
|
|
+ throw new Error('Navidrome账号不可用');
|
|
|
|
|
+ }
|
|
|
|
|
+ const navSongId = song.remote_rel_path || song.id || song.filePath;
|
|
|
|
|
+
|
|
|
|
|
+ // 检查缓存是否存在
|
|
|
|
|
+ const normalizedRelative = normalizeCacheRelativePath(navSongId);
|
|
|
|
|
+ const pathInfo = await resolveCacheFilePath(
|
|
|
|
|
+ RemoteCacheType.NAVIDROME,
|
|
|
|
|
+ account.id?.toString(),
|
|
|
|
|
+ normalizedRelative
|
|
|
|
|
+ );
|
|
|
|
|
+ const cachePath = pathInfo.cachePath;
|
|
|
|
|
+ const exists = await FileManager.isExist(cachePath);
|
|
|
|
|
+ if (exists) {
|
|
|
|
|
+ const size = await FileManager.getFileSize(cachePath);
|
|
|
|
|
+ if (size > 0) {
|
|
|
|
|
+ // 检查文件是否完整(允许5%的误差,因为分片边界可能导致实际大小略有不同)
|
|
|
|
|
+ if (song.videoSize > 0 && size < song.videoSize * 0.95) {
|
|
|
|
|
+ Logger.warn(TAG, `Navidrome 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
|
|
|
|
|
+ await FileManager.deleteFile(cachePath);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Logger.info(TAG, `Navidrome 缓存命中,直接播放: ${cachePath}, size=${size}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ return cachePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 缓存不存在,获取流媒体URL并开始后台缓存
|
|
|
|
|
+ const streamUrl = await navidromeApi.buildStreamUrl(account, navSongId);
|
|
|
|
|
+ void ServerLogUtil.info(TAG, `流地址构建成功: ${streamUrl}`);
|
|
|
|
|
+ void ServerLogUtil.debug(TAG, `播放songId=${navSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 立即返回流媒体URL,同时后台异步缓存
|
|
|
|
|
+ const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `后台开始缓存Navidrome文件: ${navSongId}`);
|
|
|
|
|
+ void ServerLogUtil.info('NavidromeStream', `开始缓存: songId=${navSongId}`);
|
|
|
|
|
+ const cachedFilePath = await ensureNavidromeFileCached(
|
|
|
|
|
+ account,
|
|
|
|
|
+ navSongId,
|
|
|
|
|
+ streamUrl,
|
|
|
|
|
+ song.videoSize
|
|
|
|
|
+ );
|
|
|
|
|
+ Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void ServerLogUtil.info('NavidromeStream', `缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ } catch (cacheError) {
|
|
|
|
|
+ const cacheErr = cacheError as Error;
|
|
|
|
|
+ Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ void ServerLogUtil.error('NavidromeStream', `缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+
|
|
|
|
|
+ return sanitizedUrl;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ void ServerLogUtil.error(TAG, `URL构建失败: ${err.message}`);
|
|
|
|
|
+ throw new Error(err.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Jellyfin 类型处理
|
|
|
|
|
+ if (isJellyfinType(song.type) && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+ if (!account) {
|
|
|
|
|
+ throw new Error('Jellyfin账号不可用');
|
|
|
|
|
+ }
|
|
|
|
|
+ const jellyfinSongId = song.remote_rel_path || song.id || song.filePath;
|
|
|
|
|
+
|
|
|
|
|
+ // 检查缓存是否存在
|
|
|
|
|
+ const normalizedRelative = normalizeCacheRelativePath(jellyfinSongId);
|
|
|
|
|
+ const pathInfo = await resolveCacheFilePath(
|
|
|
|
|
+ RemoteCacheType.JELLYFIN,
|
|
|
|
|
+ account.id?.toString(),
|
|
|
|
|
+ normalizedRelative
|
|
|
|
|
+ );
|
|
|
|
|
+ const cachePath = pathInfo.cachePath;
|
|
|
|
|
+ const exists = await FileManager.isExist(cachePath);
|
|
|
|
|
+ if (exists) {
|
|
|
|
|
+ const size = await FileManager.getFileSize(cachePath);
|
|
|
|
|
+ if (size > 0) {
|
|
|
|
|
+ // 检查文件是否完整(允许5%的误差)
|
|
|
|
|
+ if (song.videoSize > 0 && size < song.videoSize * 0.95) {
|
|
|
|
|
+ Logger.warn(TAG, `Jellyfin 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
|
|
|
|
|
+ await FileManager.deleteFile(cachePath);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Logger.info(TAG, `Jellyfin 缓存命中,直接播放: ${cachePath}, size=${size}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ return cachePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 缓存不存在,获取流媒体URL并开始后台缓存
|
|
|
|
|
+ const streamUrl = await jellyfinApi.buildStreamUrl(account, jellyfinSongId);
|
|
|
|
|
+ void ServerLogUtil.info(TAG, `Jellyfin 流地址构建成功: ${streamUrl}`);
|
|
|
|
|
+ void ServerLogUtil.debug(TAG, `播放songId=${jellyfinSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 立即返回流媒体URL,同时后台异步缓存
|
|
|
|
|
+ const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `后台开始缓存Jellyfin文件: ${jellyfinSongId}`);
|
|
|
|
|
+ void ServerLogUtil.info('JellyfinStream', `开始缓存: itemId=${jellyfinSongId}`);
|
|
|
|
|
+ const cachedFilePath = await ensureJellyfinFileCached(
|
|
|
|
|
+ account,
|
|
|
|
|
+ jellyfinSongId,
|
|
|
|
|
+ streamUrl,
|
|
|
|
|
+ song.videoSize
|
|
|
|
|
+ );
|
|
|
|
|
+ Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void ServerLogUtil.info('JellyfinStream', `缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ } catch (cacheError) {
|
|
|
|
|
+ const cacheErr = cacheError as Error;
|
|
|
|
|
+ Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ void ServerLogUtil.error('JellyfinStream', `缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+
|
|
|
|
|
+ return sanitizedUrl;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ void ServerLogUtil.error(TAG, `Jellyfin URL构建失败: ${err.message}`);
|
|
|
|
|
+ throw new Error(err.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Emby 类型处理
|
|
|
|
|
+ if (isEmbyType(song.type) && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+ if (!account) {
|
|
|
|
|
+ throw new Error('Emby账号不可用');
|
|
|
|
|
+ }
|
|
|
|
|
+ const embySongId = song.remote_rel_path || song.id || song.filePath;
|
|
|
|
|
+
|
|
|
|
|
+ // 检查缓存是否存在
|
|
|
|
|
+ const normalizedRelative = normalizeCacheRelativePath(embySongId);
|
|
|
|
|
+ const pathInfo = await resolveCacheFilePath(
|
|
|
|
|
+ RemoteCacheType.EMBY,
|
|
|
|
|
+ account.id?.toString(),
|
|
|
|
|
+ normalizedRelative
|
|
|
|
|
+ );
|
|
|
|
|
+ const cachePath = pathInfo.cachePath;
|
|
|
|
|
+ const exists = await FileManager.isExist(cachePath);
|
|
|
|
|
+ if (exists) {
|
|
|
|
|
+ const size = await FileManager.getFileSize(cachePath);
|
|
|
|
|
+ if (size > 0) {
|
|
|
|
|
+ // 检查文件是否完整(允许5%的误差)
|
|
|
|
|
+ if (song.videoSize > 0 && size < song.videoSize * 0.95) {
|
|
|
|
|
+ Logger.warn(TAG, `Emby 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
|
|
|
|
|
+ await FileManager.deleteFile(cachePath);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Logger.info(TAG, `Emby 缓存命中,直接播放: ${cachePath}, size=${size}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ return cachePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 缓存不存在,获取流媒体URL并开始后台缓存
|
|
|
|
|
+ const streamUrl = await embyApi.buildStreamUrl(account, embySongId);
|
|
|
|
|
+ void ServerLogUtil.info(TAG, `Emby 流地址构建成功: ${streamUrl}`);
|
|
|
|
|
+ void ServerLogUtil.debug(TAG, `播放songId=${embySongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 立即返回流媒体URL,同时后台异步缓存
|
|
|
|
|
+ const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `后台开始缓存Emby文件: ${embySongId}`);
|
|
|
|
|
+ void ServerLogUtil.info('EmbyStream', `开始缓存: itemId=${embySongId}`);
|
|
|
|
|
+ const cachedFilePath = await ensureEmbyFileCached(
|
|
|
|
|
+ account,
|
|
|
|
|
+ embySongId,
|
|
|
|
|
+ streamUrl,
|
|
|
|
|
+ song.videoSize
|
|
|
|
|
+ );
|
|
|
|
|
+ Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void ServerLogUtil.info('EmbyStream', `缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ } catch (cacheError) {
|
|
|
|
|
+ const cacheErr = cacheError as Error;
|
|
|
|
|
+ Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ void ServerLogUtil.error('EmbyStream', `缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+
|
|
|
|
|
+ return sanitizedUrl;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ void ServerLogUtil.error(TAG, `Emby URL构建失败: ${err.message}`);
|
|
|
|
|
+ throw new Error(err.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // SMB 类型处理
|
|
|
|
|
+ if (isSmbType(song.type) && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+ if (!account) {
|
|
|
|
|
+ throw new Error('SMB账号不可用');
|
|
|
|
|
+ }
|
|
|
|
|
+ const relativePath = extractSmbRelativePath(song, account.smbShare);
|
|
|
|
|
+ const pathInfo = await resolveCacheFilePath(
|
|
|
|
|
+ RemoteCacheType.SMB,
|
|
|
|
|
+ account.id?.toString(),
|
|
|
|
|
+ relativePath
|
|
|
|
|
+ );
|
|
|
|
|
+ const streamMeta: SmbStreamingMeta = {
|
|
|
|
|
+ fileSize: song.videoSize,
|
|
|
|
|
+ mimeType: song.mimeType,
|
|
|
|
|
+ fileName: song.fileName || song.name
|
|
|
|
|
+ };
|
|
|
|
|
+ const streamingUrl = await ensureSmbFileStreaming(account, pathInfo.normalizedRelative, streamMeta);
|
|
|
|
|
+ Logger.info(TAG, `SMB 流地址: ${streamingUrl}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, pathInfo.cachePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ const shouldBypassSanitize = streamingUrl.startsWith('http://127.0.0.1:');
|
|
|
|
|
+ return shouldBypassSanitize ? streamingUrl : sanitizePlaybackUrl(streamingUrl);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `SMB 流式播放失败: ${err.message}`);
|
|
|
|
|
+ throw new Error(err.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // FTP 类型处理
|
|
|
|
|
+ if (isFtpType(song.type) && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+ if (!account) {
|
|
|
|
|
+ throw new Error('FTP账号不可用');
|
|
|
|
|
+ }
|
|
|
|
|
+ const relativePath = extractFtpRelativePath(song);
|
|
|
|
|
+ const cachedPath = await RemoteCacheManager.ensureCached(RemoteCacheType.FTP, {
|
|
|
|
|
+ account,
|
|
|
|
|
+ remotePath: relativePath
|
|
|
|
|
+ });
|
|
|
|
|
+ Logger.info(TAG, `FTP 缓存路径: ${cachedPath}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions, song.videoSize);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions, song.videoSize);
|
|
|
|
|
+ return cachedPath;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `FTP 缓存失败: ${err.message}`);
|
|
|
|
|
+ throw new Error(err.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 百度网盘类型处理
|
|
|
|
|
+ if (isBaiduType(song.type) && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const manager = RemoteDriveManager.getInstance();
|
|
|
|
|
+ const account = await manager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+ if (!account) {
|
|
|
|
|
+ throw new Error('百度网盘账户不可用');
|
|
|
|
|
+ }
|
|
|
|
|
+ Logger.info(TAG, `百度网盘播放准备 - accountId: ${account.id}, fsId: ${song.baiduFsId || song.id}`);
|
|
|
|
|
+ const sanitizedAccount = ServerLogUtil.sanitizeAccount(account);
|
|
|
|
|
+ void ServerLogUtil.info('BaiduStream', `准备播放百度歌曲: account=${sanitizedAccount}, fsId=${song.baiduFsId || song.id}`);
|
|
|
|
|
+
|
|
|
|
|
+ const fsId = song.baiduFsId || song.id;
|
|
|
|
|
+ if (!fsId) {
|
|
|
|
|
+ throw new Error('缺少百度网盘文件fs_id');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 优先使用remote_rel_path,回退到文件名
|
|
|
|
|
+ const relativePath = song.remote_rel_path || song.name || song.fileName || song.filePath;
|
|
|
|
|
+ const streamPath = song.remote_rel_path;
|
|
|
|
|
+ const cachedPath = await resolveCacheFilePath(
|
|
|
|
|
+ RemoteCacheType.BAIDU,
|
|
|
|
|
+ account.id?.toString(),
|
|
|
|
|
+ relativePath
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const fileExists = await FileManager.isExist(cachedPath.cachePath);
|
|
|
|
|
+ if (fileExists) {
|
|
|
|
|
+ const fileSize = await FileManager.getFileSize(cachedPath.cachePath);
|
|
|
|
|
+ if (fileSize > 0) {
|
|
|
|
|
+ if (song.videoSize > 0 && fileSize < song.videoSize * 0.9) {
|
|
|
|
|
+ Logger.warn(TAG, `百度网盘缓存文件不完整,准备删除重试: ${cachedPath.cachePath}, size=${fileSize}, expect=${song.videoSize}`);
|
|
|
|
|
+ await FileManager.deleteFile(cachedPath.cachePath);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Logger.info(TAG, `百度网盘文件已缓存: ${cachedPath.cachePath}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachedPath.cachePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ return cachedPath.cachePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 缓存不存在或无效,获取文件信息(包含大小和下载链接)
|
|
|
|
|
+ Logger.info(TAG, `百度网盘文件未缓存,准备获取文件信息: ${relativePath}`);
|
|
|
|
|
+ const fileInfo = await manager.getBaiduFileInfo(account, fsId, streamPath);
|
|
|
|
|
+ Logger.info(TAG, `百度网盘文件信息获取成功: 大小=${fileInfo.size}字节,流地址已获取`);
|
|
|
|
|
+ void ServerLogUtil.info('BaiduStream', `音频流URL获取成功: ${fileInfo.streamUrl}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 立即返回媒体流地址,开始播放
|
|
|
|
|
+ Logger.info(TAG, `返回音频流链接进行播放,同时后台缓存: ${fileInfo.streamUrl}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 异步进行缓存,不阻塞播放
|
|
|
|
|
+ void (async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Logger.info(TAG, `后台开始缓存百度网盘文件: ${relativePath},大小: ${fileInfo.size}字节`);
|
|
|
|
|
+ void ServerLogUtil.info('BaiduStream', `开始缓存百度文件: account=${sanitizedAccount}, path=${relativePath}`);
|
|
|
|
|
+ const cachedFilePath = await ensureBaiduFileCached(
|
|
|
|
|
+ account,
|
|
|
|
|
+ fsId,
|
|
|
|
|
+ relativePath,
|
|
|
|
|
+ fileInfo.dlink,
|
|
|
|
|
+ fileInfo.size
|
|
|
|
|
+ );
|
|
|
|
|
+ Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void ServerLogUtil.info('BaiduStream', `缓存完成: ${cachedFilePath}`);
|
|
|
|
|
+ void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
|
|
|
|
|
+ } catch (cacheError) {
|
|
|
|
|
+ const cacheErr = cacheError as Error;
|
|
|
|
|
+ Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ void ServerLogUtil.error('BaiduStream', `缓存失败: ${cacheErr.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+
|
|
|
|
|
+ return fileInfo.streamUrl;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `百度网盘播放链接获取失败: ${err.message}`);
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查缺少账号ID的各种类型
|
|
|
|
|
+ if (isWebDavType(song.type)) {
|
|
|
|
|
+ Logger.warn(TAG, `WebDAV歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
|
|
|
|
|
+ return sanitizePlaybackUrl(song.filePath);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isSmbType(song.type)) {
|
|
|
|
|
+ Logger.warn(TAG, `SMB歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
|
|
|
|
|
+ return song.filePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isFtpType(song.type)) {
|
|
|
|
|
+ Logger.warn(TAG, `FTP歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
|
|
|
|
|
+ return song.filePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isBaiduType(song.type)) {
|
|
|
|
|
+ throw new Error('百度网盘歌曲缺少webdav_account_id,无法构建播放链接');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isNavidromeType(song.type)) {
|
|
|
|
|
+ throw new Error('Navidrome歌曲缺少webdav_account_id,无法构建播放链接');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isJellyfinType(song.type)) {
|
|
|
|
|
+ throw new Error('Jellyfin歌曲缺少webdav_account_id,无法构建播放链接');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isEmbyType(song.type)) {
|
|
|
|
|
+ throw new Error('Emby歌曲缺少webdav_account_id,无法构建播放链接');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Logger.info(TAG, `setVideoUrlForSong fallback直接返回原始路径: ${song.filePath}`);
|
|
|
|
|
+ return song.filePath;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function sanitizePlaybackUrl(rawUrl: string | null | undefined): string {
|
|
|
|
|
+ if (!rawUrl) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const decoded = decodeURI(rawUrl);
|
|
|
|
|
+ const encoded = encodeURI(decoded);
|
|
|
|
|
+ // encodeURI 不会处理 #,手动转义避免播放器把剩余内容当作片段
|
|
|
|
|
+ return encoded.replace(/#/g, '%23');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `URL 编码失败,使用降级方案: ${err?.message}`);
|
|
|
|
|
+ return rawUrl.replace(/ /g, '%20').replace(/#/g, '%23');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+//排序类型
|
|
|
|
|
+export function getTypeOrder(type: number) {
|
|
|
|
|
+ switch (type) {
|
|
|
|
|
+ case CommonConstants.TYPE_IS_DIR:
|
|
|
|
|
+ return 1; // First
|
|
|
|
|
+ case CommonConstants.TYPE_IS_CSJAD:
|
|
|
|
|
+ return 2; // Middle
|
|
|
|
|
+ case CommonConstants.TYPE_LOCAL:
|
|
|
|
|
+ return 3; // Last
|
|
|
|
|
+ case CommonConstants.TYPE_WEBDAV:
|
|
|
|
|
+ case CommonConstants.TYPE_SMB:
|
|
|
|
|
+ case CommonConstants.TYPE_NAVIDROME:
|
|
|
|
|
+ case CommonConstants.TYPE_FTP:
|
|
|
|
|
+ case CommonConstants.TYPE_BAIDU:
|
|
|
|
|
+ case CommonConstants.TYPE_JELLYFIN:
|
|
|
|
|
+ case CommonConstants.TYPE_EMBY:
|
|
|
|
|
+ return 3;
|
|
|
|
|
+ default:
|
|
|
|
|
+ return 4; // Unknown types, if any, go last
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isWebDavType(type: number): boolean {
|
|
|
|
|
+ return type === CommonConstants.TYPE_WEBDAV;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isSmbType(type: number): boolean {
|
|
|
|
|
+ return type === CommonConstants.TYPE_SMB;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isNavidromeType(type: number | undefined): boolean {
|
|
|
|
|
+ return type !== undefined && type === CommonConstants.TYPE_NAVIDROME;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isFtpType(type: number): boolean {
|
|
|
|
|
+ return type === CommonConstants.TYPE_FTP;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isBaiduType(type: number): boolean {
|
|
|
|
|
+ return type === CommonConstants.TYPE_BAIDU;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+export function isJellyfinType(type: number | undefined): boolean {
|
|
|
|
|
+ return type !== undefined && type === CommonConstants.TYPE_JELLYFIN;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isEmbyType(type: number | undefined): boolean {
|
|
|
|
|
+ return type !== undefined && type === CommonConstants.TYPE_EMBY;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function isRemoteCloudType(type: number): boolean {
|
|
|
|
|
+ return isWebDavType(type) || isSmbType(type) || isNavidromeType(type) || isFtpType(type) || isBaiduType(type) || isJellyfinType(type) || isEmbyType(type);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function getShareNameFromFilePath(filePath?: string): string | undefined {
|
|
|
|
|
+ if (!filePath) {
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+ const match = filePath.match(/^smb:\/\/[^/]+\/([^/]+)/i);
|
|
|
|
|
+ return match && match[1] ? match[1] : undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function removeSharePrefixFromPath(path: string, primaryShare?: string, secondaryShare?: string): string {
|
|
|
|
|
+ const uniqueShares: string[] = [];
|
|
|
|
|
+ [primaryShare, secondaryShare].forEach((name?: string) => {
|
|
|
|
|
+ const cleaned = name ? name.replace(/^\/+|\/+$/g, '') : '';
|
|
|
|
|
+ if (cleaned.length > 0 && !uniqueShares.some(item => item.toLowerCase() === cleaned.toLowerCase())) {
|
|
|
|
|
+ uniqueShares.push(cleaned);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ if (uniqueShares.length === 0) {
|
|
|
|
|
+ return path;
|
|
|
|
|
+ }
|
|
|
|
|
+ let result = path;
|
|
|
|
|
+ for (let i = 0; i < uniqueShares.length; i++) {
|
|
|
|
|
+ const share = uniqueShares[i];
|
|
|
|
|
+ const lowerPath = result.toLowerCase();
|
|
|
|
|
+ const lowerShare = share.toLowerCase();
|
|
|
|
|
+ if (lowerPath === lowerShare) {
|
|
|
|
|
+ result = '';
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ const prefix = `${lowerShare}/`;
|
|
|
|
|
+ if (lowerPath.startsWith(prefix)) {
|
|
|
|
|
+ result = result.substring(share.length + 1);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function extractSmbRelativePath(song: VideoItem, shareNameOverride?: string): string {
|
|
|
|
|
+ const shareFromFilePath = getShareNameFromFilePath(song.filePath);
|
|
|
|
|
+ const shareName = shareNameOverride ?? shareFromFilePath;
|
|
|
|
|
+ if (song.remote_rel_path && song.remote_rel_path.length > 0) {
|
|
|
|
|
+ const cleaned = song.remote_rel_path.replace(/^\/+/, '');
|
|
|
|
|
+ return removeSharePrefixFromPath(cleaned, shareName, shareFromFilePath);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!song.filePath) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const match = song.filePath.match(/^smb:\/\/[^/]+\/([^/]+)(\/.*)?$/i);
|
|
|
|
|
+ if (!match) {
|
|
|
|
|
+ return song.filePath.replace(/^\/+/, '');
|
|
|
|
|
+ }
|
|
|
|
|
+ const remainder = match[2] ? match[2].replace(/^\/+/, '') : '';
|
|
|
|
|
+ if (remainder.length === 0) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ return remainder;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function extractFtpRelativePath(song: VideoItem): string {
|
|
|
|
|
+ if (song.remote_rel_path && song.remote_rel_path.length > 0) {
|
|
|
|
|
+ return song.remote_rel_path.replace(/^\/+/, '');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!song.filePath) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const match = song.filePath.match(/^ftp:\/\/[^/]+(?::\d+)?(\/.*)$/i);
|
|
|
|
|
+ if (match && match[1]) {
|
|
|
|
|
+ return match[1].replace(/^\/+/, '');
|
|
|
|
|
+ }
|
|
|
|
|
+ return song.filePath.replace(/^\/+/, '');
|
|
|
|
|
+}
|