|
|
@@ -26,6 +26,8 @@ import { SettingPage } from '../pages/SettingPage';
|
|
|
import { jellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../common/network/JellyfinApi';
|
|
|
import { embyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../common/network/EmbyApi';
|
|
|
import { getRemoteDriveDisplayLabel } from '../common/util/RemoteDriveLabel';
|
|
|
+import NavidromeListCache, { AllCacheData } from '../common/util/NavidromeListCache';
|
|
|
+import { taskpool } from '@kit.ArkTS';
|
|
|
|
|
|
const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
|
|
|
const NAVIDROME_SEARCH_LIMIT = 500;
|
|
|
@@ -56,6 +58,42 @@ interface LibraryInfo {
|
|
|
scheme: string;
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * taskpool 任务结果接口
|
|
|
+ * 返回带封面的完整数据
|
|
|
+ */
|
|
|
+interface TaskResult {
|
|
|
+ songs: VideoItem[];
|
|
|
+ artists: NavidromeRestArtist[];
|
|
|
+ albums: NavidromeRestAlbum[];
|
|
|
+ playlists: NavidromeRestPlaylist[];
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Navidrome API 分页响应接口
|
|
|
+ */
|
|
|
+interface PagedResponse<T> {
|
|
|
+ data: T[];
|
|
|
+ nextStart: number | null;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Account 序列化数据接口 (用于 taskpool 传递)
|
|
|
+ */
|
|
|
+interface AccountData {
|
|
|
+ id: number;
|
|
|
+ webType: number;
|
|
|
+ host: string;
|
|
|
+ port: number;
|
|
|
+ account: string;
|
|
|
+ password: string;
|
|
|
+ enableHttps: boolean;
|
|
|
+ navidromeBasePath: string;
|
|
|
+ jellyfinBasePath: string;
|
|
|
+ embyBasePath: string;
|
|
|
+ name: string;
|
|
|
+}
|
|
|
+
|
|
|
@Component
|
|
|
export struct NavidromePage {
|
|
|
@State isShowTitleBar: boolean = true //是否显示分类导航条
|
|
|
@@ -113,6 +151,7 @@ export struct NavidromePage {
|
|
|
private embySongIdSeen: Set<string> = new Set();
|
|
|
private jellyfinSongKeySeen: Set<string> = new Set();
|
|
|
private embySongKeySeen: Set<string> = new Set();
|
|
|
+ private listCache = NavidromeListCache.getInstance();
|
|
|
|
|
|
private isNavidromeAccount(account: WebDavAccount): boolean {
|
|
|
return account.webType === RemoteDriveType.Navidrome;
|
|
|
@@ -326,6 +365,92 @@ export struct NavidromePage {
|
|
|
|
|
|
try {
|
|
|
await this.logAccountCacheInfo(account);
|
|
|
+
|
|
|
+ // 尝试从缓存读取(前50条)
|
|
|
+ const accountId = account.id?.toString() ?? 'default';
|
|
|
+ const cachedData: AllCacheData = this.listCache.getAllCache(accountId);
|
|
|
+
|
|
|
+ let loadedFromCache = false;
|
|
|
+ if (cachedData.songs && cachedData.songs.length > 0) {
|
|
|
+ this.allVideos = cachedData.songs;
|
|
|
+ loadedFromCache = true;
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', `从缓存加载歌曲: ${cachedData.songs.length} 首`);
|
|
|
+ }
|
|
|
+ if (cachedData.artists && cachedData.artists.length > 0) {
|
|
|
+ this.artists = cachedData.artists;
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', `从缓存加载艺术家: ${cachedData.artists.length} 位`);
|
|
|
+ }
|
|
|
+ if (cachedData.albums && cachedData.albums.length > 0) {
|
|
|
+ this.albums = cachedData.albums;
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', `从缓存加载专辑: ${cachedData.albums.length} 张`);
|
|
|
+ }
|
|
|
+ if (cachedData.playlists && cachedData.playlists.length > 0) {
|
|
|
+ this.playlists = cachedData.playlists;
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', `从缓存加载歌单: ${cachedData.playlists.length} 个`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果有缓存数据,先显示缓存,然后后台加载完整数据
|
|
|
+ if (loadedFromCache || cachedData.artists || cachedData.albums || cachedData.playlists) {
|
|
|
+ this.loading = false;
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', '缓存数据加载完成,开始后台加载完整数据');
|
|
|
+
|
|
|
+ // 使用 taskpool 后台加载完整数据,避免 UI 卡顿
|
|
|
+ // 提取 account 的序列化数据 (taskpool 不支持 Proxy 对象)
|
|
|
+ const accountData: AccountData = {
|
|
|
+ id: account.id ?? 0,
|
|
|
+ webType: account.webType,
|
|
|
+ host: account.host ?? '',
|
|
|
+ port: account.port ?? 80,
|
|
|
+ account: account.account ?? '',
|
|
|
+ password: account.password ?? '',
|
|
|
+ enableHttps: account.enableHttps ?? false,
|
|
|
+ navidromeBasePath: account.navidromeBasePath ?? '/rest',
|
|
|
+ jellyfinBasePath: account.jellyfinBasePath ?? '',
|
|
|
+ embyBasePath: account.embyBasePath ?? '',
|
|
|
+ name: account.name ?? ''
|
|
|
+ };
|
|
|
+
|
|
|
+ const task = new taskpool.Task(loadNavidromeDataTask, accountData, ticket);
|
|
|
+ taskpool.execute(task, taskpool.Priority.MEDIUM).then((result: Object) => {
|
|
|
+ if (ticket !== this.loadTicket) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 类型断言
|
|
|
+ const taskResult = result as TaskResult;
|
|
|
+
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', `后台加载完成: 歌曲 ${taskResult.songs.length} / 艺术家 ${taskResult.artists.length} / 专辑 ${taskResult.albums.length} / 歌单 ${taskResult.playlists.length}`);
|
|
|
+
|
|
|
+ // 直接更新数据(封面已在后台处理)
|
|
|
+ if (taskResult.songs && taskResult.songs.length > 0) {
|
|
|
+ this.allVideos = taskResult.songs;
|
|
|
+ }
|
|
|
+ if (taskResult.artists && taskResult.artists.length > 0) {
|
|
|
+ this.artists = taskResult.artists;
|
|
|
+ }
|
|
|
+ if (taskResult.albums && taskResult.albums.length > 0) {
|
|
|
+ this.albums = taskResult.albums;
|
|
|
+ }
|
|
|
+ if (taskResult.playlists && taskResult.playlists.length > 0) {
|
|
|
+ this.playlists = taskResult.playlists;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存新的缓存(前50条)
|
|
|
+ this.listCache.saveSongsCache(accountId, this.allVideos);
|
|
|
+ this.listCache.saveArtistsCache(accountId, this.artists);
|
|
|
+ this.listCache.saveAlbumsCache(accountId, this.albums);
|
|
|
+ this.listCache.savePlaylistsCache(accountId, this.playlists);
|
|
|
+
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', `后台加载完成并更新缓存: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
|
|
|
+ void this.logCacheStatistics(account);
|
|
|
+ }).catch((error: Error) => {
|
|
|
+ void ServerLogUtil.error('NavidromeLoad', `后台数据加载失败: ${error.message}`);
|
|
|
+ });
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 没有缓存,正常加载
|
|
|
await Promise.all([
|
|
|
this.loadNextSongPage(account, ticket),
|
|
|
this.loadNextArtistPage(account, ticket),
|
|
|
@@ -335,7 +460,14 @@ export struct NavidromePage {
|
|
|
if (ticket !== this.loadTicket) {
|
|
|
return;
|
|
|
}
|
|
|
- void ServerLogUtil.info('NavidromeLoad', `首次加载完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
|
|
|
+
|
|
|
+ // 保存缓存(前50条)
|
|
|
+ this.listCache.saveSongsCache(accountId, this.allVideos);
|
|
|
+ this.listCache.saveArtistsCache(accountId, this.artists);
|
|
|
+ this.listCache.saveAlbumsCache(accountId, this.albums);
|
|
|
+ this.listCache.savePlaylistsCache(accountId, this.playlists);
|
|
|
+
|
|
|
+ void ServerLogUtil.info('NavidromeLoad', `首次加载完成并已缓存: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
|
|
|
await this.logCacheStatistics(account);
|
|
|
} catch (error) {
|
|
|
if (ticket === this.loadTicket) {
|
|
|
@@ -2467,3 +2599,237 @@ export struct NavidromePage {
|
|
|
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Navidrome 数据加载任务 (使用 taskpool 后台执行)
|
|
|
+ * 该函数在 worker 线程中运行,避免阻塞 UI 线程
|
|
|
+ */
|
|
|
+@Concurrent
|
|
|
+async function loadNavidromeDataTask(accountData: AccountData, ticket: number): Promise<TaskResult> {
|
|
|
+ const result: TaskResult = {
|
|
|
+ songs: [],
|
|
|
+ artists: [],
|
|
|
+ albums: [],
|
|
|
+ playlists: []
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 从 AccountData 重建 WebDavAccount 对象 (用于 API 调用)
|
|
|
+ const account: WebDavAccount = new WebDavAccount();
|
|
|
+ account.id = accountData.id;
|
|
|
+ account.webType = accountData.webType;
|
|
|
+ account.host = accountData.host;
|
|
|
+ account.port = accountData.port;
|
|
|
+ account.account = accountData.account;
|
|
|
+ account.password = accountData.password;
|
|
|
+ account.enableHttps = accountData.enableHttps;
|
|
|
+ account.navidromeBasePath = accountData.navidromeBasePath;
|
|
|
+ account.jellyfinBasePath = accountData.jellyfinBasePath;
|
|
|
+ account.embyBasePath = accountData.embyBasePath;
|
|
|
+ account.name = accountData.name;
|
|
|
+
|
|
|
+ // 加载歌曲并转换成 VideoItem(包含封面)
|
|
|
+ let songNextStart: number | null = 0;
|
|
|
+ while (songNextStart !== null) {
|
|
|
+ const response: PagedResponse<NavidromeRestSong> = await navidromeRestApi.fetchSongPage(account, songNextStart);
|
|
|
+ const chunk: NavidromeRestSong[] = response.data ?? [];
|
|
|
+ if (chunk.length === 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 转换歌曲为 VideoItem 并处理封面
|
|
|
+ for (let i = 0; i < chunk.length; i++) {
|
|
|
+ const song: NavidromeRestSong = chunk[i];
|
|
|
+ const title: string = song.title ?? Constants.UNKNOWN_TITLE;
|
|
|
+
|
|
|
+ // 获取库类型
|
|
|
+ let libraryType = CommonConstants.TYPE_NAVIDROME;
|
|
|
+ let libraryScheme = 'navidrome';
|
|
|
+ if (account.webType === RemoteDriveType.Jellyfin) {
|
|
|
+ libraryType = CommonConstants.TYPE_JELLYFIN;
|
|
|
+ libraryScheme = 'jellyfin';
|
|
|
+ } else if (account.webType === RemoteDriveType.Emby) {
|
|
|
+ libraryType = CommonConstants.TYPE_EMBY;
|
|
|
+ libraryScheme = 'emby';
|
|
|
+ }
|
|
|
+
|
|
|
+ const videoItem = new VideoItem(
|
|
|
+ title,
|
|
|
+ song.id,
|
|
|
+ `${libraryScheme}://${account.id ?? 0}/${song.id}`,
|
|
|
+ libraryType,
|
|
|
+ song.size ?? 0,
|
|
|
+ song.createdAt ?? '',
|
|
|
+ Utility.formatFSize(song.size ?? 0),
|
|
|
+ undefined,
|
|
|
+ song.artist ?? Constants.UNKNOWN_ARTIST,
|
|
|
+ song.album ?? '',
|
|
|
+ `${title}${song.suffix ? '.' + song.suffix : ''}`
|
|
|
+ );
|
|
|
+
|
|
|
+ // 设置时长
|
|
|
+ if (song.duration !== undefined && song.duration !== null && song.duration >= 0) {
|
|
|
+ const totalSeconds = Math.floor(song.duration);
|
|
|
+ const minutes = Math.floor(totalSeconds / 60);
|
|
|
+ const seconds = totalSeconds % 60;
|
|
|
+ const pad = (value: number) => value.toString().padStart(2, '0');
|
|
|
+ videoItem.duration = `${pad(minutes)}:${pad(seconds)}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ videoItem.size = Utility.formatFSize(song.size ?? 0);
|
|
|
+ videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
|
|
|
+ videoItem.genre = song.genre;
|
|
|
+ videoItem.webdav_account_id = account.id?.toString();
|
|
|
+ videoItem.remote_rel_path = song.id;
|
|
|
+ videoItem.navArtistId = song.artistId;
|
|
|
+ videoItem.navAlbumId = song.albumId;
|
|
|
+ videoItem.lyricContent = song.lyrics;
|
|
|
+
|
|
|
+ if (song.track !== undefined && song.track !== null) {
|
|
|
+ videoItem.track = song.track.toString();
|
|
|
+ }
|
|
|
+ if (song.year !== undefined && song.year !== null) {
|
|
|
+ videoItem.year = song.year.toString();
|
|
|
+ }
|
|
|
+ if (song.contentType) {
|
|
|
+ videoItem.mimeType = song.contentType;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理封面(在 worker 线程中调用 API)
|
|
|
+ let coverUrl: string | undefined = undefined;
|
|
|
+ try {
|
|
|
+ // 获取封面ID
|
|
|
+ const coverId = song.coverArt ?? song.coverArtId ?? song.id;
|
|
|
+
|
|
|
+ // 判断是否是 Navidrome 账号
|
|
|
+ const isNavidrome = account.webType === RemoteDriveType.Navidrome;
|
|
|
+
|
|
|
+ if (!isNavidrome) {
|
|
|
+ // Jellyfin/Emby 直接使用 albumId 或 id
|
|
|
+ const fallbackId = song.albumId ?? song.id;
|
|
|
+ coverUrl = await navidromeApi.buildCoverArtUrl(account, fallbackId, 300);
|
|
|
+ } else {
|
|
|
+ // Navidrome 优先使用直接封面路径
|
|
|
+ const embedArtPath = song.embedArtPath ?? song.coverArtPath;
|
|
|
+ if (embedArtPath) {
|
|
|
+ // 构建直接封面URL
|
|
|
+ const protocol = account.enableHttps ? 'https' : 'http';
|
|
|
+ const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
|
|
|
+ const portPart = account.port && account.port !== 80 && account.port !== 443 ? `:${account.port}` : '';
|
|
|
+ const basePath = account.navidromeBasePath ?? '/rest';
|
|
|
+ // 移除 /rest 后缀
|
|
|
+ const apiBase = basePath.endsWith('/rest') ? basePath.slice(0, -5) : basePath;
|
|
|
+ coverUrl = `${protocol}://${host}${portPart}${apiBase}/embed/${embedArtPath}?${account.account}:${account.password}`;
|
|
|
+ } else {
|
|
|
+ // 使用封面API
|
|
|
+ coverUrl = await navidromeApi.buildCoverArtUrl(account, coverId, 300);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ // 封面获取失败,忽略
|
|
|
+ }
|
|
|
+
|
|
|
+ videoItem.pixelMapPath = coverUrl;
|
|
|
+ result.songs.push(videoItem);
|
|
|
+ }
|
|
|
+
|
|
|
+ songNextStart = response.nextStart;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载艺术家并处理封面
|
|
|
+ let artistNextStart: number | null = 0;
|
|
|
+ while (artistNextStart !== null) {
|
|
|
+ const response: PagedResponse<NavidromeRestArtist> = await navidromeRestApi.fetchArtistPage(account, artistNextStart);
|
|
|
+ const chunk: NavidromeRestArtist[] = response.data ?? [];
|
|
|
+ if (chunk.length === 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理艺术家封面
|
|
|
+ for (let i = 0; i < chunk.length; i++) {
|
|
|
+ const artist = chunk[i];
|
|
|
+
|
|
|
+ // 尝试获取已有图片URL
|
|
|
+ let coverUrl: string | undefined = artist.mediumImageUrl ?? artist.largeImageUrl;
|
|
|
+
|
|
|
+ // 如果没有直接URL,生成封面URL
|
|
|
+ if (!coverUrl) {
|
|
|
+ const coverId = artist.coverArt ?? artist.coverArtId ?? (artist.id ? `ar-${artist.id}` : undefined);
|
|
|
+ try {
|
|
|
+ coverUrl = await navidromeApi.buildCoverArtUrl(account, coverId, 256);
|
|
|
+ } catch (error) {
|
|
|
+ // 封面生成失败,忽略
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ artist.coverUrl = coverUrl;
|
|
|
+ result.artists.push(artist);
|
|
|
+ }
|
|
|
+
|
|
|
+ artistNextStart = response.nextStart;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载专辑并处理封面
|
|
|
+ let albumNextStart: number | null = 0;
|
|
|
+ while (albumNextStart !== null) {
|
|
|
+ const response: PagedResponse<NavidromeRestAlbum> = await navidromeRestApi.fetchAlbumPage(account, albumNextStart);
|
|
|
+ const chunk: NavidromeRestAlbum[] = response.data ?? [];
|
|
|
+ if (chunk.length === 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理专辑封面
|
|
|
+ for (let i = 0; i < chunk.length; i++) {
|
|
|
+ const album = chunk[i];
|
|
|
+
|
|
|
+ // 专辑没有直接的 imageUrl 属性,需要生成封面URL
|
|
|
+ let coverUrl: string | undefined = undefined;
|
|
|
+
|
|
|
+ // 优先使用 embed 路径
|
|
|
+ if (album.embedArtPath) {
|
|
|
+ const protocol = account.enableHttps ? 'https' : 'http';
|
|
|
+ const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
|
|
|
+ const portPart = account.port && account.port !== 80 && account.port !== 443 ? `:${account.port}` : '';
|
|
|
+ const basePath = account.navidromeBasePath ?? '/rest';
|
|
|
+ const apiBase = basePath.endsWith('/rest') ? basePath.slice(0, -5) : basePath;
|
|
|
+ coverUrl = `${protocol}://${host}${portPart}${apiBase}/embed/${album.embedArtPath}?${account.account}:${account.password}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果没有 embed 路径,使用封面API
|
|
|
+ if (!coverUrl) {
|
|
|
+ const coverId = album.coverArt ?? album.coverArtId ?? album.id;
|
|
|
+ try {
|
|
|
+ coverUrl = await navidromeApi.buildCoverArtUrl(account, coverId, 300);
|
|
|
+ } catch (error) {
|
|
|
+ // 封面生成失败,忽略
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ album.coverUrl = coverUrl;
|
|
|
+ result.albums.push(album);
|
|
|
+ }
|
|
|
+
|
|
|
+ albumNextStart = response.nextStart;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载歌单
|
|
|
+ let playlistNextStart: number | null = 0;
|
|
|
+ while (playlistNextStart !== null) {
|
|
|
+ const response: PagedResponse<NavidromeRestPlaylist> = await navidromeRestApi.fetchPlaylistPage(account, playlistNextStart);
|
|
|
+ const chunk: NavidromeRestPlaylist[] = response.data ?? [];
|
|
|
+ if (chunk.length === 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ // 使用循环代替展开运算符
|
|
|
+ for (let i = 0; i < chunk.length; i++) {
|
|
|
+ result.playlists.push(chunk[i]);
|
|
|
+ }
|
|
|
+ playlistNextStart = response.nextStart;
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ Logger.error('NavidromeTask', `后台加载数据失败: ${(error as Error).message}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+}
|