|
|
@@ -30,6 +30,7 @@ import { fileIo } from '@kit.CoreFileKit';
|
|
|
import { http } from '@kit.NetworkKit';
|
|
|
import { JSON } from '@kit.ArkTS';
|
|
|
import { simpleCalculateMD5, simplePrecreateFile, simpleUploadFilePart, simpleCreateFile, TaskPrecreateRequest, TaskPrecreateResponse, TaskUploadPartResponse, TaskCreateFileRequest, TaskCreateFileResponse, convertToBaiduPath, simpleLocateUploadServer } from './TaskPoolHelper';
|
|
|
+import { JellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../network/JellyfinApi';
|
|
|
|
|
|
const TAG = 'heanup RemoteDriveManager';
|
|
|
|
|
|
@@ -324,6 +325,7 @@ export class RemoteDriveManager {
|
|
|
private readonly PROGRESS_THROTTLE_MS: number = 500; // 进度更新节流时间(毫秒)
|
|
|
|
|
|
private navidromeApi: NavidromeApi = new NavidromeApi();
|
|
|
+ private jellyfinApi: JellyfinApi = new JellyfinApi();
|
|
|
private pathDisplayNames: Map<string, string> = new Map();
|
|
|
private lastAccountId: number | null = null;
|
|
|
|
|
|
@@ -410,6 +412,7 @@ export class RemoteDriveManager {
|
|
|
const smbShareIndex = resultSet.getColumnIndex('smbShare');
|
|
|
const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
|
|
|
const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
|
|
|
+ const jellyfinBaseIndex = resultSet.getColumnIndex('jellyfinBasePath');
|
|
|
const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
|
|
|
const baiduAccessIndex = resultSet.getColumnIndex('baiduAccessToken');
|
|
|
const baiduRefreshIndex = resultSet.getColumnIndex('baiduRefreshToken');
|
|
|
@@ -426,6 +429,12 @@ export class RemoteDriveManager {
|
|
|
} else {
|
|
|
account.navidromeBasePath = '/rest';
|
|
|
}
|
|
|
+ if (jellyfinBaseIndex >= 0) {
|
|
|
+ const stored = resultSet.getString(jellyfinBaseIndex);
|
|
|
+ account.jellyfinBasePath = stored ?? '';
|
|
|
+ } else {
|
|
|
+ account.jellyfinBasePath = '';
|
|
|
+ }
|
|
|
if (ftpEncodingIndex >= 0) {
|
|
|
const encodingValue = resultSet.getString(ftpEncodingIndex);
|
|
|
account.ftpEncoding = encodingValue && encodingValue.length > 0 ? encodingValue : 'utf-8';
|
|
|
@@ -556,6 +565,27 @@ export class RemoteDriveManager {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ if (currentSong.type === CommonConstants.TYPE_JELLYFIN) {
|
|
|
+ if (currentSong.webdav_account_id) {
|
|
|
+ try {
|
|
|
+ const account = await this.getWebDavAccountById(currentSong.webdav_account_id);
|
|
|
+ if (account) {
|
|
|
+ const authHeaders = await this.jellyfinApi.getAuthHeaders(account);
|
|
|
+ authHeaders.forEach((value, key) => {
|
|
|
+ headers.set(key, value);
|
|
|
+ });
|
|
|
+ Logger.info(TAG, `Jellyfin 认证头已构建`);
|
|
|
+ } else {
|
|
|
+ Logger.warn(TAG, `Jellyfin 账号不可用: ${currentSong.webdav_account_id}`);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ Logger.error(TAG, `Jellyfin 认证头构建失败: ${(error as Error).message}`);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ Logger.warn(TAG, 'Jellyfin歌曲缺少webdav_account_id,无法构建认证头');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
if (currentSong.type === CommonConstants.TYPE_BAIDU) {
|
|
|
headers.set('User-Agent', BaiduConstants.STREAMING_USER_AGENT);
|
|
|
headers.set('Host', 'pan.baidu.com');
|
|
|
@@ -587,7 +617,7 @@ export class RemoteDriveManager {
|
|
|
// 查询所有TYPE_WEBDAV且webdav_account_id为空或null的记录
|
|
|
const querySql = `
|
|
|
SELECT id, filePath FROM mediaTable
|
|
|
- WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU})
|
|
|
+ WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU}, ${CommonConstants.TYPE_JELLYFIN})
|
|
|
AND (webdav_account_id IS NULL OR webdav_account_id = '')
|
|
|
`;
|
|
|
|
|
|
@@ -601,7 +631,7 @@ export class RemoteDriveManager {
|
|
|
const updateSql = `
|
|
|
UPDATE mediaTable
|
|
|
SET webdav_account_id = ?
|
|
|
- WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU})
|
|
|
+ WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU}, ${CommonConstants.TYPE_JELLYFIN})
|
|
|
AND (webdav_account_id IS NULL OR webdav_account_id = '')
|
|
|
`;
|
|
|
|
|
|
@@ -677,6 +707,7 @@ export class RemoteDriveManager {
|
|
|
const smbShareIndex = resultSet.getColumnIndex('smbShare');
|
|
|
const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
|
|
|
const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
|
|
|
+ const jellyfinBaseIndex = resultSet.getColumnIndex('jellyfinBasePath');
|
|
|
const baiduAccessIndex = resultSet.getColumnIndex('baiduAccessToken');
|
|
|
const baiduRefreshIndex = resultSet.getColumnIndex('baiduRefreshToken');
|
|
|
const baiduExpireIndex = resultSet.getColumnIndex('baiduTokenExpiresAt');
|
|
|
@@ -745,6 +776,7 @@ export class RemoteDriveManager {
|
|
|
smbShare TEXT,
|
|
|
smbDomain TEXT,
|
|
|
navidromeBasePath TEXT,
|
|
|
+ jellyfinBasePath TEXT,
|
|
|
ftpEncoding TEXT
|
|
|
)`;
|
|
|
|
|
|
@@ -809,6 +841,14 @@ export class RemoteDriveManager {
|
|
|
} catch (error) {
|
|
|
}
|
|
|
|
|
|
+ try {
|
|
|
+ Logger.info(TAG, '尝试添加jellyfinBasePath字段...');
|
|
|
+ const addJellyfinBaseSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN jellyfinBasePath TEXT`;
|
|
|
+ await this.dataBaseUtil.executeSql(addJellyfinBaseSql);
|
|
|
+ Logger.info(TAG, 'jellyfinBasePath字段添加成功');
|
|
|
+ } catch (error) {
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
Logger.info(TAG, '尝试添加ftpEncoding字段...');
|
|
|
const addFtpEncodingSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN ftpEncoding TEXT`;
|
|
|
@@ -837,7 +877,7 @@ export class RemoteDriveManager {
|
|
|
// 先查询基础字段(确保这些字段在旧版本中存在)
|
|
|
const baseColumns = ['id', 'name', 'isActivate', 'host', 'localHost', 'isUseLocalHost',
|
|
|
'port', 'filepath', 'imageFilePath', 'lyricFilePath', 'uploadFilePath',
|
|
|
- 'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain', 'navidromeBasePath', 'ftpEncoding',
|
|
|
+ 'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain', 'navidromeBasePath', 'jellyfinBasePath', 'ftpEncoding',
|
|
|
'baiduAccessToken', 'baiduRefreshToken', 'baiduTokenExpiresAt'];
|
|
|
|
|
|
const resultSet = await this.dataBaseUtil.queryData(this.webDavTable, baseColumns, predicates);
|
|
|
@@ -881,6 +921,13 @@ export class RemoteDriveManager {
|
|
|
} else {
|
|
|
account.navidromeBasePath = '/rest';
|
|
|
}
|
|
|
+ const jellyfinBaseIndex = resultSet.getColumnIndex('jellyfinBasePath');
|
|
|
+ if (jellyfinBaseIndex >= 0) {
|
|
|
+ const stored = resultSet.getString(jellyfinBaseIndex);
|
|
|
+ account.jellyfinBasePath = stored ?? '';
|
|
|
+ } else {
|
|
|
+ account.jellyfinBasePath = '';
|
|
|
+ }
|
|
|
const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
|
|
|
if (ftpEncodingIndex >= 0) {
|
|
|
const encodingValue = resultSet.getString(ftpEncodingIndex);
|
|
|
@@ -934,6 +981,7 @@ export class RemoteDriveManager {
|
|
|
smbShare: string = '',
|
|
|
smbDomain: string = '',
|
|
|
navidromeBasePath: string = '/rest',
|
|
|
+ jellyfinBasePath: string = '',
|
|
|
ftpEncoding: string = 'utf-8',
|
|
|
baiduAccessToken: string = '',
|
|
|
baiduRefreshToken: string = '',
|
|
|
@@ -959,6 +1007,7 @@ export class RemoteDriveManager {
|
|
|
'smbShare': smbShare,
|
|
|
'smbDomain': smbDomain,
|
|
|
'navidromeBasePath': navidromeBasePath,
|
|
|
+ 'jellyfinBasePath': jellyfinBasePath,
|
|
|
'ftpEncoding': ftpEncoding,
|
|
|
'baiduAccessToken': baiduAccessToken,
|
|
|
'baiduRefreshToken': baiduRefreshToken,
|
|
|
@@ -1000,6 +1049,7 @@ export class RemoteDriveManager {
|
|
|
'smbShare': account.smbShare,
|
|
|
'smbDomain': account.smbDomain,
|
|
|
'navidromeBasePath': account.navidromeBasePath,
|
|
|
+ 'jellyfinBasePath': account.jellyfinBasePath,
|
|
|
'ftpEncoding': account.ftpEncoding,
|
|
|
'baiduAccessToken': account.baiduAccessToken,
|
|
|
'baiduRefreshToken': account.baiduRefreshToken,
|
|
|
@@ -1098,6 +1148,8 @@ export class RemoteDriveManager {
|
|
|
await this.loadSmbFiles(account, normalizedFullPath);
|
|
|
} else if (account.webType === RemoteDriveType.Navidrome) {
|
|
|
await this.loadNavidromeFiles(account, normalizedFullPath);
|
|
|
+ } else if (account.webType === RemoteDriveType.Jellyfin) {
|
|
|
+ await this.loadJellyfinFiles(account, normalizedFullPath);
|
|
|
} else if (account.webType === RemoteDriveType.Ftp) {
|
|
|
await this.loadFtpFiles(account, normalizedFullPath);
|
|
|
} else if (account.webType === RemoteDriveType.Baidu) {
|
|
|
@@ -1620,6 +1672,64 @@ export class RemoteDriveManager {
|
|
|
throw new Error(`不支持的Navidrome路径: ${fullPath}`);
|
|
|
}
|
|
|
|
|
|
+ private async loadJellyfinFiles(account: WebDavAccount, fullPath: string): Promise<void> {
|
|
|
+ const normalized = this.normalizeFullPath(fullPath);
|
|
|
+ this.registerPathLabel('/', '根目录');
|
|
|
+ const segments = normalized.split('/').filter(part => part.length > 0);
|
|
|
+ void ServerLogUtil.info(TAG, `浏览 Jellyfin 路径: ${normalized} account=${ServerLogUtil.sanitizeAccount(account)}`);
|
|
|
+
|
|
|
+ if (normalized === '/' || segments.length === 0) {
|
|
|
+ const artists: JellyfinArtist[] = await this.jellyfinApi.getArtists(account);
|
|
|
+ artists.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
+ this.webDavFiles = artists.map(artist => {
|
|
|
+ const info = this.createJellyfinDirectory(artist.name, `/artist/${artist.id}`);
|
|
|
+ this.registerPathLabel(info.href, artist.name);
|
|
|
+ return info;
|
|
|
+ });
|
|
|
+ this.webDavSongs = [];
|
|
|
+ void ServerLogUtil.info(TAG, `获取 Jellyfin 艺术家列表,数量: ${artists.length}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (segments[0] === 'artist' && segments[1]) {
|
|
|
+ const artistId = segments[1];
|
|
|
+ const albums: JellyfinAlbum[] = await this.jellyfinApi.getArtistAlbums(account, artistId);
|
|
|
+ const artistLabel = this.pathDisplayNames.get(`/artist/${artistId}`) ?? (albums[0]?.artist ?? '艺术家');
|
|
|
+ if (artistLabel) {
|
|
|
+ this.registerPathLabel(`/artist/${artistId}`, artistLabel);
|
|
|
+ }
|
|
|
+ this.webDavFiles = albums.map(album => {
|
|
|
+ const info = this.createJellyfinDirectory(album.name, `/album/${album.id}`);
|
|
|
+ this.registerPathLabel(info.href, album.name);
|
|
|
+ return info;
|
|
|
+ });
|
|
|
+ this.webDavSongs = [];
|
|
|
+ void ServerLogUtil.info(TAG, `进入 Jellyfin 艺术家 ${artistLabel},专辑数: ${albums.length}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (segments[0] === 'album' && segments[1]) {
|
|
|
+ const albumId = segments[1];
|
|
|
+ const album = await this.jellyfinApi.getAlbum(account, albumId);
|
|
|
+ const albumName = album?.name ?? this.pathDisplayNames.get(`/album/${albumId}`) ?? '专辑';
|
|
|
+ this.registerPathLabel(`/album/${albumId}`, albumName);
|
|
|
+ const songs = await this.jellyfinApi.getAlbumSongs(account, albumId);
|
|
|
+ let coverUrl: string | undefined = undefined;
|
|
|
+ try {
|
|
|
+ coverUrl = await this.jellyfinApi.buildPrimaryImageUrl(account, album?.id ?? albumId);
|
|
|
+ } catch (error) {
|
|
|
+ Logger.warn(TAG, `Jellyfin 专辑封面获取失败: ${(error as Error).message}`);
|
|
|
+ }
|
|
|
+ this.webDavFiles = songs.map(song => this.createJellyfinSongFileInfo(song, albumId));
|
|
|
+ this.webDavSongs = songs.map(song => this.buildJellyfinVideoItem(song, account, album, coverUrl));
|
|
|
+ await this.enrichSongsWithDatabase(this.webDavSongs);
|
|
|
+ void ServerLogUtil.info(TAG, `进入 Jellyfin 专辑 ${albumName},歌曲数: ${songs.length}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new Error(`不支持的Jellyfin路径: ${fullPath}`);
|
|
|
+ }
|
|
|
+
|
|
|
private async enrichSongsWithDatabase(videoItems: VideoItem[]): Promise<void> {
|
|
|
if (!this.context || !videoItems || videoItems.length === 0) {
|
|
|
return;
|
|
|
@@ -1676,7 +1786,8 @@ export class RemoteDriveManager {
|
|
|
return null;
|
|
|
}
|
|
|
if (item.type === CommonConstants.TYPE_SMB || item.type === CommonConstants.TYPE_NAVIDROME ||
|
|
|
- item.type === CommonConstants.TYPE_FTP || item.type === CommonConstants.TYPE_BAIDU) {
|
|
|
+ item.type === CommonConstants.TYPE_FTP || item.type === CommonConstants.TYPE_BAIDU ||
|
|
|
+ item.type === CommonConstants.TYPE_JELLYFIN) {
|
|
|
if (item.remote_rel_path) {
|
|
|
return item.remote_rel_path;
|
|
|
}
|
|
|
@@ -2051,6 +2162,14 @@ export class RemoteDriveManager {
|
|
|
return info;
|
|
|
}
|
|
|
|
|
|
+ private createJellyfinDirectory(name: string, href: string): FileInfo {
|
|
|
+ const info = new FileInfo('', name, 0, Date.now());
|
|
|
+ info.fileName = name;
|
|
|
+ info.href = this.normalizeFullPath(href);
|
|
|
+ info.isDirectory = true;
|
|
|
+ return info;
|
|
|
+ }
|
|
|
+
|
|
|
private createNavSongFileInfo(song: NavidromeSong, albumId: string): FileInfo {
|
|
|
const displayName = song.title ?? '未知曲目';
|
|
|
const info = new FileInfo('', displayName, song.size ?? 0, Date.now());
|
|
|
@@ -2061,6 +2180,16 @@ export class RemoteDriveManager {
|
|
|
return info;
|
|
|
}
|
|
|
|
|
|
+ private createJellyfinSongFileInfo(song: JellyfinSong, albumId: string): FileInfo {
|
|
|
+ const displayName = song.title ?? '未知曲目';
|
|
|
+ const info = new FileInfo('', displayName, song.size ?? 0, Date.now());
|
|
|
+ info.fileName = `${displayName}${song.suffix ? '.' + song.suffix : ''}`;
|
|
|
+ info.href = this.normalizeFullPath(`/album/${albumId}/${song.id}`);
|
|
|
+ info.isDirectory = false;
|
|
|
+ info.contentLength = song.size ?? 0;
|
|
|
+ return info;
|
|
|
+ }
|
|
|
+
|
|
|
private buildNavidromeVideoItem(song: NavidromeSong, account: WebDavAccount, album?: NavidromeAlbumDetail, coverUrl?: string): VideoItem {
|
|
|
const title = song.title ?? '未知曲目';
|
|
|
const fileName = `${title}${song.suffix ? '.' + song.suffix : ''}`;
|
|
|
@@ -2092,6 +2221,37 @@ export class RemoteDriveManager {
|
|
|
return videoItem;
|
|
|
}
|
|
|
|
|
|
+ private buildJellyfinVideoItem(song: JellyfinSong, account: WebDavAccount, album?: JellyfinAlbum | null, coverUrl?: string): VideoItem {
|
|
|
+ const title = song.title ?? '未知曲目';
|
|
|
+ const fileName = `${title}${song.suffix ? '.' + song.suffix : ''}`;
|
|
|
+ const videoItem = new VideoItem(
|
|
|
+ title,
|
|
|
+ song.id,
|
|
|
+ `jellyfin://${account.id ?? 0}/${song.id}`,
|
|
|
+ CommonConstants.TYPE_JELLYFIN,
|
|
|
+ song.size ?? 0,
|
|
|
+ Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
|
|
|
+ Utility.formatFSize(song.size ?? 0),
|
|
|
+ undefined,
|
|
|
+ song.artist ?? album?.artist ?? Constants.UNKNOWN_ARTIST,
|
|
|
+ album?.name ?? song.album,
|
|
|
+ fileName
|
|
|
+ );
|
|
|
+ videoItem.size = Utility.formatFSize(song.size ?? 0);
|
|
|
+ videoItem.webdav_account_id = account.id?.toString();
|
|
|
+ videoItem.remote_rel_path = song.id;
|
|
|
+ videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
|
|
|
+ videoItem.sampleRate = song.sampleRate ? song.sampleRate.toString() : undefined;
|
|
|
+ videoItem.pixelMapPath = coverUrl;
|
|
|
+ if (song.track !== undefined && song.track !== null) {
|
|
|
+ videoItem.track = song.track.toString();
|
|
|
+ }
|
|
|
+ if (song.year !== undefined && song.year !== null) {
|
|
|
+ videoItem.year = song.year.toString();
|
|
|
+ }
|
|
|
+ return videoItem;
|
|
|
+ }
|
|
|
+
|
|
|
private registerPathLabel(path: string, label: string): void {
|
|
|
if (!path || label === undefined) {
|
|
|
return;
|