RemoteDriveManager.ets 146 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812
  1. // RemoteDriveManager - WebDAV管理器(简化版)
  2. import { VideoItem } from '../../viewmodel/VideoItem';
  3. import { common } from '@kit.AbilityKit';
  4. import { FileInfo } from '../../viewmodel/FileInfo';
  5. import { connection } from '@kit.NetworkKit';
  6. import FileManager, { merge2paths } from './FileManager';
  7. import { BusinessError } from '@kit.BasicServicesKit';
  8. import { RemoteDriveManagerStates } from '../enums/RemoteDriveManagerStates';
  9. import { RcpSocket } from './RcpSocketUtil';
  10. import { DataBaseUtil } from './DataBaseUtil';
  11. import { WebDavAccount } from '../../viewmodel/WebDavAccount';
  12. import { relationalStore } from '@kit.ArkData';
  13. import { Constants } from '../../Constants';
  14. import Logger from './Logger';
  15. import { buffer } from '@kit.ArkTS';
  16. import { CommonConstants } from '../constants/CommonConstants';
  17. import { GlobalContext, PreferencesUtil } from '@pura/harmony-utils';
  18. import { MusicInfo, parseMusicFileName, Utility } from './Utility';
  19. import { RemoteDriveType } from '../enums/RemoteDriveType';
  20. import { createSmbDirectory, deleteSmbEntry, listSmbDirectory, SmbDirectoryEntry } from '../network/SmbBridge';
  21. import { NavidromeApi, NavidromeAlbumDetail, NavidromeArtist, NavidromeArtistDetail, NavidromeSong } from '../network/NavidromeApi';
  22. import { WebDavUrlUtil } from './WebDavUrlUtil';
  23. import MediaTable from './MediaTable';
  24. import { FtpClient, FileInfo as FtpEntryInfo, StringEncoding, AccessOptions } from '@liuzhosoft/ftp4h';
  25. import { BaiduConstants } from '../constants/BaiduConstants';
  26. import { appendAccessTokenToDlink, BaiduListEntry, BaiduFileMeta, buildAudioStreamingUrl, calculateMD5, createFolder, deleteFiles as deleteBaiduFiles, ensureAudioStreamReady, fetchFileMetas as fetchBaiduFileMetas, getUploadDomain, listDirectory as listBaiduDirectory, precreateFile, refreshAccessToken as refreshBaiduAccessToken, uploadFilePart } from '../network/BaiduPanClient';
  27. import { ServerLogUtil } from './ServerLogUtil';
  28. import { taskpool } from '@kit.ArkTS';
  29. import { fileIo } from '@kit.CoreFileKit';
  30. import { http } from '@kit.NetworkKit';
  31. import { JSON } from '@kit.ArkTS';
  32. import { simpleCalculateMD5, simplePrecreateFile, simpleUploadFilePart, simpleCreateFile, TaskPrecreateRequest, TaskPrecreateResponse, TaskUploadPartResponse, TaskCreateFileRequest, TaskCreateFileResponse, convertToBaiduPath, simpleLocateUploadServer } from './TaskPoolHelper';
  33. import { JellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../network/JellyfinApi';
  34. import { EmbyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../network/EmbyApi';
  35. const TAG = 'heanup RemoteDriveManager';
  36. // 百度网盘上传TaskPool任务接口(使用前缀避免名称冲突)
  37. interface TaskBaiduUploadParams {
  38. filePath: string;
  39. accessToken: string;
  40. remotePath: string;
  41. songName: string;
  42. songId: string;
  43. }
  44. interface TaskBaiduUploadResult {
  45. success: boolean;
  46. error?: string;
  47. filePath?: string;
  48. remotePath?: string;
  49. songId?: string;
  50. uploadId?: string;
  51. progress?: number;
  52. currentPart?: number;
  53. totalParts?: number;
  54. }
  55. // TaskPool进度消息接口
  56. interface BaiduUploadProgressMessage {
  57. type: 'progress';
  58. progress: number;
  59. currentPart?: number;
  60. totalParts?: number;
  61. stage: 'md5' | 'upload' | 'create';
  62. uploadedSize?: number;
  63. totalSize?: number;
  64. }
  65. /**
  66. * 百度网盘文件上传TaskPool任务
  67. * 注意:TaskPool任务函数不能访问外部变量,必须使用纯函数
  68. */
  69. @Concurrent
  70. async function executeBaiduUpload(params: TaskBaiduUploadParams): Promise<TaskBaiduUploadResult> {
  71. const filePath = params.filePath;
  72. const accessToken = params.accessToken;
  73. const remotePath = params.remotePath;
  74. const songName = params.songName;
  75. const songId = params.songId;
  76. try {
  77. // 1. 读取文件数据
  78. const file = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
  79. const stat = await fileIo.stat(filePath);
  80. const fileSize = stat.size;
  81. const fileData = new ArrayBuffer(fileSize);
  82. await fileIo.read(file.fd, fileData);
  83. fileIo.closeSync(file);
  84. if (fileSize === 0) {
  85. throw new Error('文件大小为0');
  86. }
  87. // 2. 计算分片信息
  88. const PART_SIZE = 4 * 1024 * 1024; // 4MB分片大小
  89. const totalParts = Math.ceil(fileSize / PART_SIZE);
  90. // 3. 计算分片MD5列表(占总进度的10%)
  91. const blockMd5List: string[] = [];
  92. for (let i = 0; i < totalParts; i++) {
  93. const start = i * PART_SIZE;
  94. const end = Math.min(start + PART_SIZE, fileSize);
  95. const partData = fileData.slice(start, end);
  96. const md5 = await simpleCalculateMD5(partData);
  97. blockMd5List.push(md5);
  98. // 发送MD5计算进度
  99. const md5Progress = ((i + 1) / totalParts) * 10;
  100. const progressMessage: BaiduUploadProgressMessage = {
  101. type: 'progress',
  102. progress: md5Progress,
  103. currentPart: i + 1,
  104. totalParts: totalParts,
  105. stage: 'md5',
  106. uploadedSize: Math.floor(fileSize * md5Progress / 100),
  107. totalSize: fileSize
  108. };
  109. taskpool.Task.sendData(progressMessage);
  110. }
  111. // 4. 预上传(第一阶段)
  112. const precreateRequest: TaskPrecreateRequest = {
  113. path: remotePath,
  114. size: fileSize,
  115. isdir: 0,
  116. block_list: JSON.stringify(blockMd5List),
  117. autoinit: 1,
  118. rtype: 1,
  119. local_ctime: Math.floor(Date.now() / 1000).toString(),
  120. local_mtime: Math.floor(Date.now() / 1000).toString(),
  121. dupicate_check: 0 // 不检查重复文件
  122. };
  123. const precreateResponse = await simplePrecreateFile(accessToken, precreateRequest);
  124. if (!precreateResponse.uploadid) {
  125. throw new Error('预上传失败:未获取到uploadid');
  126. }
  127. console.info(`heanup 预上传成功,uploadid: ${JSON.stringify(precreateResponse)}`);
  128. const uploadServer = await simpleLocateUploadServer(accessToken, remotePath, precreateResponse.uploadid!);
  129. // 5. 分片上传(占总进度的80%)
  130. const partsToUpload = precreateResponse.block_list || [];
  131. Logger.info(`heanup 预上传返回block_list: ${JSON.stringify(partsToUpload)}, errno: ${precreateResponse.errno}`);
  132. // 如果block_list为空,说明所有分片都已存在,直接跳过分片上传
  133. Logger.info( `heanup 需要上传的分片列表: ${JSON.stringify(partsToUpload)}, 总计${partsToUpload.length}个分片`);
  134. const baseProgress = 10; // MD5计算已完成
  135. const uploadProgressRange = 80; // 上传进度范围
  136. const uploadedParts = new Set<number>();
  137. for (let i = 0; i < partsToUpload.length; i++) {
  138. const partSeq = partsToUpload[i];
  139. const start = partSeq * PART_SIZE;
  140. const end = Math.min(start + PART_SIZE, fileSize);
  141. const partData = fileData.slice(start, end);
  142. Logger.info( `heanup 开始上传分片${partSeq}, 大小: ${partData.byteLength}字节`);
  143. await simpleUploadFilePart(uploadServer, accessToken, remotePath, precreateResponse.uploadid!, partSeq, partData);
  144. Logger.info(`heanup 分片${partSeq}上传完成`);
  145. // 记录已上传的分片并发送进度
  146. uploadedParts.add(partSeq);
  147. const uploadProgress = (uploadedParts.size / partsToUpload.length) * uploadProgressRange;
  148. const totalProgress = baseProgress + uploadProgress;
  149. const progressMessage: BaiduUploadProgressMessage = {
  150. type: 'progress',
  151. progress: totalProgress,
  152. currentPart: uploadedParts.size,
  153. totalParts: partsToUpload.length,
  154. stage: 'upload',
  155. uploadedSize: Math.floor(fileSize * totalProgress / 100),
  156. totalSize: fileSize
  157. };
  158. taskpool.Task.sendData(progressMessage);
  159. }
  160. // 6. 创建文件(第三阶段,占总进度的10%)
  161. const createFileProgressMessage: BaiduUploadProgressMessage = {
  162. type: 'progress',
  163. progress: 90,
  164. stage: 'create',
  165. uploadedSize: Math.floor(fileSize * 0.9),
  166. totalSize: fileSize
  167. };
  168. taskpool.Task.sendData(createFileProgressMessage);
  169. const createFileRequest: TaskCreateFileRequest = {
  170. path: remotePath,
  171. size: fileSize,
  172. uploadid: precreateResponse.uploadid!,
  173. block_list: JSON.stringify(blockMd5List)
  174. };
  175. const createFileResponse = await simpleCreateFile(accessToken, createFileRequest);
  176. if (!createFileResponse.path) {
  177. throw new Error('创建文件失败:未获取到文件路径');
  178. }
  179. // 发送完成进度
  180. const finalProgressMessage: BaiduUploadProgressMessage = {
  181. type: 'progress',
  182. progress: 100,
  183. stage: 'create',
  184. uploadedSize: fileSize,
  185. totalSize: fileSize
  186. };
  187. taskpool.Task.sendData(finalProgressMessage);
  188. return {
  189. success: true,
  190. filePath: filePath,
  191. remotePath: createFileResponse.path,
  192. songId: songId,
  193. uploadId: precreateResponse.uploadid,
  194. progress: 100
  195. };
  196. } catch (error) {
  197. const err = error as Error;
  198. return {
  199. success: false,
  200. error: err.message,
  201. filePath: filePath,
  202. remotePath: remotePath,
  203. songId: songId,
  204. progress: 0
  205. };
  206. }
  207. }
  208. export interface BreadcrumbItem {
  209. label: string;
  210. path: string;
  211. }
  212. interface BaiduDlinkCacheEntry {
  213. url: string;
  214. token: string;
  215. expireAt: number;
  216. }
  217. // WebDAV认证信息接口
  218. export interface WebDavAuthInfo {
  219. headers: Record<string, string>;
  220. url: string;
  221. accountId?: string; // 添加账号ID字段,用于标识认证信息对应的账号
  222. }
  223. // 流媒体认证信息接口
  224. export interface StreamAuthInfo {
  225. url: string;
  226. headers: Record<string, string>;
  227. }
  228. // 百度网盘文件信息接口
  229. export interface BaiduFileInfo {
  230. size: number;
  231. dlink: string;
  232. streamUrl: string;
  233. }
  234. export interface TransferTask {
  235. song: VideoItem;
  236. account: WebDavAccount;
  237. retryCount?: number; // 已重试次数
  238. customUploadPath?: string; // 自定义上传路径(可选)
  239. }
  240. @Observed
  241. export class RemoteDriveManager {
  242. public rcpSocket: RcpSocket = RcpSocket.getInstance();
  243. private dataBaseUtil = DataBaseUtil.getInstance();
  244. public observers: Array<(event: string) => void> = [];
  245. public static instance: RemoteDriveManager;
  246. public context: common.Context | undefined;
  247. public DownloadDirectoryFilePath: string = '';
  248. public audioExtensions = Constants.AUDIO_EXTENSIONS;
  249. public lyricExtensions = Constants.LYRIC_EXTENSIONS;
  250. public imageExtensions = Constants.IMAGE_EXTENSIONS;
  251. // 数据表名称
  252. public preferenceName = Constants.WEBDAV_PREFERENCE_NAME;
  253. public webDavTable = 'WebDavAccount';
  254. public receivedSize: number = 0;
  255. public totalSize: number = 0;
  256. public webDavAccounts: WebDavAccount[] = [];
  257. public webDavSongs: VideoItem[] = [];
  258. public webDavFiles: FileInfo[] = []; // 当前目录的所有文件(包括文件夹)
  259. private static readonly BAIDU_DLINK_CACHE_TTL: number = 10 * 60 * 1000; // 10分钟
  260. private baiduDlinkCache: Map<string, BaiduDlinkCacheEntry> = new Map();
  261. // 路径导航
  262. public currentPath: string = ''; // 当前浏览的路径
  263. public pathHistory: string[] = []; // 路径历史记录
  264. // 错误信息
  265. public ErrorMessage: string | BusinessError = '';
  266. // 下载队列
  267. public downloadQueue: TransferTask[] = [];
  268. public finishDownloadQueue: TransferTask[] = [];
  269. public isProcessingQueue: boolean = false;
  270. public currentDownloadTask: TransferTask | null = null;
  271. public isPauseDownload: boolean = true;
  272. // 上传队列
  273. public uploadQueue: TransferTask[] = [];
  274. public finishUploadQueue: TransferTask[] = [];
  275. public isProcessingUploadQueue: boolean = false;
  276. public currentUploadTask: TransferTask | null = null;
  277. public isPauseUpload: boolean = true;
  278. public uploadReceivedSize: number = 0;
  279. public uploadTotalSize: number = 0;
  280. // 性能优化相关
  281. // 限制队列大小避免内存溢出:每个任务包含VideoItem对象,200个任务约占用几MB内存
  282. private readonly MAX_UPLOAD_QUEUE_SIZE: number = 200; // 最大上传队列大小
  283. // 进度更新节流:避免频繁UI刷新导致性能下降,每500ms最多更新一次
  284. private lastProgressNotifyTime: number = 0; // 上次进度通知时间
  285. private readonly PROGRESS_THROTTLE_MS: number = 500; // 进度更新节流时间(毫秒)
  286. private navidromeApi: NavidromeApi = new NavidromeApi();
  287. private jellyfinApi: JellyfinApi = new JellyfinApi();
  288. private embyApi: EmbyApi = new EmbyApi();
  289. private pathDisplayNames: Map<string, string> = new Map();
  290. private lastAccountId: number | null = null;
  291. public currentAccount:WebDavAccount = new WebDavAccount();
  292. public constructor() {
  293. this.rcpSocket.subscribeHTTPDataTransfer((event: string) => {
  294. this.notifyObservers(event);
  295. });
  296. }
  297. public static getInstance(): RemoteDriveManager {
  298. if (!RemoteDriveManager.instance) {
  299. RemoteDriveManager.instance = new RemoteDriveManager();
  300. }
  301. return RemoteDriveManager.instance;
  302. }
  303. public setContext(context: common.Context): void {
  304. this.context = context;
  305. this.DownloadDirectoryFilePath = merge2paths(context.filesDir, 'Download');
  306. }
  307. // ==================== 观察者模式 ====================
  308. public subscribe(callback: (event: string) => void): void {
  309. this.observers.push(callback);
  310. }
  311. public unsubscribe(callback: (event: string) => void): void {
  312. const index = this.observers.indexOf(callback);
  313. if (index > -1) {
  314. this.observers.splice(index, 1);
  315. }
  316. }
  317. public notifyObservers(event: string): void {
  318. Logger.info(TAG, '通知观察者:', event);
  319. Logger.info(TAG, '观察者数量:', this.observers.length.toString());
  320. for (let i = 0; i < this.observers.length; i++) {
  321. const observer = this.observers[i];
  322. Logger.info(TAG, '调用观察者', i.toString(), ',事件:', event);
  323. observer(event);
  324. }
  325. }
  326. // ==================== 数据库操作 ====================
  327. /**
  328. * 根据webdav_account_id查询WebDAV账号信息
  329. * @param accountId WebDAV账号ID
  330. * @returns Promise<WebDavAccount | null> 返回WebDAV账号信息或null
  331. */
  332. public async getWebDavAccountById(accountId: string): Promise<WebDavAccount | null> {
  333. if (!accountId) {
  334. Logger.warn(TAG, 'webdav_account_id为空,无法查询账号信息');
  335. return null;
  336. }
  337. try {
  338. const db = await this.dataBaseUtil.getDB();
  339. const predicates = new relationalStore.RdbPredicates(this.webDavTable);
  340. predicates.equalTo('id', accountId);
  341. const resultSet = await db.query(predicates);
  342. if (resultSet.rowCount > 0) {
  343. resultSet.goToFirstRow();
  344. const account = new WebDavAccount();
  345. account.id = resultSet.getLong(resultSet.getColumnIndex('id'));
  346. account.name = resultSet.getString(resultSet.getColumnIndex('name'));
  347. account.host = resultSet.getString(resultSet.getColumnIndex('host'));
  348. account.localHost = resultSet.getString(resultSet.getColumnIndex('localHost'));
  349. account.isUseLocalHost = resultSet.getLong(resultSet.getColumnIndex('isUseLocalHost')) === 1;
  350. account.port = resultSet.getLong(resultSet.getColumnIndex('port'));
  351. account.filepath = resultSet.getString(resultSet.getColumnIndex('filepath'));
  352. account.imageFilePath = resultSet.getString(resultSet.getColumnIndex('imageFilePath'));
  353. account.lyricFilePath = resultSet.getString(resultSet.getColumnIndex('lyricFilePath'));
  354. account.uploadFilePath = resultSet.getString(resultSet.getColumnIndex('uploadFilePath'));
  355. account.account = resultSet.getString(resultSet.getColumnIndex('account'));
  356. account.password = resultSet.getString(resultSet.getColumnIndex('password'));
  357. account.enableHttps = resultSet.getLong(resultSet.getColumnIndex('enableHttps')) === 1;
  358. account.coverPath = resultSet.getString(resultSet.getColumnIndex('coverPath'));
  359. account.webType = resultSet.getLong(resultSet.getColumnIndex('webType'));
  360. const smbShareIndex = resultSet.getColumnIndex('smbShare');
  361. const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
  362. const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
  363. const jellyfinBaseIndex = resultSet.getColumnIndex('jellyfinBasePath');
  364. const embyBaseIndex = resultSet.getColumnIndex('embyBasePath');
  365. const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
  366. const baiduAccessIndex = resultSet.getColumnIndex('baiduAccessToken');
  367. const baiduRefreshIndex = resultSet.getColumnIndex('baiduRefreshToken');
  368. const baiduExpireIndex = resultSet.getColumnIndex('baiduTokenExpiresAt');
  369. if (smbShareIndex >= 0) {
  370. account.smbShare = resultSet.getString(smbShareIndex) ?? '';
  371. }
  372. if (smbDomainIndex >= 0) {
  373. account.smbDomain = resultSet.getString(smbDomainIndex) ?? '';
  374. }
  375. if (navBaseIndex >= 0) {
  376. const stored = resultSet.getString(navBaseIndex);
  377. account.navidromeBasePath = stored && stored.length > 0 ? stored : '/rest';
  378. } else {
  379. account.navidromeBasePath = '/rest';
  380. }
  381. if (jellyfinBaseIndex >= 0) {
  382. const stored = resultSet.getString(jellyfinBaseIndex);
  383. account.jellyfinBasePath = stored ?? '';
  384. } else {
  385. account.jellyfinBasePath = '';
  386. }
  387. if (embyBaseIndex >= 0) {
  388. const stored = resultSet.getString(embyBaseIndex);
  389. account.embyBasePath = stored ?? '';
  390. } else {
  391. account.embyBasePath = '';
  392. }
  393. if (ftpEncodingIndex >= 0) {
  394. const encodingValue = resultSet.getString(ftpEncodingIndex);
  395. account.ftpEncoding = encodingValue && encodingValue.length > 0 ? encodingValue : 'utf-8';
  396. } else {
  397. account.ftpEncoding = 'utf-8';
  398. }
  399. if (baiduAccessIndex >= 0) {
  400. account.baiduAccessToken = resultSet.getString(baiduAccessIndex) ?? '';
  401. }
  402. if (baiduRefreshIndex >= 0) {
  403. account.baiduRefreshToken = resultSet.getString(baiduRefreshIndex) ?? '';
  404. }
  405. if (baiduExpireIndex >= 0) {
  406. account.baiduTokenExpiresAt = resultSet.getLong(baiduExpireIndex);
  407. }
  408. account.isActivate = resultSet.getLong(resultSet.getColumnIndex('isActivate')) === 1;
  409. resultSet.close();
  410. Logger.info(TAG, `成功查询到WebDAV账号: ${account.name}, ID: ${account.id}`);
  411. return account;
  412. } else {
  413. Logger.warn(TAG, `未找到ID为 ${accountId} 的WebDAV账号`);
  414. resultSet.close();
  415. return null;
  416. }
  417. } catch (error) {
  418. const err = error as Error;
  419. Logger.error(TAG, `查询WebDAV账号失败: ${err.message}`);
  420. return null;
  421. }
  422. }
  423. /**
  424. * 根据WebDAV账号构建认证信息
  425. * @param accountId WebDAV账号ID
  426. * @returns Promise<WebDavAuthInfo | null> 返回认证信息或null
  427. */
  428. public async buildAuthInfoByAccountId(accountId: string): Promise<WebDavAuthInfo | null> {
  429. const account = await this.getWebDavAccountById(accountId);
  430. if (!account) {
  431. Logger.warn(TAG, `无法构建认证信息,账号ID ${accountId} 对应的账号不存在`);
  432. return null;
  433. }
  434. try {
  435. // 构建基础URL
  436. const protocol = account.enableHttps ? 'https' : 'http';
  437. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  438. const port = account.port && account.port !== 80 && account.port !== 443 ? `:${account.port}` : '';
  439. const baseUrl = `${protocol}://${host}${port}`;
  440. // 构建认证头
  441. const headers: Record<string, string> = {};
  442. if (account.account && account.password) {
  443. const credentials = `${account.account}:${account.password}`;
  444. // 简化base64编码实现,避免复杂的类型转换
  445. try {
  446. const bufferObj = buffer.from(credentials, 'utf-8');
  447. const base64Credentials = bufferObj.toString('base64');
  448. headers['Authorization'] = `Basic ${base64Credentials}`;
  449. } catch (error) {
  450. const err = error as Error;
  451. Logger.warn(TAG, `base64编码失败,使用原始凭据: ${err.message}`);
  452. headers['Authorization'] = `Basic ${credentials}`;
  453. }
  454. }
  455. Logger.info(TAG, `成功构建WebDAV认证信息,账号: ${account.name}, URL: ${baseUrl}`);
  456. return {
  457. headers: headers,
  458. url: baseUrl,
  459. accountId: accountId
  460. };
  461. } catch (error) {
  462. const err = error as Error;
  463. Logger.error(TAG, `构建WebDAV认证信息失败: ${err.message}`);
  464. return null;
  465. }
  466. }
  467. /**
  468. * 构建HTTP请求头,通过webdav_account_id查询认证信息
  469. * @param currentSong - 当前播放的歌曲信息
  470. * @param videoUrl - 当前歌曲的URL
  471. * @returns Promise<Map<string, string>> HTTP请求头
  472. */
  473. public async buildHttpHeadersWithAccountId(
  474. currentSong: VideoItem | undefined,
  475. videoUrl: string
  476. ): Promise<Map<string, string>> {
  477. const headers = new Map<string, string>();
  478. if (!currentSong) {
  479. return headers;
  480. }
  481. Logger.info(`heanup 当前歌曲信息 - name: ${currentSong.name}, type: ${currentSong.type}, filePath: ${currentSong.filePath}`);
  482. // 检查是否为WebDAV歌曲
  483. if (currentSong.type === CommonConstants.TYPE_WEBDAV) {
  484. // 优先使用webdav_account_id查询认证信息
  485. if (currentSong.webdav_account_id) {
  486. Logger.info(`heanup 使用webdav_account_id查询认证信息,账号ID: ${currentSong.webdav_account_id}`);
  487. try {
  488. const authInfo = await this.buildAuthInfoByAccountId(currentSong.webdav_account_id);
  489. if (authInfo) {
  490. // 添加WebDAV认证头
  491. Object.keys(authInfo.headers).forEach(key => {
  492. headers.set(key, authInfo.headers[key]);
  493. });
  494. Logger.info(`heanup 成功通过webdav_account_id获取WebDAV认证信息`);
  495. } else {
  496. Logger.warn(`heanup 无法通过webdav_account_id ${currentSong.webdav_account_id} 获取认证信息`);
  497. }
  498. } catch (error) {
  499. const err = error as Error;
  500. Logger.error(`heanup 查询webdav_account_id认证信息时出错: ${err.message}`);
  501. }
  502. } else {
  503. Logger.warn(`heanup WebDAV歌曲缺少webdav_account_id字段,无法查询认证信息`);
  504. }
  505. // 如果没有认证信息,记录警告
  506. if (headers.size === 0) {
  507. Logger.warn(`heanup WebDAV歌曲缺少认证信息,可能需要手动配置WebDAV账户`);
  508. }
  509. }
  510. if (currentSong.type === CommonConstants.TYPE_JELLYFIN) {
  511. if (currentSong.webdav_account_id) {
  512. try {
  513. const account = await this.getWebDavAccountById(currentSong.webdav_account_id);
  514. if (account) {
  515. const authHeaders = await this.jellyfinApi.getAuthHeaders(account);
  516. authHeaders.forEach((value, key) => {
  517. headers.set(key, value);
  518. });
  519. Logger.info(TAG, `Jellyfin 认证头已构建`);
  520. } else {
  521. Logger.warn(TAG, `Jellyfin 账号不可用: ${currentSong.webdav_account_id}`);
  522. }
  523. } catch (error) {
  524. Logger.error(TAG, `Jellyfin 认证头构建失败: ${(error as Error).message}`);
  525. }
  526. } else {
  527. Logger.warn(TAG, 'Jellyfin歌曲缺少webdav_account_id,无法构建认证头');
  528. }
  529. }
  530. if (currentSong.type === CommonConstants.TYPE_EMBY) {
  531. if (currentSong.webdav_account_id) {
  532. try {
  533. const account = await this.getWebDavAccountById(currentSong.webdav_account_id);
  534. if (account) {
  535. const authHeaders = await this.embyApi.getAuthHeaders(account);
  536. authHeaders.forEach((value, key) => {
  537. headers.set(key, value);
  538. });
  539. Logger.info(TAG, `Emby 认证头已构建`);
  540. } else {
  541. Logger.warn(TAG, `Emby 账号不可用: ${currentSong.webdav_account_id}`);
  542. }
  543. } catch (error) {
  544. Logger.error(TAG, `Emby 认证头构建失败: ${(error as Error).message}`);
  545. }
  546. } else {
  547. Logger.warn(TAG, 'Emby歌曲缺少webdav_account_id,无法构建认证头');
  548. }
  549. }
  550. if (currentSong.type === CommonConstants.TYPE_BAIDU) {
  551. headers.set('User-Agent', BaiduConstants.STREAMING_USER_AGENT);
  552. headers.set('Accept', '*/*');
  553. headers.set('Connection', 'Keep-Alive');
  554. headers.set('Referer', 'https://pan.baidu.com/disk/home');
  555. headers.set('Accept-Encoding', 'identity');
  556. headers.set('Pragma', 'no-cache');
  557. headers.set('Cache-Control', 'no-cache');
  558. const headerSnapshot: Record<string, string> = {};
  559. headers.forEach((value, key) => {
  560. headerSnapshot[key] = value;
  561. });
  562. Logger.info(TAG, `构建百度请求头: ${JSON.stringify(headerSnapshot)}`);
  563. }
  564. // SMB类型的歌曲通过本地HTTP代理服务器播放,不需要特殊认证头
  565. if (currentSong.type === CommonConstants.TYPE_SMB) {
  566. Logger.info(TAG, `SMB歌曲使用HTTP代理流,无需特殊认证头`);
  567. }
  568. return headers;
  569. }
  570. /**
  571. * 更新现有WebDAV歌曲的webdav_account_id字段
  572. * 用于修复旧版本数据库中缺少webdav_account_id的记录
  573. * @param accountId WebDAV账号ID
  574. */
  575. public async updateWebDavSongsAccountId(accountId: string): Promise<void> {
  576. try {
  577. const db = await this.dataBaseUtil.getDB();
  578. // 查询所有TYPE_WEBDAV且webdav_account_id为空或null的记录
  579. const querySql = `
  580. SELECT id, filePath FROM mediaTable
  581. WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU}, ${CommonConstants.TYPE_JELLYFIN}, ${CommonConstants.TYPE_EMBY})
  582. AND (webdav_account_id IS NULL OR webdav_account_id = '')
  583. `;
  584. const resultSet = await db.querySql(querySql);
  585. const updateCount = resultSet.rowCount;
  586. if (updateCount > 0) {
  587. Logger.info(TAG, `发现 ${updateCount} 个WebDAV歌曲缺少webdav_account_id,开始更新`);
  588. // 更新这些记录的webdav_account_id
  589. const updateSql = `
  590. UPDATE mediaTable
  591. SET webdav_account_id = ?
  592. WHERE mtype IN (${CommonConstants.TYPE_WEBDAV}, ${CommonConstants.TYPE_SMB}, ${CommonConstants.TYPE_FTP}, ${CommonConstants.TYPE_BAIDU}, ${CommonConstants.TYPE_JELLYFIN})
  593. AND (webdav_account_id IS NULL OR webdav_account_id = '')
  594. `;
  595. await db.executeSql(updateSql, [accountId]);
  596. Logger.info(TAG, `成功更新 ${updateCount} 个WebDAV歌曲的webdav_account_id为: ${accountId}`);
  597. } else {
  598. Logger.info(TAG, `所有WebDAV歌曲都已有webdav_account_id,无需更新`);
  599. }
  600. resultSet.close();
  601. } catch (error) {
  602. const err = error as Error;
  603. Logger.error(TAG, `更新WebDAV歌曲webdav_account_id失败: ${err.message}`);
  604. }
  605. }
  606. /**
  607. * 修复所有WebDAV歌曲的webdav_account_id字段
  608. * 用于全局修复旧版本数据库中缺少webdav_account_id的记录
  609. * @param accountId WebDAV账号ID(可选,如果不提供则使用当前激活的账号)
  610. */
  611. public async fixAllWebDavSongsAccountId(accountId?: string): Promise<void> {
  612. try {
  613. // 如果没有提供accountId,尝试使用当前激活的账号
  614. if (!accountId) {
  615. const activeAccount = await this.getActiveWebDavAccount();
  616. if (activeAccount && activeAccount.id) {
  617. accountId = activeAccount.id.toString();
  618. } else {
  619. Logger.warn(TAG, `无法找到激活的WebDAV账号来修复歌曲记录`);
  620. return;
  621. }
  622. }
  623. Logger.info(TAG, `开始修复所有WebDAV歌曲的webdav_account_id,使用账号ID: ${accountId}`);
  624. await this.updateWebDavSongsAccountId(accountId);
  625. Logger.info(TAG, `WebDAV歌曲webdav_account_id修复完成`);
  626. } catch (error) {
  627. const err = error as Error;
  628. Logger.error(TAG, `修复WebDAV歌曲webdav_account_id失败: ${err.message}`);
  629. }
  630. }
  631. /**
  632. * 获取当前激活的WebDAV账号
  633. */
  634. private async getActiveWebDavAccount(): Promise<WebDavAccount | null> {
  635. try {
  636. const db = await this.dataBaseUtil.getDB();
  637. const predicates = new relationalStore.RdbPredicates(this.webDavTable);
  638. predicates.equalTo('isActivate', 1);
  639. predicates.limitAs(1);
  640. const resultSet = await db.query(predicates);
  641. if (resultSet.rowCount > 0) {
  642. resultSet.goToFirstRow();
  643. const account = new WebDavAccount();
  644. account.id = resultSet.getLong(resultSet.getColumnIndex('id'));
  645. account.name = resultSet.getString(resultSet.getColumnIndex('name'));
  646. account.host = resultSet.getString(resultSet.getColumnIndex('host'));
  647. account.localHost = resultSet.getString(resultSet.getColumnIndex('localHost'));
  648. account.isUseLocalHost = resultSet.getLong(resultSet.getColumnIndex('isUseLocalHost')) === 1;
  649. account.port = resultSet.getLong(resultSet.getColumnIndex('port'));
  650. account.filepath = resultSet.getString(resultSet.getColumnIndex('filepath'));
  651. account.imageFilePath = resultSet.getString(resultSet.getColumnIndex('imageFilePath'));
  652. account.lyricFilePath = resultSet.getString(resultSet.getColumnIndex('lyricFilePath'));
  653. account.uploadFilePath = resultSet.getString(resultSet.getColumnIndex('uploadFilePath'));
  654. account.account = resultSet.getString(resultSet.getColumnIndex('account'));
  655. account.password = resultSet.getString(resultSet.getColumnIndex('password'));
  656. account.enableHttps = resultSet.getLong(resultSet.getColumnIndex('enableHttps')) === 1;
  657. account.coverPath = resultSet.getString(resultSet.getColumnIndex('coverPath'));
  658. account.webType = resultSet.getLong(resultSet.getColumnIndex('webType'));
  659. const smbShareIndex = resultSet.getColumnIndex('smbShare');
  660. const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
  661. const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
  662. const jellyfinBaseIndex = resultSet.getColumnIndex('jellyfinBasePath');
  663. const baiduAccessIndex = resultSet.getColumnIndex('baiduAccessToken');
  664. const baiduRefreshIndex = resultSet.getColumnIndex('baiduRefreshToken');
  665. const baiduExpireIndex = resultSet.getColumnIndex('baiduTokenExpiresAt');
  666. if (smbShareIndex >= 0) {
  667. account.smbShare = resultSet.getString(smbShareIndex) ?? '';
  668. }
  669. if (smbDomainIndex >= 0) {
  670. account.smbDomain = resultSet.getString(smbDomainIndex) ?? '';
  671. }
  672. const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
  673. if (navBaseIndex >= 0) {
  674. const stored = resultSet.getString(navBaseIndex);
  675. account.navidromeBasePath = stored && stored.length > 0 ? stored : '/rest';
  676. } else {
  677. account.navidromeBasePath = '/rest';
  678. }
  679. if (ftpEncodingIndex >= 0) {
  680. const encodingValue = resultSet.getString(ftpEncodingIndex);
  681. account.ftpEncoding = encodingValue && encodingValue.length > 0 ? encodingValue : 'utf-8';
  682. } else {
  683. account.ftpEncoding = 'utf-8';
  684. }
  685. if (baiduAccessIndex >= 0) {
  686. account.baiduAccessToken = resultSet.getString(baiduAccessIndex) ?? '';
  687. }
  688. if (baiduRefreshIndex >= 0) {
  689. account.baiduRefreshToken = resultSet.getString(baiduRefreshIndex) ?? '';
  690. }
  691. if (baiduExpireIndex >= 0) {
  692. account.baiduTokenExpiresAt = resultSet.getLong(baiduExpireIndex);
  693. }
  694. account.isActivate = resultSet.getLong(resultSet.getColumnIndex('isActivate')) === 1;
  695. resultSet.close();
  696. return account;
  697. } else {
  698. resultSet.close();
  699. return null;
  700. }
  701. } catch (error) {
  702. const err = error as Error;
  703. Logger.error(TAG, `获取激活的WebDAV账号失败: ${err.message}`);
  704. return null;
  705. }
  706. }
  707. // 创建WebDAV账户表
  708. public createWebDavTableInDB(): Promise<void> {
  709. const createTableSql = `CREATE TABLE IF NOT EXISTS ${this.webDavTable} (
  710. id INTEGER PRIMARY KEY AUTOINCREMENT,
  711. name TEXT,
  712. isActivate INTEGER DEFAULT 1,
  713. host TEXT,
  714. localHost TEXT,
  715. isUseLocalHost INTEGER DEFAULT 0,
  716. port INTEGER,
  717. filepath TEXT,
  718. imageFilePath TEXT,
  719. lyricFilePath TEXT,
  720. uploadFilePath TEXT,
  721. account TEXT,
  722. password TEXT,
  723. enableHttps INTEGER DEFAULT 0,
  724. coverPath TEXT,
  725. webType INTEGER DEFAULT 0,
  726. smbShare TEXT,
  727. smbDomain TEXT,
  728. navidromeBasePath TEXT,
  729. jellyfinBasePath TEXT,
  730. embyBasePath TEXT,
  731. ftpEncoding TEXT
  732. )`;
  733. return this.dataBaseUtil.executeSql(createTableSql)
  734. .then(() => {
  735. Logger.info(TAG, 'WebDAV账户表创建成功');
  736. // 检查并添加新字段(用于数据库升级)
  737. return this.upgradeWebDavTable();
  738. })
  739. .catch((err: Error) => {
  740. Logger.error(TAG, '创建WebDAV账户表失败:', err.message);
  741. throw err;
  742. });
  743. }
  744. // 升级WebDAV表结构
  745. private async upgradeWebDavTable(): Promise<void> {
  746. try {
  747. // 直接尝试添加coverPath字段,如果字段已存在会失败但不影响应用运行
  748. Logger.info(TAG, '检查数据库表结构,尝试添加coverPath字段...');
  749. const addColumnSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN coverPath TEXT`;
  750. await this.dataBaseUtil.executeSql(addColumnSql);
  751. Logger.info(TAG, 'coverPath字段添加成功,数据库升级完成');
  752. } catch (error) {
  753. // 如果添加字段失败(可能字段已存在),记录但不阻止应用启动
  754. }
  755. try {
  756. // 添加webType字段,用于标识WebDAV账户类型
  757. Logger.info(TAG, '检查数据库表结构,尝试添加webType字段...');
  758. const addWebTypeColumnSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN webType INTEGER DEFAULT 0`;
  759. await this.dataBaseUtil.executeSql(addWebTypeColumnSql);
  760. Logger.info(TAG, 'webType字段添加成功,数据库升级完成');
  761. } catch (error) {
  762. // 如果添加字段失败(可能字段已存在),记录但不阻止应用启动
  763. }
  764. try {
  765. Logger.info(TAG, '尝试添加smbShare字段...');
  766. const addSmbShareSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN smbShare TEXT`;
  767. await this.dataBaseUtil.executeSql(addSmbShareSql);
  768. Logger.info(TAG, 'smbShare字段添加成功');
  769. } catch (error) {
  770. }
  771. try {
  772. Logger.info(TAG, '尝试添加smbDomain字段...');
  773. const addSmbDomainSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN smbDomain TEXT`;
  774. await this.dataBaseUtil.executeSql(addSmbDomainSql);
  775. Logger.info(TAG, 'smbDomain字段添加成功');
  776. } catch (error) {
  777. }
  778. try {
  779. Logger.info(TAG, '尝试添加navidromeBasePath字段...');
  780. const addNavBaseSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN navidromeBasePath TEXT`;
  781. await this.dataBaseUtil.executeSql(addNavBaseSql);
  782. Logger.info(TAG, 'navidromeBasePath字段添加成功');
  783. } catch (error) {
  784. }
  785. try {
  786. Logger.info(TAG, '尝试添加jellyfinBasePath字段...');
  787. const addJellyfinBaseSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN jellyfinBasePath TEXT`;
  788. await this.dataBaseUtil.executeSql(addJellyfinBaseSql);
  789. Logger.info(TAG, 'jellyfinBasePath字段添加成功');
  790. } catch (error) {
  791. }
  792. try {
  793. Logger.info(TAG, '尝试添加embyBasePath字段...');
  794. const addEmbyBaseSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN embyBasePath TEXT`;
  795. await this.dataBaseUtil.executeSql(addEmbyBaseSql);
  796. Logger.info(TAG, 'embyBasePath字段添加成功');
  797. } catch (error) {
  798. }
  799. try {
  800. Logger.info(TAG, '尝试添加ftpEncoding字段...');
  801. const addFtpEncodingSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN ftpEncoding TEXT`;
  802. await this.dataBaseUtil.executeSql(addFtpEncodingSql);
  803. Logger.info(TAG, 'ftpEncoding字段添加成功');
  804. } catch (error) {
  805. }
  806. try {
  807. Logger.info(TAG, '尝试添加百度授权字段...');
  808. await this.dataBaseUtil.executeSql(`ALTER TABLE ${this.webDavTable} ADD COLUMN baiduAccessToken TEXT`);
  809. await this.dataBaseUtil.executeSql(`ALTER TABLE ${this.webDavTable} ADD COLUMN baiduRefreshToken TEXT`);
  810. await this.dataBaseUtil.executeSql(`ALTER TABLE ${this.webDavTable} ADD COLUMN baiduTokenExpiresAt INTEGER`);
  811. Logger.info(TAG, '百度授权字段添加成功');
  812. } catch (error) {
  813. }
  814. try {
  815. Logger.info(TAG, '尝试添加sortOrder字段...');
  816. const addSortOrderSql = `ALTER TABLE ${this.webDavTable} ADD COLUMN sortOrder INTEGER DEFAULT 999999`;
  817. await this.dataBaseUtil.executeSql(addSortOrderSql);
  818. Logger.info(TAG, 'sortOrder字段添加成功');
  819. } catch (error) {
  820. }
  821. }
  822. // 从数据库查询所有账户
  823. public async queryWebDavAccountsFromDB(): Promise<void> {
  824. try {
  825. // 确保表结构是最新的
  826. await this.upgradeWebDavTable();
  827. const predicates = new relationalStore.RdbPredicates(this.webDavTable);
  828. // 按sortOrder升序排序,然后按id升序排序
  829. predicates.orderByAsc('sortOrder').orderByAsc('id');
  830. // 先查询基础字段(确保这些字段在旧版本中存在)
  831. const baseColumns = ['id', 'name', 'isActivate', 'host', 'localHost', 'isUseLocalHost',
  832. 'port', 'filepath', 'imageFilePath', 'lyricFilePath', 'uploadFilePath',
  833. 'account', 'password', 'enableHttps', 'coverPath', 'webType', 'smbShare', 'smbDomain', 'navidromeBasePath', 'jellyfinBasePath', 'embyBasePath', 'ftpEncoding',
  834. 'baiduAccessToken', 'baiduRefreshToken', 'baiduTokenExpiresAt', 'sortOrder'];
  835. const resultSet = await this.dataBaseUtil.queryData(this.webDavTable, baseColumns, predicates);
  836. this.webDavAccounts = [];
  837. while (resultSet.goToNextRow()) {
  838. const account = new WebDavAccount();
  839. account.id = resultSet.getLong(resultSet.getColumnIndex('id'));
  840. account.name = resultSet.getString(resultSet.getColumnIndex('name'));
  841. account.isActivate = resultSet.getLong(resultSet.getColumnIndex('isActivate')) === 1;
  842. account.host = resultSet.getString(resultSet.getColumnIndex('host'));
  843. account.localHost = resultSet.getString(resultSet.getColumnIndex('localHost'));
  844. account.isUseLocalHost = resultSet.getLong(resultSet.getColumnIndex('isUseLocalHost')) === 1;
  845. account.port = resultSet.getLong(resultSet.getColumnIndex('port'));
  846. account.filepath = resultSet.getString(resultSet.getColumnIndex('filepath'));
  847. account.imageFilePath = resultSet.getString(resultSet.getColumnIndex('imageFilePath'));
  848. account.lyricFilePath = resultSet.getString(resultSet.getColumnIndex('lyricFilePath'));
  849. account.uploadFilePath = resultSet.getString(resultSet.getColumnIndex('uploadFilePath'));
  850. account.account = resultSet.getString(resultSet.getColumnIndex('account'));
  851. account.password = resultSet.getString(resultSet.getColumnIndex('password'));
  852. account.enableHttps = resultSet.getLong(resultSet.getColumnIndex('enableHttps')) === 1;
  853. const coverPathIndex = resultSet.getColumnIndex('coverPath');
  854. if (coverPathIndex >= 0) {
  855. account.coverPath = resultSet.getString(coverPathIndex) ?? undefined;
  856. }
  857. const webTypeIndex = resultSet.getColumnIndex('webType');
  858. account.webType = webTypeIndex >= 0 ? resultSet.getLong(webTypeIndex) : 0;
  859. const smbShareIndex = resultSet.getColumnIndex('smbShare');
  860. if (smbShareIndex >= 0) {
  861. account.smbShare = resultSet.getString(smbShareIndex) ?? '';
  862. }
  863. const smbDomainIndex = resultSet.getColumnIndex('smbDomain');
  864. if (smbDomainIndex >= 0) {
  865. account.smbDomain = resultSet.getString(smbDomainIndex) ?? '';
  866. }
  867. const navBaseIndex = resultSet.getColumnIndex('navidromeBasePath');
  868. if (navBaseIndex >= 0) {
  869. const stored = resultSet.getString(navBaseIndex);
  870. account.navidromeBasePath = stored && stored.length > 0 ? stored : '/rest';
  871. } else {
  872. account.navidromeBasePath = '/rest';
  873. }
  874. const jellyfinBaseIndex = resultSet.getColumnIndex('jellyfinBasePath');
  875. if (jellyfinBaseIndex >= 0) {
  876. const stored = resultSet.getString(jellyfinBaseIndex);
  877. account.jellyfinBasePath = stored ?? '';
  878. } else {
  879. account.jellyfinBasePath = '';
  880. }
  881. const embyBaseIndex = resultSet.getColumnIndex('embyBasePath');
  882. if (embyBaseIndex >= 0) {
  883. const stored = resultSet.getString(embyBaseIndex);
  884. account.embyBasePath = stored ?? '';
  885. } else {
  886. account.embyBasePath = '';
  887. }
  888. const ftpEncodingIndex = resultSet.getColumnIndex('ftpEncoding');
  889. if (ftpEncodingIndex >= 0) {
  890. const encodingValue = resultSet.getString(ftpEncodingIndex);
  891. account.ftpEncoding = encodingValue && encodingValue.length > 0 ? encodingValue : 'utf-8';
  892. } else {
  893. account.ftpEncoding = 'utf-8';
  894. }
  895. const baiduAccessIndex = resultSet.getColumnIndex('baiduAccessToken');
  896. if (baiduAccessIndex >= 0) {
  897. account.baiduAccessToken = resultSet.getString(baiduAccessIndex) ?? '';
  898. }
  899. const baiduRefreshIndex = resultSet.getColumnIndex('baiduRefreshToken');
  900. if (baiduRefreshIndex >= 0) {
  901. account.baiduRefreshToken = resultSet.getString(baiduRefreshIndex) ?? '';
  902. }
  903. const baiduExpireIndex = resultSet.getColumnIndex('baiduTokenExpiresAt');
  904. if (baiduExpireIndex >= 0) {
  905. account.baiduTokenExpiresAt = resultSet.getLong(baiduExpireIndex);
  906. }
  907. const sortOrderIndex = resultSet.getColumnIndex('sortOrder');
  908. if (sortOrderIndex >= 0) {
  909. account.sortOrder = resultSet.getLong(sortOrderIndex);
  910. // 如果数据库中的值为NULL,使用默认值
  911. if (resultSet.isColumnNull(sortOrderIndex)) {
  912. account.sortOrder = 999999;
  913. }
  914. } else {
  915. account.sortOrder = 999999; // 默认值
  916. }
  917. //Lo Logger.info(TAG, `加载账户 ${account.name}, sortOrder: ${account.sortOrder}, id: ${account.id}`);
  918. this.webDavAccounts.push(account);
  919. }
  920. resultSet.close();
  921. // 打印排序信息
  922. this.webDavAccounts.sort((a, b) => a.sortOrder - b.sortOrder);
  923. Logger.info(TAG, '从数据库加载了', this.webDavAccounts.length.toString(), '个WebDAV账户');
  924. if (this.webDavAccounts.length > 0) {
  925. Logger.info(TAG, `第一个账户: ${this.webDavAccounts[0].name}, sortOrder: ${this.webDavAccounts[0].sortOrder}`);
  926. }
  927. this.notifyObservers(RemoteDriveManagerStates.QueryAccountsSucceed);
  928. } catch (err) {
  929. const error = err as Error;
  930. Logger.error(TAG, '查询WebDAV账户失败:', error.message);
  931. this.notifyObservers(RemoteDriveManagerStates.QueryAccountsFailed);
  932. throw error;
  933. }
  934. }
  935. // 插入新账户
  936. public async insertAccount(
  937. name: string,
  938. host: string,
  939. localHost: string,
  940. isUseLocalHost: boolean,
  941. port: number,
  942. filepath: string,
  943. lyricFilePath: string,
  944. uploadFilePath: string,
  945. imageFilePath: string,
  946. account: string,
  947. password: string,
  948. enableHttps: boolean,
  949. coverPath?: string,
  950. webType: number = 0,
  951. smbShare: string = '',
  952. smbDomain: string = '',
  953. navidromeBasePath: string = '/rest',
  954. jellyfinBasePath: string = '',
  955. embyBasePath: string = '',
  956. ftpEncoding: string = 'utf-8',
  957. baiduAccessToken: string = '',
  958. baiduRefreshToken: string = '',
  959. baiduTokenExpiresAt: number = 0,
  960. sortOrder: number = 999999
  961. ): Promise<void> {
  962. try {
  963. const values: relationalStore.ValuesBucket = {
  964. 'name': name,
  965. 'isActivate': 1,
  966. 'host': host,
  967. 'localHost': localHost,
  968. 'isUseLocalHost': isUseLocalHost ? 1 : 0,
  969. 'port': port,
  970. 'filepath': filepath,
  971. 'imageFilePath': imageFilePath,
  972. 'lyricFilePath': lyricFilePath,
  973. 'uploadFilePath': uploadFilePath,
  974. 'account': account,
  975. 'password': password,
  976. 'enableHttps': enableHttps ? 1 : 0,
  977. 'coverPath': coverPath || null,
  978. 'webType': webType,
  979. 'smbShare': smbShare,
  980. 'smbDomain': smbDomain,
  981. 'navidromeBasePath': navidromeBasePath,
  982. 'jellyfinBasePath': jellyfinBasePath,
  983. 'embyBasePath': embyBasePath,
  984. 'ftpEncoding': ftpEncoding,
  985. 'baiduAccessToken': baiduAccessToken,
  986. 'baiduRefreshToken': baiduRefreshToken,
  987. 'baiduTokenExpiresAt': baiduTokenExpiresAt,
  988. 'sortOrder': sortOrder
  989. };
  990. await this.dataBaseUtil.insertData(this.webDavTable, values);
  991. Logger.info(TAG, '插入WebDAV账户成功:', name);
  992. await this.queryWebDavAccountsFromDB();
  993. this.notifyObservers(RemoteDriveManagerStates.InsertAccountSucceed);
  994. } catch (err) {
  995. const error = err as Error;
  996. Logger.error(TAG, '插入WebDAV账户失败:', error.message);
  997. this.notifyObservers(RemoteDriveManagerStates.InsertAccountFailed);
  998. throw error;
  999. }
  1000. }
  1001. // 编辑账户
  1002. public async editAccount(account: WebDavAccount): Promise<void> {
  1003. try {
  1004. const values: relationalStore.ValuesBucket = {
  1005. 'name': account.name,
  1006. 'isActivate': account.isActivate ? 1 : 0,
  1007. 'host': account.host,
  1008. 'localHost': account.localHost,
  1009. 'isUseLocalHost': account.isUseLocalHost ? 1 : 0,
  1010. 'port': account.port,
  1011. 'filepath': account.filepath,
  1012. 'imageFilePath': account.imageFilePath,
  1013. 'lyricFilePath': account.lyricFilePath,
  1014. 'uploadFilePath': account.uploadFilePath,
  1015. 'account': account.account,
  1016. 'password': account.password,
  1017. 'enableHttps': account.enableHttps ? 1 : 0,
  1018. 'coverPath': account.coverPath || null,
  1019. 'webType': account.webType,
  1020. 'smbShare': account.smbShare,
  1021. 'smbDomain': account.smbDomain,
  1022. 'navidromeBasePath': account.navidromeBasePath,
  1023. 'jellyfinBasePath': account.jellyfinBasePath,
  1024. 'embyBasePath': account.embyBasePath,
  1025. 'ftpEncoding': account.ftpEncoding,
  1026. 'baiduAccessToken': account.baiduAccessToken,
  1027. 'baiduRefreshToken': account.baiduRefreshToken,
  1028. 'baiduTokenExpiresAt': account.baiduTokenExpiresAt,
  1029. 'sortOrder': account.sortOrder
  1030. };
  1031. const predicates = new relationalStore.RdbPredicates(this.webDavTable);
  1032. predicates.equalTo('id', account.id);
  1033. await this.dataBaseUtil.updateData(this.webDavTable, values, predicates);
  1034. Logger.info(TAG, '更新WebDAV账户成功:', account.name);
  1035. await this.queryWebDavAccountsFromDB();
  1036. this.notifyObservers(RemoteDriveManagerStates.EditAccountSucceed);
  1037. } catch (err) {
  1038. const error = err as Error;
  1039. Logger.error(TAG, '更新WebDAV账户失败:', error.message);
  1040. this.notifyObservers(RemoteDriveManagerStates.EditAccountFailed);
  1041. throw error;
  1042. }
  1043. }
  1044. // 删除账户
  1045. public async removeAccount(account: WebDavAccount): Promise<void> {
  1046. try {
  1047. const predicates = new relationalStore.RdbPredicates(this.webDavTable);
  1048. predicates.equalTo('id', account.id);
  1049. await this.dataBaseUtil.deleteData(predicates);
  1050. Logger.info(TAG, '删除WebDAV账户成功:', account.name);
  1051. await this.queryWebDavAccountsFromDB();
  1052. this.notifyObservers(RemoteDriveManagerStates.RemoveAccountSucceed);
  1053. } catch (err) {
  1054. const error = err as Error;
  1055. Logger.error(TAG, '删除WebDAV账户失败:', error.message);
  1056. this.notifyObservers(RemoteDriveManagerStates.RemoveAccountFailed);
  1057. throw error;
  1058. }
  1059. }
  1060. // 置顶账户
  1061. public async pinAccount(account: WebDavAccount): Promise<void> {
  1062. try {
  1063. // 获取当前所有账户中最小的sortOrder值
  1064. const allAccounts = this.getAllWebDavAccounts();
  1065. if (allAccounts.length === 0) {
  1066. return;
  1067. }
  1068. // 找到最小的sortOrder值
  1069. let minSortOrder = allAccounts[0].sortOrder;
  1070. for (let i = 1; i < allAccounts.length; i++) {
  1071. if (allAccounts[i].sortOrder < minSortOrder) {
  1072. minSortOrder = allAccounts[i].sortOrder;
  1073. }
  1074. }
  1075. Logger.info(TAG, `当前最小sortOrder: ${minSortOrder}, 要置顶的账户sortOrder: ${account.sortOrder}, 账户名: ${account.name}`);
  1076. // 如果账户已经是最小的,则不需要置顶
  1077. if (account.sortOrder === minSortOrder && minSortOrder !== 999999) {
  1078. Logger.info(TAG, '账户已经是第一个:', account.name);
  1079. return;
  1080. }
  1081. // 将账户的sortOrder设置为比最小值更小的值
  1082. account.sortOrder = minSortOrder - 1;
  1083. Logger.info(TAG, `设置账户 ${account.name} 的sortOrder为: ${account.sortOrder}`);
  1084. // 更新数据库
  1085. await this.editAccount(account);
  1086. Logger.info(TAG, '置顶WebDAV账户成功:', account.name);
  1087. } catch (err) {
  1088. const error = err as Error;
  1089. Logger.error(TAG, '置顶WebDAV账户失败:', error.message);
  1090. throw error;
  1091. }
  1092. }
  1093. // 获取所有账户
  1094. public getAllWebDavAccounts(): WebDavAccount[] {
  1095. return this.webDavAccounts;
  1096. }
  1097. // 获取激活的账户
  1098. public getActivatedWebDavAccount(): WebDavAccount | null {
  1099. if(this.currentAccount)
  1100. return this.currentAccount;
  1101. for (let i = 0; i < this.webDavAccounts.length; i++) {
  1102. const account = this.webDavAccounts[i];
  1103. if (account.isActivate) {
  1104. return account;
  1105. }
  1106. }
  1107. return null;
  1108. }
  1109. // ==================== 文件操作 ====================
  1110. // 从WebDAV加载文件列表
  1111. public async loadFilesInfoFromWebdav(customPath?: string): Promise<void> {
  1112. const account = await this.getActiveWebDavAccount();
  1113. if(account){
  1114. console.info('heanup 2'+account.name+'type '+account.webType)
  1115. }
  1116. if (!account) {
  1117. Logger.error(TAG, '没有激活的WebDAV账户');
  1118. this.notifyObservers(RemoteDriveManagerStates.LoadFilesInfoFailed);
  1119. return;
  1120. }
  1121. await this.loadFilesInfoFromAccount(account, customPath);
  1122. }
  1123. public async loadFilesInfoFromAccount(account: WebDavAccount,customPath?: string): Promise<void> {
  1124. this.currentAccount = account;
  1125. if (this.lastAccountId !== account.id) {
  1126. this.pathHistory = [];
  1127. this.pathDisplayNames.clear();
  1128. this.registerPathLabel('/', '根目录');
  1129. }
  1130. this.lastAccountId = account.id ?? null;
  1131. // 更新现有WebDAV歌曲的webdav_account_id字段
  1132. if (account && account.id) {
  1133. await this.updateWebDavSongsAccountId(account.id.toString());
  1134. }
  1135. try {
  1136. this.notifyObservers(RemoteDriveManagerStates.LoadFilesInfoStart);
  1137. // 使用自定义路径或账户默认路径
  1138. const normalizedFullPath = this.normalizeFullPath(customPath !== undefined ? customPath : account.filepath);
  1139. this.currentPath = normalizedFullPath;
  1140. if (account.webType === RemoteDriveType.Smb) {
  1141. await this.loadSmbFiles(account, normalizedFullPath);
  1142. } else if (account.webType === RemoteDriveType.Navidrome) {
  1143. await this.loadNavidromeFiles(account, normalizedFullPath);
  1144. } else if (account.webType === RemoteDriveType.Jellyfin) {
  1145. await this.loadJellyfinFiles(account, normalizedFullPath);
  1146. } else if (account.webType === RemoteDriveType.Emby) {
  1147. await this.loadEmbyFiles(account, normalizedFullPath);
  1148. } else if (account.webType === RemoteDriveType.Ftp) {
  1149. await this.loadFtpFiles(account, normalizedFullPath);
  1150. } else if (account.webType === RemoteDriveType.Baidu) {
  1151. await this.loadBaiduFiles(account, normalizedFullPath);
  1152. } else {
  1153. await this.loadWebDavFiles(account, normalizedFullPath);
  1154. }
  1155. this.notifyObservers(RemoteDriveManagerStates.LoadFilesInfoSucceed);
  1156. } catch (error) {
  1157. this.ErrorMessage = error as BusinessError;
  1158. this.notifyObservers(RemoteDriveManagerStates.LoadFilesInfoFailed);
  1159. }
  1160. }
  1161. private normalizeFullPath(path?: string): string {
  1162. if (!path || path.trim().length === 0) {
  1163. return '/';
  1164. }
  1165. let normalized = path.trim().replace(/\\/g, '/');
  1166. if (!normalized.startsWith('/')) {
  1167. normalized = `/${normalized}`;
  1168. }
  1169. normalized = normalized.replace(/\/+/g, '/');
  1170. if (normalized.length > 1 && normalized.endsWith('/')) {
  1171. normalized = normalized.slice(0, -1);
  1172. }
  1173. return normalized || '/';
  1174. }
  1175. private getSongKey(song: VideoItem): string {
  1176. if (!song) {
  1177. return '';
  1178. }
  1179. if (song.filePath && song.filePath.length > 0) {
  1180. return song.filePath;
  1181. }
  1182. if (song.name && song.name.length > 0) {
  1183. return song.name;
  1184. }
  1185. return '';
  1186. }
  1187. private sanitizeRelativeUploadPath(path?: string): string {
  1188. if (!path || path.length === 0) {
  1189. return '';
  1190. }
  1191. let sanitized = path.replace(/\\/g, '/');
  1192. sanitized = sanitized.replace(/^\/+/, '');
  1193. sanitized = sanitized.replace(/\/+/g, '/');
  1194. return sanitized;
  1195. }
  1196. private combineRemoteUploadPath(basePath: string, relativePath: string): string {
  1197. const normalizedBase = this.normalizeFullPath(basePath);
  1198. const cleanedRelative = this.sanitizeRelativeUploadPath(relativePath);
  1199. if (!cleanedRelative || cleanedRelative.length === 0) {
  1200. return normalizedBase;
  1201. }
  1202. if (normalizedBase === '/') {
  1203. return this.normalizeFullPath(`/${cleanedRelative}`);
  1204. }
  1205. return this.normalizeFullPath(`${normalizedBase}/${cleanedRelative}`);
  1206. }
  1207. private getDirectoryFromRemotePath(remotePath: string): string {
  1208. if (!remotePath || remotePath === '/') {
  1209. return '/';
  1210. }
  1211. const normalized = this.normalizeFullPath(remotePath);
  1212. const lastSlash = normalized.lastIndexOf('/');
  1213. if (lastSlash <= 0) {
  1214. return '/';
  1215. }
  1216. return normalized.substring(0, lastSlash);
  1217. }
  1218. private async ensureRemoteDirectories(account: WebDavAccount, directoryPath: string): Promise<void> {
  1219. if (!directoryPath || directoryPath === '/') {
  1220. return;
  1221. }
  1222. const normalizedDir = this.normalizeFullPath(directoryPath);
  1223. if (normalizedDir === '/') {
  1224. return;
  1225. }
  1226. const segments = normalizedDir.split('/').filter(segment => segment.length > 0);
  1227. if (segments.length === 0) {
  1228. return;
  1229. }
  1230. let currentPath = '';
  1231. for (let i = 0; i < segments.length; i++) {
  1232. currentPath = `${currentPath}/${segments[i]}`;
  1233. const normalizedCurrent = this.normalizeFullPath(currentPath);
  1234. await this.ensureSingleRemoteDirectory(account, normalizedCurrent);
  1235. }
  1236. }
  1237. private async ensureSingleRemoteDirectory(account: WebDavAccount, directoryPath: string): Promise<void> {
  1238. try {
  1239. await this.rcpSocket.getFileList(
  1240. account.host,
  1241. account.localHost,
  1242. account.isUseLocalHost,
  1243. account.port,
  1244. directoryPath,
  1245. account.account,
  1246. account.password,
  1247. account.enableHttps
  1248. );
  1249. } catch (_error) {
  1250. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  1251. try {
  1252. await this.rcpSocket.createDirectory(
  1253. host,
  1254. account.port,
  1255. account.account,
  1256. account.password,
  1257. directoryPath,
  1258. account.enableHttps
  1259. );
  1260. Logger.info(TAG, `创建远程目录: ${directoryPath}`);
  1261. } catch (createErr) {
  1262. const err = createErr as Error;
  1263. Logger.error(TAG, `创建远程目录失败 (${directoryPath}): ${err.message}`);
  1264. throw err;
  1265. }
  1266. }
  1267. }
  1268. // 将 WebDAV 响应中的 href 统一转换为以 / 开头的相对路径
  1269. private normalizeRemoteHref(rawHref: string): string {
  1270. if (!rawHref || rawHref.trim().length === 0) {
  1271. return '/';
  1272. }
  1273. const trimmed = rawHref.trim();
  1274. const relativeFromHttp = WebDavUrlUtil.extractRelativePath(trimmed);
  1275. if (relativeFromHttp) {
  1276. return this.normalizeFullPath(relativeFromHttp);
  1277. }
  1278. if (trimmed.startsWith('//')) {
  1279. const hostEnd = trimmed.indexOf('/', 2);
  1280. if (hostEnd > 1) {
  1281. return this.normalizeFullPath(trimmed.substring(hostEnd));
  1282. }
  1283. return '/';
  1284. }
  1285. return this.normalizeFullPath(trimmed);
  1286. }
  1287. private toRelativePath(fullPath: string): string {
  1288. if (!fullPath || fullPath === '/') {
  1289. return '';
  1290. }
  1291. return fullPath.replace(/^\/+/, '');
  1292. }
  1293. private async loadWebDavFiles(account: WebDavAccount, fullPath: string): Promise<void> {
  1294. const files = await this.rcpSocket.getFileList(
  1295. account.host,
  1296. account.localHost,
  1297. account.isUseLocalHost,
  1298. account.port,
  1299. fullPath,
  1300. account.account,
  1301. account.password,
  1302. account.enableHttps
  1303. );
  1304. this.webDavFiles = files;
  1305. this.webDavSongs = [];
  1306. for (let i = 0; i < files.length; i++) {
  1307. const file = files[i];
  1308. if (!file.isDirectory && this.isAudioFile(file.fileName)) {
  1309. this.webDavSongs.push(this.fileInfoToVideoItem(file, account));
  1310. }
  1311. }
  1312. await this.enrichSongsWithDatabase(this.webDavSongs);
  1313. Logger.info(TAG, `从WebDAV获取到 ${files.length} 个文件/文件夹`);
  1314. }
  1315. private async loadSmbFiles(account: WebDavAccount, fullPath: string): Promise<void> {
  1316. if (!account.smbShare) {
  1317. throw new Error('SMB账户缺少共享名称');
  1318. }
  1319. const relativePath = this.toRelativePath(fullPath);
  1320. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  1321. const entries = await listSmbDirectory({
  1322. host,
  1323. share: account.smbShare,
  1324. username: account.account,
  1325. password: account.password,
  1326. domain: account.smbDomain,
  1327. path: relativePath
  1328. });
  1329. this.webDavFiles = [];
  1330. this.webDavSongs = [];
  1331. for (let i = 0; i < entries.length; i++) {
  1332. const entry = entries[i];
  1333. const fileInfo = this.smbEntryToFileInfo(entry, relativePath);
  1334. this.webDavFiles.push(fileInfo);
  1335. if (!entry.isDirectory && this.isAudioFile(entry.name)) {
  1336. this.webDavSongs.push(this.fileInfoToVideoItem(fileInfo, account));
  1337. }
  1338. }
  1339. await this.enrichSongsWithDatabase(this.webDavSongs);
  1340. Logger.info(TAG, `从SMB获取到 ${entries.length} 个文件/文件夹`);
  1341. }
  1342. private resolveFtpHost(account: WebDavAccount): string {
  1343. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  1344. return host?.trim() ?? '';
  1345. }
  1346. private getFtpEncoding(account: WebDavAccount): StringEncoding {
  1347. if (account.ftpEncoding && account.ftpEncoding.length > 0) {
  1348. return account.ftpEncoding as StringEncoding;
  1349. }
  1350. return 'utf-8';
  1351. }
  1352. private buildFtpAccessOptions(account: WebDavAccount): AccessOptions {
  1353. const host = this.resolveFtpHost(account);
  1354. if (!host) {
  1355. throw new Error('FTP账户缺少服务器地址');
  1356. }
  1357. return {
  1358. host,
  1359. port: account.port && account.port > 0 ? account.port : 21,
  1360. user: account.account && account.account.length > 0 ? account.account : undefined,
  1361. password: account.password && account.password.length > 0 ? account.password : undefined,
  1362. encoding: this.getFtpEncoding(account)
  1363. };
  1364. }
  1365. private async loadFtpFiles(account: WebDavAccount, fullPath: string): Promise<void> {
  1366. const client = new FtpClient();
  1367. const options = this.buildFtpAccessOptions(account);
  1368. await client.access(options);
  1369. try {
  1370. const listPath = this.normalizeFullPath(fullPath);
  1371. const entries = await client.list(listPath === '/' ? '/' : listPath);
  1372. this.webDavFiles = [];
  1373. this.webDavSongs = [];
  1374. for (let i = 0; i < entries.length; i++) {
  1375. const entry = entries[i];
  1376. const info = this.ftpEntryToFileInfo(entry, listPath);
  1377. this.webDavFiles.push(info);
  1378. if (info.isDirectory) {
  1379. this.registerPathLabel(info.href, info.fileName);
  1380. } else if (this.isAudioFile(info.fileName)) {
  1381. this.webDavSongs.push(this.buildFtpVideoItem(info, account));
  1382. }
  1383. }
  1384. await this.enrichSongsWithDatabase(this.webDavSongs);
  1385. Logger.info(TAG, `从FTP获取到 ${entries.length} 个文件/文件夹`);
  1386. } finally {
  1387. await client.close();
  1388. }
  1389. }
  1390. private async loadBaiduFiles(account: WebDavAccount, fullPath: string): Promise<void> {
  1391. const accessToken = await this.ensureBaiduAccessToken(account);
  1392. const directory = fullPath && fullPath.length > 0 ? fullPath : '/';
  1393. const entries = await listBaiduDirectory(accessToken, directory);
  1394. this.webDavFiles = [];
  1395. this.webDavSongs = [];
  1396. for (let i = 0; i < entries.length; i++) {
  1397. const entry = entries[i];
  1398. const info = this.baiduEntryToFileInfo(entry);
  1399. this.webDavFiles.push(info);
  1400. if (info.isDirectory) {
  1401. this.registerPathLabel(info.href, info.fileName);
  1402. } else if (this.isAudioFile(info.fileName)) {
  1403. this.webDavSongs.push(this.buildBaiduVideoItem(entry, account));
  1404. }
  1405. }
  1406. await this.enrichSongsWithDatabase(this.webDavSongs);
  1407. Logger.info(TAG, `从百度网盘获取到 ${entries.length} 个文件/文件夹`);
  1408. this.scheduleBaiduPrefetch(account, accessToken, entries);
  1409. Logger.info(TAG, `百度预取任务已调度, account=${account.id}, 文件数=${entries.length}`);
  1410. }
  1411. public async deleteRemoteSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
  1412. if (!account) {
  1413. throw new Error('请先选择账户');
  1414. }
  1415. if (!songs || songs.length === 0) {
  1416. return;
  1417. }
  1418. const deletable = songs.filter(item => item && item.filePath && item.filePath.length > 0);
  1419. if (deletable.length === 0) {
  1420. return;
  1421. }
  1422. if (account.webType === RemoteDriveType.WebDav) {
  1423. await this.deleteWebDavSongs(account, deletable);
  1424. } else if (account.webType === RemoteDriveType.Smb) {
  1425. await this.deleteSmbSongs(account, deletable);
  1426. } else if (account.webType === RemoteDriveType.Ftp) {
  1427. await this.deleteFtpSongs(account, deletable);
  1428. } else if (account.webType === RemoteDriveType.Baidu) {
  1429. await this.deleteBaiduSongs(account, deletable);
  1430. } else {
  1431. throw new Error('当前账户类型暂不支持删除');
  1432. }
  1433. }
  1434. private resolveWebDavDeletePath(song: VideoItem): string {
  1435. if (song.remote_rel_path && song.remote_rel_path.length > 0) {
  1436. return this.normalizeRemoteHref(song.remote_rel_path);
  1437. }
  1438. if (song.filePath) {
  1439. const relative = WebDavUrlUtil.extractRelativePath(song.filePath);
  1440. if (relative) {
  1441. return this.normalizeRemoteHref(relative);
  1442. }
  1443. }
  1444. throw new Error(`无法确定 WebDAV 文件路径: ${song.name}`);
  1445. }
  1446. private async deleteWebDavSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
  1447. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  1448. if (!host || host.length === 0) {
  1449. throw new Error('WebDAV账户缺少服务器地址');
  1450. }
  1451. for (let i = 0; i < songs.length; i++) {
  1452. const song = songs[i];
  1453. const relativePath = this.resolveWebDavDeletePath(song);
  1454. try {
  1455. await this.rcpSocket.RcpSendDelete(
  1456. host,
  1457. account.port,
  1458. account.account,
  1459. account.password,
  1460. relativePath,
  1461. account.enableHttps
  1462. );
  1463. } catch (error) {
  1464. const err = error as Error;
  1465. throw new Error(`删除 ${song.name} 失败: ${err.message}`);
  1466. }
  1467. }
  1468. }
  1469. private resolveSmbSongPath(song: VideoItem, account: WebDavAccount): string {
  1470. if (song.remote_rel_path && song.remote_rel_path.length > 0) {
  1471. return this.normalizeSmbRelativePath(song.remote_rel_path, account.smbShare);
  1472. }
  1473. if (song.filePath) {
  1474. const withoutScheme = song.filePath.replace(/^smb:\/\//i, '').replace(/^[^/]+/i, '');
  1475. return this.normalizeSmbRelativePath(withoutScheme, account.smbShare);
  1476. }
  1477. throw new Error(`无法确定SMB路径: ${song.name}`);
  1478. }
  1479. private async deleteSmbSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
  1480. if (!account.smbShare || account.smbShare.length === 0) {
  1481. throw new Error('SMB账户缺少共享名称');
  1482. }
  1483. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  1484. if (!host || host.length === 0) {
  1485. throw new Error('SMB账户缺少服务器地址');
  1486. }
  1487. for (let i = 0; i < songs.length; i++) {
  1488. const song = songs[i];
  1489. const relativePath = this.resolveSmbSongPath(song, account);
  1490. if (!relativePath || relativePath.length === 0 || relativePath === '/') {
  1491. throw new Error(`无法确定SMB文件路径: ${song.name}`);
  1492. }
  1493. try {
  1494. await deleteSmbEntry({
  1495. host,
  1496. share: account.smbShare,
  1497. username: account.account,
  1498. password: account.password,
  1499. domain: account.smbDomain,
  1500. path: relativePath,
  1501. isDirectory: false
  1502. });
  1503. } catch (error) {
  1504. const err = error as Error;
  1505. throw new Error(`删除 ${song.name} 失败: ${err.message}`);
  1506. }
  1507. }
  1508. }
  1509. private resolveFtpSongPath(song: VideoItem): string {
  1510. const target = song.remote_rel_path && song.remote_rel_path.length > 0 ? song.remote_rel_path : song.filePath;
  1511. if (!target || target.length === 0) {
  1512. throw new Error(`无法确定FTP路径: ${song.name}`);
  1513. }
  1514. return this.normalizeFullPath(target);
  1515. }
  1516. private async deleteFtpSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
  1517. const options = this.buildFtpAccessOptions(account);
  1518. const client = new FtpClient();
  1519. try {
  1520. await client.access(options);
  1521. for (let i = 0; i < songs.length; i++) {
  1522. const song = songs[i];
  1523. const remotePath = this.resolveFtpSongPath(song);
  1524. const success = await client.remove(remotePath);
  1525. if (!success) {
  1526. throw new Error(`删除 ${song.name} 失败`);
  1527. }
  1528. }
  1529. } finally {
  1530. await client.close();
  1531. }
  1532. }
  1533. private async deleteBaiduSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
  1534. const accessToken = await this.ensureBaiduAccessToken(account);
  1535. const paths: string[] = [];
  1536. for (let i = 0; i < songs.length; i++) {
  1537. const path = songs[i].remote_rel_path;
  1538. if (!path || path.length === 0) {
  1539. throw new Error(`无法确定百度网盘路径: ${songs[i].name}`);
  1540. }
  1541. paths.push(path);
  1542. }
  1543. const batchSize = 20;
  1544. for (let i = 0; i < paths.length; i += batchSize) {
  1545. const batch = paths.slice(i, i + batchSize);
  1546. try {
  1547. await deleteBaiduFiles(accessToken, batch);
  1548. } catch (error) {
  1549. const err = error as Error;
  1550. throw new Error(`删除文件失败: ${err.message}`);
  1551. }
  1552. }
  1553. }
  1554. private scheduleBaiduPrefetch(account: WebDavAccount, accessToken: string, entries: BaiduListEntry[]): void {
  1555. const fileEntries = entries.filter(entry => entry.isdir === 0 && this.isAudioFile(entry.server_filename));
  1556. if (fileEntries.length === 0) {
  1557. return;
  1558. }
  1559. const fsIds = fileEntries.map(entry => entry.fs_id.toString());
  1560. const chunkSize = 50;
  1561. const prefetch = async () => {
  1562. Logger.info(TAG, `开始预取百度dlink,文件数: ${fsIds.length}`);
  1563. Logger.info(TAG, `Baidu dlink prefetch start: account=${account.id} files=${fsIds.length}`);
  1564. for (let i = 0; i < fsIds.length; i += chunkSize) {
  1565. const chunk = fsIds.slice(i, i + chunkSize);
  1566. try {
  1567. const metas = await fetchBaiduFileMetas(accessToken, chunk);
  1568. metas.forEach((meta: BaiduFileMeta) => {
  1569. if (!meta.dlink) {
  1570. return;
  1571. }
  1572. const rawLink = meta.dlink.startsWith('https://') ? meta.dlink : meta.dlink.replace('http://', 'https://');
  1573. const finalUrl = appendAccessTokenToDlink(rawLink, accessToken);
  1574. this.cacheBaiduDlink(account, meta.fs_id.toString(), accessToken, finalUrl);
  1575. });
  1576. Logger.info(TAG, `Baidu dlink prefetch chunk success account=${account.id} size=${chunk.length}`);
  1577. } catch (error) {
  1578. const err = error as Error;
  1579. Logger.warn(TAG, `百度dlink预取失败: ${err.message}`);
  1580. break;
  1581. }
  1582. }
  1583. Logger.info(TAG, `Baidu dlink prefetch finished account=${account.id}`);
  1584. };
  1585. void prefetch();
  1586. }
  1587. private async loadNavidromeFiles(account: WebDavAccount, fullPath: string): Promise<void> {
  1588. const normalized = this.normalizeFullPath(fullPath);
  1589. this.registerPathLabel('/', '根目录');
  1590. const segments = normalized.split('/').filter(part => part.length > 0);
  1591. void ServerLogUtil.info(TAG, `浏览 Navidrome 路径: ${normalized} account=${ServerLogUtil.sanitizeAccount(account)}`);
  1592. if (normalized === '/' || segments.length === 0) {
  1593. const artists: NavidromeArtist[] = await this.navidromeApi.getArtists(account);
  1594. artists.sort((a, b) => a.name.localeCompare(b.name));
  1595. this.webDavFiles = artists.map(artist => {
  1596. const info = this.createNavDirectory(artist.name, `/artist/${artist.id}`);
  1597. this.registerPathLabel(info.href, artist.name);
  1598. return info;
  1599. });
  1600. this.webDavSongs = [];
  1601. void ServerLogUtil.info(TAG, `获取艺术家列表,数量: ${artists.length}`);
  1602. void ServerLogUtil.debug(TAG, `artists ids: ${artists.map(a => a.id).join(',')}`)
  1603. return;
  1604. }
  1605. if (segments[0] === 'artist' && segments[1]) {
  1606. const artistId = segments[1];
  1607. const detail: NavidromeArtistDetail = await this.navidromeApi.getArtist(account, artistId);
  1608. this.registerPathLabel(`/artist/${artistId}`, detail.name);
  1609. this.webDavFiles = detail.albums.map(album => {
  1610. const info = this.createNavDirectory(album.name, `/album/${album.id}`);
  1611. this.registerPathLabel(info.href, album.name);
  1612. return info;
  1613. });
  1614. this.webDavSongs = [];
  1615. void ServerLogUtil.info(TAG, `进入艺术家 ${detail.name},专辑数: ${detail.albums.length}`);
  1616. void ServerLogUtil.debug(TAG, `artist albums: ${detail.albums.map(a => a.id).join(',')}`)
  1617. return;
  1618. }
  1619. if (segments[0] === 'album' && segments[1]) {
  1620. const albumId = segments[1];
  1621. const detail: NavidromeAlbumDetail = await this.navidromeApi.getAlbum(account, albumId);
  1622. this.registerPathLabel(`/album/${albumId}`, detail.name);
  1623. this.webDavFiles = detail.songs.map(song => this.createNavSongFileInfo(song, albumId));
  1624. const albumCoverUrl = await this.navidromeApi.buildCoverArtUrl(account,
  1625. detail.coverArt ?? (detail.id ? `al-${detail.id}` : undefined));
  1626. this.webDavSongs = detail.songs.map(song => this.buildNavidromeVideoItem(song, account, detail, albumCoverUrl));
  1627. await this.enrichSongsWithDatabase(this.webDavSongs);
  1628. void ServerLogUtil.info(TAG, `进入专辑 ${detail.name},歌曲数: ${detail.songs.length}`);
  1629. void ServerLogUtil.debug(TAG, `album songs ids: ${detail.songs.map(s => s.id).join(',')}`)
  1630. return;
  1631. }
  1632. throw new Error(`不支持的Navidrome路径: ${fullPath}`);
  1633. }
  1634. private async loadJellyfinFiles(account: WebDavAccount, fullPath: string): Promise<void> {
  1635. const normalized = this.normalizeFullPath(fullPath);
  1636. this.registerPathLabel('/', '根目录');
  1637. const segments = normalized.split('/').filter(part => part.length > 0);
  1638. void ServerLogUtil.info(TAG, `浏览 Jellyfin 路径: ${normalized} account=${ServerLogUtil.sanitizeAccount(account)}`);
  1639. if (normalized === '/' || segments.length === 0) {
  1640. const songs = await this.jellyfinApi.getAllSongs(account);
  1641. this.webDavFiles = [];
  1642. this.webDavSongs = songs.map(song => this.buildJellyfinVideoItem(song, account, null, undefined));
  1643. await this.enrichSongsWithDatabase(this.webDavSongs);
  1644. void ServerLogUtil.info(TAG, `进入 Jellyfin 根目录,歌曲数: ${songs.length}`);
  1645. return;
  1646. }
  1647. if (segments[0] === 'artist' && segments[1]) {
  1648. const artistId = segments[1];
  1649. const albums: JellyfinAlbum[] = await this.jellyfinApi.getArtistAlbums(account, artistId);
  1650. const artistLabel = this.pathDisplayNames.get(`/artist/${artistId}`) ?? (albums[0]?.artist ?? '艺术家');
  1651. if (artistLabel) {
  1652. this.registerPathLabel(`/artist/${artistId}`, artistLabel);
  1653. }
  1654. this.webDavFiles = albums.map(album => {
  1655. const info = this.createJellyfinDirectory(album.name, `/album/${album.id}`);
  1656. this.registerPathLabel(info.href, album.name);
  1657. return info;
  1658. });
  1659. this.webDavSongs = [];
  1660. void ServerLogUtil.info(TAG, `进入 Jellyfin 艺术家 ${artistLabel},专辑数: ${albums.length}`);
  1661. return;
  1662. }
  1663. if (segments[0] === 'album' && segments[1]) {
  1664. const albumId = segments[1];
  1665. const album = await this.jellyfinApi.getAlbum(account, albumId);
  1666. const albumName = album?.name ?? this.pathDisplayNames.get(`/album/${albumId}`) ?? '专辑';
  1667. this.registerPathLabel(`/album/${albumId}`, albumName);
  1668. const songs = await this.jellyfinApi.getAlbumSongs(account, albumId);
  1669. let coverUrl: string | undefined = undefined;
  1670. try {
  1671. coverUrl = await this.jellyfinApi.buildPrimaryImageUrl(account, album?.id ?? albumId);
  1672. } catch (error) {
  1673. Logger.warn(TAG, `Jellyfin 专辑封面获取失败: ${(error as Error).message}`);
  1674. }
  1675. this.webDavFiles = songs.map(song => this.createJellyfinSongFileInfo(song, albumId));
  1676. this.webDavSongs = songs.map(song => this.buildJellyfinVideoItem(song, account, album, coverUrl));
  1677. await this.enrichSongsWithDatabase(this.webDavSongs);
  1678. void ServerLogUtil.info(TAG, `进入 Jellyfin 专辑 ${albumName},歌曲数: ${songs.length}`);
  1679. return;
  1680. }
  1681. throw new Error(`不支持的Jellyfin路径: ${fullPath}`);
  1682. }
  1683. private async loadEmbyFiles(account: WebDavAccount, fullPath: string): Promise<void> {
  1684. const normalized = this.normalizeFullPath(fullPath);
  1685. this.registerPathLabel('/', '根目录');
  1686. const segments = normalized.split('/').filter(part => part.length > 0);
  1687. void ServerLogUtil.info(TAG, `浏览 Emby 路径: ${normalized} account=${ServerLogUtil.sanitizeAccount(account)}`);
  1688. if (normalized === '/' || segments.length === 0) {
  1689. const songs = await this.embyApi.getAllSongs(account);
  1690. this.webDavFiles = [];
  1691. this.webDavSongs = songs.map(song => this.buildEmbyVideoItem(song, account, null, undefined));
  1692. await this.enrichSongsWithDatabase(this.webDavSongs);
  1693. void ServerLogUtil.info(TAG, `进入 Emby 根目录,歌曲数: ${songs.length}`);
  1694. return;
  1695. }
  1696. if (segments[0] === 'artist' && segments[1]) {
  1697. const artistId = segments[1];
  1698. const albums: EmbyAlbum[] = await this.embyApi.getArtistAlbums(account, artistId);
  1699. const artistLabel = this.pathDisplayNames.get(`/artist/${artistId}`) ?? (albums[0]?.artist ?? '艺术家');
  1700. if (artistLabel) {
  1701. this.registerPathLabel(`/artist/${artistId}`, artistLabel);
  1702. }
  1703. this.webDavFiles = albums.map(album => {
  1704. const info = this.createEmbyDirectory(album.name, `/album/${album.id}`);
  1705. this.registerPathLabel(info.href, album.name);
  1706. return info;
  1707. });
  1708. this.webDavSongs = [];
  1709. void ServerLogUtil.info(TAG, `进入 Emby 艺术家 ${artistLabel},专辑数: ${albums.length}`);
  1710. return;
  1711. }
  1712. if (segments[0] === 'album' && segments[1]) {
  1713. const albumId = segments[1];
  1714. const album = await this.embyApi.getAlbum(account, albumId);
  1715. const albumName = album?.name ?? this.pathDisplayNames.get(`/album/${albumId}`) ?? '专辑';
  1716. this.registerPathLabel(`/album/${albumId}`, albumName);
  1717. const songs = await this.embyApi.getAlbumSongs(account, albumId);
  1718. let coverUrl: string | undefined = undefined;
  1719. try {
  1720. coverUrl = await this.embyApi.buildPrimaryImageUrl(account, album?.id ?? albumId);
  1721. } catch (error) {
  1722. Logger.warn(TAG, `Emby 专辑封面获取失败: ${(error as Error).message}`);
  1723. }
  1724. this.webDavFiles = songs.map(song => this.createEmbySongFileInfo(song, albumId));
  1725. this.webDavSongs = songs.map(song => this.buildEmbyVideoItem(song, account, album, coverUrl));
  1726. await this.enrichSongsWithDatabase(this.webDavSongs);
  1727. void ServerLogUtil.info(TAG, `进入 Emby 专辑 ${albumName},歌曲数: ${songs.length}`);
  1728. return;
  1729. }
  1730. throw new Error(`不支持的Emby路径: ${fullPath}`);
  1731. }
  1732. private async enrichSongsWithDatabase(videoItems: VideoItem[]): Promise<void> {
  1733. if (!this.context || !videoItems || videoItems.length === 0) {
  1734. return;
  1735. }
  1736. try {
  1737. const mediaTable = new MediaTable(this.context);
  1738. await new Promise<void>((resolve) => {
  1739. mediaTable.getRdbStore(this.context as common.Context, () => resolve());
  1740. });
  1741. const pathMap: Map<string, VideoItem[]> = new Map();
  1742. for (let i = 0; i < videoItems.length; i++) {
  1743. const item = videoItems[i];
  1744. const storagePath = this.resolveStoragePathForItem(item);
  1745. if (!storagePath) {
  1746. continue;
  1747. }
  1748. if (!pathMap.has(storagePath)) {
  1749. pathMap.set(storagePath, []);
  1750. }
  1751. pathMap.get(storagePath)?.push(item);
  1752. }
  1753. if (pathMap.size === 0) {
  1754. return;
  1755. }
  1756. const tasks: Promise<void>[] = [];
  1757. pathMap.forEach((targets, storagePath) => {
  1758. tasks.push((async () => {
  1759. try {
  1760. const cached = await mediaTable.queryVideoByFilePath(storagePath);
  1761. if (cached) {
  1762. targets.forEach(target => this.mergeCachedMetadata(target, cached, storagePath));
  1763. }
  1764. } catch (error) {
  1765. Logger.error(TAG, `查询缓存元数据失败: ${storagePath} - ${(error as Error).message}`);
  1766. }
  1767. })());
  1768. });
  1769. await Promise.all(tasks);
  1770. } catch (error) {
  1771. Logger.error(TAG, `补全云端歌曲元数据失败: ${(error as Error).message}`);
  1772. }
  1773. }
  1774. private resolveStoragePathForItem(item: VideoItem): string | null {
  1775. if (!item) {
  1776. return null;
  1777. }
  1778. if (item.type === CommonConstants.TYPE_WEBDAV) {
  1779. const relative = item.remote_rel_path || WebDavUrlUtil.toStoragePath(item.filePath);
  1780. if (relative) {
  1781. item.remote_rel_path = relative;
  1782. return relative;
  1783. }
  1784. return null;
  1785. }
  1786. if (item.type === CommonConstants.TYPE_SMB || item.type === CommonConstants.TYPE_NAVIDROME ||
  1787. item.type === CommonConstants.TYPE_FTP || item.type === CommonConstants.TYPE_BAIDU ||
  1788. item.type === CommonConstants.TYPE_JELLYFIN) {
  1789. if (item.remote_rel_path) {
  1790. return item.remote_rel_path;
  1791. }
  1792. return null;
  1793. }
  1794. return null;
  1795. }
  1796. private mergeCachedMetadata(target: VideoItem, cached: VideoItem, storagePath: string): void {
  1797. target.id = cached.id || target.id || storagePath;
  1798. target.name = cached.name || target.name;
  1799. target.artist = cached.artist || target.artist;
  1800. target.album = cached.album || target.album;
  1801. target.duration = cached.duration || target.duration;
  1802. target.size = cached.size || target.size;
  1803. target.pixelMapPath = cached.pixelMapPath || target.pixelMapPath;
  1804. target.lyricContent = cached.lyricContent || target.lyricContent;
  1805. target.md5Str = cached.md5Str || target.md5Str;
  1806. target.bit_rate = cached.bit_rate || target.bit_rate;
  1807. target.sampleRate = cached.sampleRate || target.sampleRate;
  1808. target.trackCount = cached.trackCount || target.trackCount;
  1809. target.mimeType = cached.mimeType || target.mimeType;
  1810. target.pyStr = cached.pyStr || target.pyStr;
  1811. target.genre = cached.genre || target.genre;
  1812. target.track = cached.track || target.track;
  1813. target.year = cached.year || target.year;
  1814. target.webdav_account_id = target.webdav_account_id || cached.webdav_account_id;
  1815. target.remote_rel_path = target.remote_rel_path || cached.remote_rel_path || storagePath;
  1816. }
  1817. // 将FileInfo转换为VideoItem
  1818. private fileInfoToVideoItem(fileInfo: FileInfo, account: WebDavAccount): VideoItem {
  1819. if (account.webType === RemoteDriveType.Smb) {
  1820. return this.buildSmbVideoItem(fileInfo, account);
  1821. }
  1822. if (account.webType === RemoteDriveType.Ftp) {
  1823. return this.buildFtpVideoItem(fileInfo, account);
  1824. }
  1825. // 构建安全的WebDAV URL(不包含认证信息)
  1826. const protocol = account.enableHttps ? 'https' : 'http';
  1827. const host = account.isUseLocalHost ? account.localHost : account.host;
  1828. const port = account.port;
  1829. // 构建基础URL,确保路径重新编码(目录/文件名中的中文与空格)
  1830. const safeHref = this.normalizeRemoteHref(fileInfo.href);
  1831. const encodedHref = encodeURI(safeHref); // 仅编码非保留字符,已编码的百分号不重复编码
  1832. const absoluteUrl = `${protocol}://${host}:${port}${encodedHref}`;
  1833. // 创建VideoItem对象
  1834. // 构造函数签名: (name, id, filePath, type, videoSize, cTime, size?, pixelMapPath?, artist?, album?, fileName?, lastPlayed?)
  1835. const videoItem = new VideoItem(
  1836. this.getFileNameWithoutExtension(fileInfo.fileName), // name: 歌曲名
  1837. '', // id: 空字符串,WebDAV文件无本地ID
  1838. absoluteUrl, // filePath: 绝对路径
  1839. CommonConstants.TYPE_WEBDAV, // type: WebDAV类型
  1840. fileInfo.contentLength, // videoSize: 文件大小
  1841. Utility.getFormatDateStr(fileInfo.time,'yyyy-MM-dd HH:mm'), // cTime: 修改时间
  1842. undefined, // size
  1843. undefined, // pixelMapPath: 对于WebDAV歌曲不设置图片路径
  1844. Constants.UNKNOWN_ARTIST, // artist: 艺术家
  1845. undefined, // album: 专辑
  1846. fileInfo.fileName // fileName: 真实文件名
  1847. );
  1848. videoItem.size = Utility.formatFSize(fileInfo.contentLength)
  1849. //根据文件名解析艺术家
  1850. const musicData:MusicInfo = parseMusicFileName(fileInfo.fileName);
  1851. if (musicData.isValid) {
  1852. console.log('onecold parseMusicFileName artist:', musicData.artist)
  1853. videoItem.artist = musicData.artist
  1854. videoItem.name = musicData.title
  1855. }
  1856. videoItem.album = ''
  1857. // 设置WebDAV账号ID,用于后续认证信息查询
  1858. if (account && account.id) {
  1859. videoItem.webdav_account_id = account.id.toString();
  1860. }
  1861. // 计算并保存相对路径
  1862. try {
  1863. const relMatch = absoluteUrl.match(/^https?:\/\/[^\/]+(?::\d+)?(\/.*)$/i);
  1864. if (relMatch && relMatch[1]) {
  1865. videoItem.remote_rel_path = relMatch[1];
  1866. }
  1867. } catch (e) {
  1868. Logger.warn(TAG, '计算相对路径失败: ' + (e as Error).message);
  1869. }
  1870. return videoItem;
  1871. }
  1872. private buildSmbVideoItem(fileInfo: FileInfo, account: WebDavAccount): VideoItem {
  1873. const relativePath = fileInfo.href && fileInfo.href.length > 0 ? fileInfo.href : `/${fileInfo.fileName}`;
  1874. const normalizedRelative = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
  1875. const sanitizedRelative = this.normalizeSmbRelativePath(normalizedRelative, account.smbShare);
  1876. const videoItem = new VideoItem(
  1877. this.getFileNameWithoutExtension(fileInfo.fileName),
  1878. '',
  1879. sanitizedRelative,
  1880. CommonConstants.TYPE_SMB,
  1881. fileInfo.contentLength,
  1882. Utility.getFormatDateStr(fileInfo.time, 'yyyy-MM-dd HH:mm'),
  1883. undefined,
  1884. undefined,
  1885. Constants.UNKNOWN_ARTIST,
  1886. undefined,
  1887. fileInfo.fileName
  1888. );
  1889. videoItem.album = ''
  1890. videoItem.size = Utility.formatFSize(fileInfo.contentLength);
  1891. if (account && account.id) {
  1892. videoItem.webdav_account_id = account.id.toString();
  1893. }
  1894. videoItem.remote_rel_path = sanitizedRelative;
  1895. const hostForDisplay = account.host && account.host.trim().length > 0
  1896. ? account.host.trim()
  1897. : (account.id?.toString() ?? '');
  1898. const cleanedShare = account.smbShare
  1899. ? account.smbShare.replace(/^\/+|\/+$/g, '')
  1900. : '';
  1901. const relativeForDisplay = sanitizedRelative === '/' ? '' : sanitizedRelative;
  1902. if (hostForDisplay.length > 0 && cleanedShare.length > 0) {
  1903. videoItem.filePath = `smb://${hostForDisplay}/${cleanedShare}${relativeForDisplay}`;
  1904. } else if (hostForDisplay.length > 0) {
  1905. videoItem.filePath = `smb://${hostForDisplay}${relativeForDisplay}`;
  1906. } else {
  1907. videoItem.filePath = `smb://${account.id}${relativeForDisplay}`;
  1908. }
  1909. const musicData: MusicInfo = parseMusicFileName(fileInfo.fileName);
  1910. if (musicData.isValid) {
  1911. videoItem.artist = musicData.artist;
  1912. videoItem.name = musicData.title;
  1913. }
  1914. videoItem.fileName = fileInfo.fileName
  1915. return videoItem;
  1916. }
  1917. private ftpEntryToFileInfo(entry: FtpEntryInfo, basePath: string): FileInfo {
  1918. const normalizedBase = this.normalizeFullPath(basePath);
  1919. const targetPath = this.normalizeFullPath(`${normalizedBase === '/' ? '' : normalizedBase}/${entry.name}`);
  1920. const info = new FileInfo(normalizedBase, entry.name, entry.size ?? 0, entry.modifiedAt ? entry.modifiedAt.getTime() : Date.now());
  1921. info.fileName = entry.name;
  1922. info.href = targetPath;
  1923. info.isDirectory = Boolean(entry.isDirectory);
  1924. info.contentLength = entry.size ?? 0;
  1925. info.time = entry.modifiedAt ? entry.modifiedAt.getTime() : Date.now();
  1926. return info;
  1927. }
  1928. private buildFtpVideoItem(fileInfo: FileInfo, account: WebDavAccount): VideoItem {
  1929. const host = this.resolveFtpHost(account);
  1930. if (!host) {
  1931. throw new Error('FTP账号缺少服务器地址');
  1932. }
  1933. const normalizedPath = this.normalizeFullPath(fileInfo.href || `/${fileInfo.fileName}`);
  1934. const encodedPath = encodeURI(normalizedPath);
  1935. const port = account.port && account.port > 0 ? `:${account.port}` : '';
  1936. const ftpUrl = `ftp://${host}${port}${encodedPath}`;
  1937. const videoItem = new VideoItem(
  1938. this.getFileNameWithoutExtension(fileInfo.fileName),
  1939. '',
  1940. ftpUrl,
  1941. CommonConstants.TYPE_FTP,
  1942. fileInfo.contentLength,
  1943. Utility.getFormatDateStr(fileInfo.time, 'yyyy-MM-dd HH:mm')
  1944. );
  1945. videoItem.album = '';
  1946. videoItem.size = Utility.formatFSize(fileInfo.contentLength);
  1947. const musicData: MusicInfo = parseMusicFileName(fileInfo.fileName);
  1948. if (musicData.isValid) {
  1949. videoItem.artist = musicData.artist;
  1950. videoItem.name = musicData.title;
  1951. } else {
  1952. videoItem.artist = Constants.UNKNOWN_ARTIST;
  1953. }
  1954. if (account && account.id) {
  1955. videoItem.webdav_account_id = account.id.toString();
  1956. }
  1957. videoItem.fileName = fileInfo.fileName
  1958. videoItem.remote_rel_path = normalizedPath;
  1959. return videoItem;
  1960. }
  1961. private baiduEntryToFileInfo(entry: BaiduListEntry): FileInfo {
  1962. const timestamp = (entry.server_mtime ?? Date.now()) * 1000;
  1963. const info = new FileInfo('', entry.server_filename, entry.size, timestamp);
  1964. info.fileName = entry.server_filename;
  1965. info.href = entry.path;
  1966. info.isDirectory = entry.isdir === 1;
  1967. info.contentLength = entry.size;
  1968. info.time = timestamp;
  1969. return info;
  1970. }
  1971. private buildBaiduVideoItem(entry: BaiduListEntry, account: WebDavAccount): VideoItem {
  1972. const timestamp = (entry.server_mtime ?? Date.now()) * 1000;
  1973. const videoItem = new VideoItem(
  1974. this.getFileNameWithoutExtension(entry.server_filename),
  1975. entry.fs_id.toString(),
  1976. `baidupan://${account.id ?? 0}${entry.path}`,
  1977. CommonConstants.TYPE_BAIDU,
  1978. entry.size,
  1979. Utility.getFormatDateStr(timestamp, 'yyyy-MM-dd HH:mm'),
  1980. Utility.formatFSize(entry.size)
  1981. );
  1982. videoItem.size = Utility.formatFSize(entry.size);
  1983. videoItem.album = '';
  1984. videoItem.fileName = entry.server_filename
  1985. videoItem.remote_rel_path = entry.path;
  1986. videoItem.baiduFsId = entry.fs_id.toString();
  1987. if (account && account.id) {
  1988. videoItem.webdav_account_id = account.id.toString();
  1989. }
  1990. const musicData: MusicInfo = parseMusicFileName(entry.server_filename);
  1991. if (musicData.isValid) {
  1992. videoItem.artist = musicData.artist;
  1993. videoItem.name = musicData.title;
  1994. } else {
  1995. videoItem.artist = Constants.UNKNOWN_ARTIST;
  1996. }
  1997. return videoItem;
  1998. }
  1999. private async ensureBaiduAccessToken(account: WebDavAccount): Promise<string> {
  2000. if (account.baiduAccessToken) {
  2001. if (!account.baiduTokenExpiresAt || (account.baiduTokenExpiresAt - Date.now()) > 60 * 1000) {
  2002. return account.baiduAccessToken;
  2003. }
  2004. }
  2005. if (!account.baiduRefreshToken) {
  2006. throw new Error('百度网盘授权已过期,请在账户设置中重新登录百度网盘');
  2007. }
  2008. const token = await refreshBaiduAccessToken(account.baiduRefreshToken);
  2009. account.baiduAccessToken = token.access_token;
  2010. account.baiduRefreshToken = token.refresh_token ?? account.baiduRefreshToken;
  2011. account.baiduTokenExpiresAt = token.expires_in ? Date.now() + token.expires_in * 1000 : 0;
  2012. await this.persistBaiduTokens(account);
  2013. return account.baiduAccessToken;
  2014. }
  2015. private async persistBaiduTokens(account: WebDavAccount): Promise<void> {
  2016. if (!account.id) {
  2017. return;
  2018. }
  2019. const predicates = new relationalStore.RdbPredicates(this.webDavTable);
  2020. predicates.equalTo('id', account.id);
  2021. const values: relationalStore.ValuesBucket = {
  2022. baiduAccessToken: account.baiduAccessToken,
  2023. baiduRefreshToken: account.baiduRefreshToken,
  2024. baiduTokenExpiresAt: account.baiduTokenExpiresAt
  2025. };
  2026. await this.dataBaseUtil.updateData(this.webDavTable, values, predicates);
  2027. }
  2028. private getBaiduCacheKey(account: WebDavAccount, fsId: string): string {
  2029. return `${account.id ?? '0'}_${fsId}`;
  2030. }
  2031. private cacheBaiduDlink(account: WebDavAccount, fsId: string, token: string, url: string): void {
  2032. const key = this.getBaiduCacheKey(account, fsId);
  2033. const expireAt = Date.now() + RemoteDriveManager.BAIDU_DLINK_CACHE_TTL;
  2034. this.baiduDlinkCache.set(key, { url, token, expireAt });
  2035. }
  2036. private getCachedBaiduDlink(account: WebDavAccount, fsId: string, token: string): string | null {
  2037. const key = this.getBaiduCacheKey(account, fsId);
  2038. const cached = this.baiduDlinkCache.get(key);
  2039. if (!cached) {
  2040. return null;
  2041. }
  2042. if (cached.token !== token || cached.expireAt <= Date.now()) {
  2043. this.baiduDlinkCache.delete(key);
  2044. return null;
  2045. }
  2046. return cached.url;
  2047. }
  2048. public async getBaiduDownloadUrl(account: WebDavAccount, song: VideoItem): Promise<string> {
  2049. const accessToken = await this.ensureBaiduAccessToken(account);
  2050. const fsId = song.baiduFsId || song.id;
  2051. if (!fsId) {
  2052. throw new Error('缺少百度网盘文件fs_id');
  2053. }
  2054. const cached = this.getCachedBaiduDlink(account, fsId, accessToken);
  2055. if (cached) {
  2056. Logger.info(TAG, `Baidu dlink cache hit, fsId=${fsId}`);
  2057. return cached;
  2058. }
  2059. const metas = await fetchBaiduFileMetas(accessToken, [fsId]);
  2060. if (!metas || metas.length === 0 || !metas[0].dlink) {
  2061. throw new Error('无法获取下载链接');
  2062. }
  2063. const rawLink = metas[0].dlink.startsWith('https://') ? metas[0].dlink : metas[0].dlink.replace('http://', 'https://');
  2064. const urlWithToken = appendAccessTokenToDlink(rawLink, accessToken);
  2065. Logger.info(TAG, `Baidu dlink ready, fsId=${fsId} url=${urlWithToken}`);
  2066. this.cacheBaiduDlink(account, fsId, accessToken, urlWithToken);
  2067. return urlWithToken;
  2068. }
  2069. public async getBaiduFileInfo(account: WebDavAccount, fsId: string, remotePath?: string): Promise<BaiduFileInfo> {
  2070. const accessToken = await this.ensureBaiduAccessToken(account);
  2071. if (!fsId) {
  2072. throw new Error('缺少百度网盘文件fs_id');
  2073. }
  2074. Logger.info(TAG, `查询百度网盘文件信息: fsId=${fsId}`);
  2075. const metas = await fetchBaiduFileMetas(accessToken, [fsId]);
  2076. if (!metas || metas.length === 0) {
  2077. throw new Error('无法获取文件信息');
  2078. }
  2079. const meta = metas[0];
  2080. if (!meta.dlink) {
  2081. throw new Error('无法获取下载链接');
  2082. }
  2083. const rawLink = meta.dlink.startsWith('https://') ? meta.dlink : meta.dlink.replace('http://', 'https://');
  2084. const urlWithToken = appendAccessTokenToDlink(rawLink, accessToken);
  2085. let streamUrl = urlWithToken;
  2086. if (remotePath) {
  2087. try {
  2088. await ensureAudioStreamReady(accessToken, remotePath, BaiduConstants.STREAMING_AUDIO_TYPE, 240, 1000, true);
  2089. streamUrl = buildAudioStreamingUrl(accessToken, remotePath);
  2090. Logger.info(TAG, `百度网盘文件信息获取成功: fsId=${fsId}, size=${meta.size}, streamUrl=${streamUrl}`);
  2091. } catch (streamError) {
  2092. const err = streamError as Error;
  2093. Logger.warn(TAG, `百度音频流暂不可用,回退到下载链接: ${err.message}`);
  2094. streamUrl = urlWithToken;
  2095. }
  2096. } else {
  2097. Logger.warn(TAG, `缺少百度网盘文件路径,使用下载链接播放: fsId=${fsId}`);
  2098. }
  2099. // 缓存 dlink
  2100. this.cacheBaiduDlink(account, fsId, accessToken, urlWithToken);
  2101. const result: BaiduFileInfo = {
  2102. size: meta.size,
  2103. dlink: urlWithToken,
  2104. streamUrl
  2105. };
  2106. return result;
  2107. }
  2108. private smbEntryToFileInfo(entry: SmbDirectoryEntry, basePath: string): FileInfo {
  2109. const normalizedPath = this.combineRemotePath(basePath, entry.name, entry.isDirectory);
  2110. const info = new FileInfo('', entry.name, entry.size, Date.now());
  2111. info.fileName = entry.name;
  2112. info.href = normalizedPath;
  2113. info.contentLength = entry.size ?? 0;
  2114. info.isDirectory = entry.isDirectory;
  2115. return info;
  2116. }
  2117. private combineRemotePath(basePath: string, name: string, isDirectory: boolean): string {
  2118. const segments: string[] = [];
  2119. const trimmedBase = basePath ? basePath.replace(/^\/+/, '').replace(/\/+/g, '/') : '';
  2120. if (trimmedBase.length > 0) {
  2121. segments.push(trimmedBase);
  2122. }
  2123. const cleanedName = name.replace(/^\/+/, '').replace(/\/+/g, '/');
  2124. if (cleanedName.length > 0) {
  2125. segments.push(cleanedName);
  2126. }
  2127. let fullPath = `/${segments.filter(part => part.length > 0).join('/')}`;
  2128. if (isDirectory && !fullPath.endsWith('/')) {
  2129. fullPath = `${fullPath}/`;
  2130. }
  2131. if (!isDirectory && fullPath.endsWith('/')) {
  2132. fullPath = fullPath.slice(0, -1);
  2133. }
  2134. return fullPath;
  2135. }
  2136. private createNavDirectory(name: string, href: string): FileInfo {
  2137. const info = new FileInfo('', name, 0, Date.now());
  2138. info.fileName = name;
  2139. info.href = this.normalizeFullPath(href);
  2140. info.isDirectory = true;
  2141. return info;
  2142. }
  2143. private createJellyfinDirectory(name: string, href: string): FileInfo {
  2144. const info = new FileInfo('', name, 0, Date.now());
  2145. info.fileName = name;
  2146. info.href = this.normalizeFullPath(href);
  2147. info.isDirectory = true;
  2148. return info;
  2149. }
  2150. private createNavSongFileInfo(song: NavidromeSong, albumId: string): FileInfo {
  2151. const displayName = song.title ?? '未知曲目';
  2152. const info = new FileInfo('', displayName, song.size ?? 0, Date.now());
  2153. info.fileName = `${displayName}${song.suffix ? '.' + song.suffix : ''}`;
  2154. info.href = this.normalizeFullPath(`/album/${albumId}/${song.id}`);
  2155. info.isDirectory = false;
  2156. info.contentLength = song.size ?? 0;
  2157. return info;
  2158. }
  2159. private createJellyfinSongFileInfo(song: JellyfinSong, albumId: string): FileInfo {
  2160. const displayName = song.title ?? '未知曲目';
  2161. const info = new FileInfo('', displayName, song.size ?? 0, Date.now());
  2162. info.fileName = `${displayName}${song.suffix ? '.' + song.suffix : ''}`;
  2163. info.href = this.normalizeFullPath(`/album/${albumId}/${song.id}`);
  2164. info.isDirectory = false;
  2165. info.contentLength = song.size ?? 0;
  2166. return info;
  2167. }
  2168. private buildNavidromeVideoItem(song: NavidromeSong, account: WebDavAccount, album?: NavidromeAlbumDetail, coverUrl?: string): VideoItem {
  2169. const title = song.title ?? '未知曲目';
  2170. const fileName = `${title}${song.suffix ? '.' + song.suffix : ''}`;
  2171. const videoItem = new VideoItem(
  2172. title,
  2173. song.id,
  2174. `navidrome://${account.id ?? 0}/${song.id}`,
  2175. CommonConstants.TYPE_NAVIDROME,
  2176. song.size ?? 0,
  2177. Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
  2178. Utility.formatFSize(song.size ?? 0),
  2179. undefined,
  2180. song.artist ?? album?.artist ?? Constants.UNKNOWN_ARTIST,
  2181. album?.name ?? song.album,
  2182. fileName
  2183. );
  2184. videoItem.size = Utility.formatFSize(song.size ?? 0);
  2185. videoItem.webdav_account_id = account.id?.toString();
  2186. videoItem.remote_rel_path = song.id;
  2187. videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
  2188. videoItem.mimeType = song.contentType;
  2189. videoItem.pixelMapPath = coverUrl;
  2190. if (song.track !== undefined && song.track !== null) {
  2191. videoItem.track = song.track.toString();
  2192. }
  2193. if (song.year !== undefined && song.year !== null) {
  2194. videoItem.year = song.year.toString();
  2195. }
  2196. return videoItem;
  2197. }
  2198. private buildJellyfinVideoItem(song: JellyfinSong, account: WebDavAccount, album?: JellyfinAlbum | null, coverUrl?: string): VideoItem {
  2199. const title = song.title ?? '未知曲目';
  2200. const fileName = `${title}${song.suffix ? '.' + song.suffix : ''}`;
  2201. const videoItem = new VideoItem(
  2202. title,
  2203. song.id,
  2204. `jellyfin://${account.id ?? 0}/${song.id}`,
  2205. CommonConstants.TYPE_JELLYFIN,
  2206. song.size ?? 0,
  2207. Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
  2208. Utility.formatFSize(song.size ?? 0),
  2209. undefined,
  2210. song.artist ?? album?.artist ?? Constants.UNKNOWN_ARTIST,
  2211. album?.name ?? song.album,
  2212. fileName
  2213. );
  2214. videoItem.size = Utility.formatFSize(song.size ?? 0);
  2215. videoItem.webdav_account_id = account.id?.toString();
  2216. videoItem.remote_rel_path = song.id;
  2217. videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
  2218. videoItem.sampleRate = song.sampleRate ? song.sampleRate.toString() : undefined;
  2219. videoItem.pixelMapPath = coverUrl;
  2220. if (song.track !== undefined && song.track !== null) {
  2221. videoItem.track = song.track.toString();
  2222. }
  2223. if (song.year !== undefined && song.year !== null) {
  2224. videoItem.year = song.year.toString();
  2225. }
  2226. return videoItem;
  2227. }
  2228. private createEmbyDirectory(name: string, href: string): FileInfo {
  2229. const info = new FileInfo('', name, 0, Date.now());
  2230. info.fileName = name;
  2231. info.href = this.normalizeFullPath(href);
  2232. info.isDirectory = true;
  2233. return info;
  2234. }
  2235. private createEmbySongFileInfo(song: EmbySong, albumId: string): FileInfo {
  2236. const displayName = song.title ?? '未知曲目';
  2237. const info = new FileInfo('', displayName, song.size ?? 0, Date.now());
  2238. info.fileName = `${displayName}${song.suffix ? '.' + song.suffix : ''}`;
  2239. info.href = this.normalizeFullPath(`/album/${albumId}/${song.id}`);
  2240. info.isDirectory = false;
  2241. info.contentLength = song.size ?? 0;
  2242. return info;
  2243. }
  2244. private buildEmbyVideoItem(song: EmbySong, account: WebDavAccount, album?: EmbyAlbum | null, coverUrl?: string): VideoItem {
  2245. const title = song.title ?? '未知曲目';
  2246. const fileName = `${title}${song.suffix ? '.' + song.suffix : ''}`;
  2247. const videoItem = new VideoItem(
  2248. title,
  2249. song.id,
  2250. `emby://${account.id ?? 0}/${song.id}`,
  2251. CommonConstants.TYPE_EMBY,
  2252. song.size ?? 0,
  2253. Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
  2254. Utility.formatFSize(song.size ?? 0),
  2255. undefined,
  2256. song.artist ?? album?.artist ?? Constants.UNKNOWN_ARTIST,
  2257. album?.name ?? song.album,
  2258. fileName
  2259. );
  2260. videoItem.size = Utility.formatFSize(song.size ?? 0);
  2261. videoItem.webdav_account_id = account.id?.toString();
  2262. videoItem.remote_rel_path = song.id;
  2263. videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
  2264. videoItem.sampleRate = song.sampleRate ? song.sampleRate.toString() : undefined;
  2265. videoItem.pixelMapPath = coverUrl;
  2266. if (song.track !== undefined && song.track !== null) {
  2267. videoItem.track = song.track.toString();
  2268. }
  2269. if (song.year !== undefined && song.year !== null) {
  2270. videoItem.year = song.year.toString();
  2271. }
  2272. // 存储歌词索引
  2273. if (song.lyricIndex !== undefined && song.lyricIndex !== null) {
  2274. videoItem.lyricIndex = song.lyricIndex;
  2275. }
  2276. return videoItem;
  2277. }
  2278. private registerPathLabel(path: string, label: string): void {
  2279. if (!path || label === undefined) {
  2280. return;
  2281. }
  2282. const normalized = this.normalizeFullPath(path);
  2283. const sanitizedLabel = this.sanitizeLabel(label);
  2284. this.pathDisplayNames.set(normalized, sanitizedLabel);
  2285. }
  2286. private sanitizeLabel(label: string): string {
  2287. if (!label) {
  2288. return '';
  2289. }
  2290. if (label === '根目录') {
  2291. return label;
  2292. }
  2293. let sanitized = label.trim();
  2294. sanitized = sanitized.replace(/\/+$/, '');
  2295. if (sanitized.length === 0) {
  2296. sanitized = '/';
  2297. }
  2298. try {
  2299. sanitized = decodeURIComponent(sanitized);
  2300. } catch (error) {
  2301. // ignore decode failure, keep original label
  2302. }
  2303. return sanitized;
  2304. }
  2305. private getBasePath(account: WebDavAccount | null = this.currentAccount): string {
  2306. if (account && account.filepath) {
  2307. return this.normalizeFullPath(account.filepath);
  2308. }
  2309. return '/';
  2310. }
  2311. private getRelativePath(fullPath: string, basePath: string): string {
  2312. const normalizedFull = this.normalizeFullPath(fullPath);
  2313. const normalizedBase = this.normalizeFullPath(basePath);
  2314. if (normalizedFull === normalizedBase) {
  2315. return '';
  2316. }
  2317. if (normalizedFull.startsWith(normalizedBase) && normalizedBase !== '/') {
  2318. return normalizedFull.substring(normalizedBase.length).replace(/^\/+/, '');
  2319. }
  2320. return normalizedFull.replace(/^\/+/, '');
  2321. }
  2322. private normalizeSmbRelativePath(path: string, shareName?: string): string {
  2323. if (!path || path.trim().length === 0) {
  2324. return '/';
  2325. }
  2326. let normalized = path.replace(/\/+/g, '/');
  2327. if (!normalized.startsWith('/')) {
  2328. normalized = `/${normalized}`;
  2329. }
  2330. if (normalized.length > 1 && normalized.endsWith('/')) {
  2331. normalized = normalized.slice(0, -1);
  2332. }
  2333. if (shareName) {
  2334. const cleanedShare = shareName.replace(/^\/+|\/+$/g, '');
  2335. if (cleanedShare.length > 0) {
  2336. const prefix = `/${cleanedShare}`;
  2337. if (normalized === prefix) {
  2338. normalized = '/';
  2339. } else if (normalized.startsWith(`${prefix}/`)) {
  2340. normalized = normalized.substring(prefix.length);
  2341. if (!normalized.startsWith('/')) {
  2342. normalized = `/${normalized}`;
  2343. }
  2344. }
  2345. }
  2346. }
  2347. return normalized || '/';
  2348. }
  2349. // 判断是否为音频文件
  2350. private isAudioFile(fileName: string): boolean {
  2351. const ext = this.getFileExtension(fileName).toLowerCase();
  2352. for (let i = 0; i < this.audioExtensions.length; i++) {
  2353. if (ext === this.audioExtensions[i]) {
  2354. return true;
  2355. }
  2356. }
  2357. return false;
  2358. }
  2359. // 获取文件扩展名
  2360. private getFileExtension(fileName: string): string {
  2361. const lastDotIndex = fileName.lastIndexOf('.');
  2362. if (lastDotIndex === -1) {
  2363. return '';
  2364. }
  2365. return fileName.substring(lastDotIndex);
  2366. }
  2367. // 获取不带扩展名的文件名
  2368. private getFileNameWithoutExtension(fileName: string): string {
  2369. const lastDotIndex = fileName.lastIndexOf('.');
  2370. if (lastDotIndex === -1) {
  2371. return fileName;
  2372. }
  2373. return fileName.substring(0, lastDotIndex);
  2374. }
  2375. // 进入文件夹
  2376. public async enterFolder(folder: FileInfo): Promise<void> {
  2377. if (!folder.isDirectory) {
  2378. Logger.error(TAG, '不是文件夹,无法进入');
  2379. return;
  2380. }
  2381. Logger.info(TAG, '准备进入文件夹:', folder.fileName);
  2382. Logger.info(TAG, '当前路径:', this.currentPath);
  2383. Logger.info(TAG, '目标路径:', folder.href);
  2384. // 保存当前路径到历史记录
  2385. this.registerPathLabel(folder.href, folder.fileName);
  2386. this.pathHistory.push(this.currentPath);
  2387. Logger.info(TAG, '路径历史:', JSON.stringify(this.pathHistory));
  2388. // 加载文件夹内容
  2389. await this.loadFilesInfoFromAccount(this.currentAccount, folder.href);
  2390. }
  2391. public async enterFolderFromPath(path: string): Promise<void> {
  2392. const normalizedTarget = this.normalizeFullPath(path);
  2393. Logger.info(TAG, '当前路径:', this.currentPath);
  2394. Logger.info(TAG, '目标路径:', normalizedTarget);
  2395. this.rebuildPathHistoryForTarget(normalizedTarget);
  2396. Logger.info(TAG, '路径历史:', JSON.stringify(this.pathHistory));
  2397. await this.loadFilesInfoFromAccount(this.currentAccount, normalizedTarget);
  2398. }
  2399. private rebuildPathHistoryForTarget(targetPath: string): void {
  2400. if (!targetPath) {
  2401. this.pathHistory = [];
  2402. return;
  2403. }
  2404. const normalizedTarget = this.normalizeFullPath(targetPath);
  2405. const basePath = this.getBasePath();
  2406. if (normalizedTarget === basePath) {
  2407. this.pathHistory = [];
  2408. return;
  2409. }
  2410. const relativePath = this.getRelativePath(normalizedTarget, basePath);
  2411. if (!relativePath) {
  2412. this.pathHistory = [];
  2413. return;
  2414. }
  2415. const parts = relativePath.split('/').filter(part => part.length > 0);
  2416. const rebuiltHistory: string[] = [];
  2417. // 把账户根路径作为栈底,以便可以返回
  2418. rebuiltHistory.push(basePath);
  2419. let cumulative = basePath;
  2420. for (let i = 0; i < parts.length - 1; i++) {
  2421. const segment = parts[i];
  2422. cumulative = this.normalizeFullPath(`${cumulative === '/' ? '' : cumulative}/${segment}`);
  2423. rebuiltHistory.push(cumulative);
  2424. }
  2425. this.pathHistory = rebuiltHistory;
  2426. }
  2427. // 返回上级目录
  2428. public async goBack(): Promise<void> {
  2429. if (this.pathHistory.length === 0) {
  2430. Logger.info(TAG, '已经在根目录,无法返回');
  2431. return;
  2432. }
  2433. // 从历史记录中取出上一级路径
  2434. const previousPath = this.pathHistory.pop();
  2435. if (previousPath !== undefined) {
  2436. await this.loadFilesInfoFromAccount(this.currentAccount, previousPath);
  2437. }
  2438. }
  2439. // 获取面包屑路径数组
  2440. public getBreadcrumbs(): BreadcrumbItem[] {
  2441. const basePath = this.getBasePath();
  2442. const breadcrumbs: BreadcrumbItem[] = [{
  2443. label: '根目录',
  2444. path: basePath
  2445. }];
  2446. if (!this.currentPath || this.normalizeFullPath(this.currentPath) === basePath) {
  2447. return breadcrumbs;
  2448. }
  2449. const relativePath = this.getRelativePath(this.currentPath, basePath);
  2450. if (!relativePath) {
  2451. return breadcrumbs;
  2452. }
  2453. const parts = relativePath.split('/').filter(part => part.length > 0);
  2454. let cumulative = basePath;
  2455. for (let i = 0; i < parts.length; i++) {
  2456. const segment = parts[i];
  2457. const targetPath = this.normalizeFullPath(`${cumulative === '/' ? '' : cumulative}/${segment}`);
  2458. const label = this.pathDisplayNames.get(targetPath) ?? segment;
  2459. breadcrumbs.push({
  2460. label: this.sanitizeLabel(label),
  2461. path: targetPath
  2462. });
  2463. cumulative = targetPath;
  2464. }
  2465. return breadcrumbs;
  2466. }
  2467. // 是否可以返回上级
  2468. public canGoBack(): boolean {
  2469. if(this.currentPath==='/')
  2470. return false
  2471. return this.pathHistory.length > 0;
  2472. }
  2473. // ==================== Preferences操作 ====================
  2474. // 加载历史数据(从Preferences迁移)
  2475. public async loadInfo(): Promise<void> {
  2476. try {
  2477. // 这里可以添加从Preferences加载历史配置的逻辑
  2478. Logger.info(TAG, '从Preferences加载配置');
  2479. } catch (error) {
  2480. Logger.error(TAG, '从Preferences加载配置失败:', error.toString());
  2481. }
  2482. }
  2483. // 保存配置到Preferences
  2484. public async saveInfo(): Promise<void> {
  2485. try {
  2486. // 这里可以添加保存配置到Preferences的逻辑
  2487. Logger.info(TAG, '保存配置到Preferences');
  2488. } catch (error) {
  2489. Logger.error(TAG, '保存配置到Preferences失败:', error.toString());
  2490. }
  2491. }
  2492. // ==================== 下载队列管理 ====================
  2493. // 添加到下载队列
  2494. public addToDownloadQueue(song: VideoItem, account: WebDavAccount): void {
  2495. const task: TransferTask = { song, account };
  2496. this.downloadQueue.push(task);
  2497. Logger.info(TAG, '添加到下载队列:', song.name);
  2498. this.notifyObservers(RemoteDriveManagerStates.DownloadQueueChanged);
  2499. }
  2500. // 从下载队列移除
  2501. public removeFromDownloadQueue(index: number): void {
  2502. if (index >= 0 && index < this.downloadQueue.length) {
  2503. const task = this.downloadQueue[index];
  2504. this.downloadQueue.splice(index, 1);
  2505. Logger.info(TAG, '从下载队列移除:', task.song.name);
  2506. this.notifyObservers(RemoteDriveManagerStates.DownloadQueueChanged);
  2507. }
  2508. }
  2509. // 清空下载队列
  2510. public clearDownloadQueue(): void {
  2511. this.downloadQueue = [];
  2512. Logger.info(TAG, '清空下载队列');
  2513. this.notifyObservers(RemoteDriveManagerStates.DownloadQueueChanged);
  2514. }
  2515. // ==================== 上传队列管理 ====================
  2516. /**
  2517. * 添加歌曲到上传队列
  2518. * @param songs 要上传的歌曲列表
  2519. * @param account 目标WebDAV账户
  2520. * @param customUploadPath 自定义上传路径(可选,如果不提供则使用账户默认路径)
  2521. */
  2522. public addToUploadQueue(songs: VideoItem[], account: WebDavAccount, customUploadPath?: string): void {
  2523. Logger.info(TAG, '========== 添加上传任务 ==========');
  2524. Logger.info(TAG, `请求添加: ${songs.length} 个文件`);
  2525. Logger.info(TAG, `目标账户: ${account.name} (ID: ${account.id})`);
  2526. Logger.info(TAG, `自定义上传路径: ${customUploadPath || '未指定,使用账户默认路径'}`);
  2527. Logger.info(TAG, `当前队列: ${this.uploadQueue.length}/${this.MAX_UPLOAD_QUEUE_SIZE}`);
  2528. // 检查队列大小限制
  2529. const availableSlots = this.MAX_UPLOAD_QUEUE_SIZE - this.uploadQueue.length;
  2530. if (availableSlots <= 0) {
  2531. Logger.warn(TAG, `上传队列已满(最大${this.MAX_UPLOAD_QUEUE_SIZE}个任务),无法添加更多任务`);
  2532. Logger.warn(TAG, '====================================');
  2533. return;
  2534. }
  2535. // 限制添加数量
  2536. const songsToAdd = songs.length > availableSlots ? songs.slice(0, availableSlots) : songs;
  2537. const existingKeys = new Set<string>();
  2538. for (let i = 0; i < this.uploadQueue.length; i++) {
  2539. const key = this.getSongKey(this.uploadQueue[i].song);
  2540. if (key) {
  2541. existingKeys.add(key);
  2542. }
  2543. }
  2544. const filteredSongs: VideoItem[] = [];
  2545. for (let i = 0; i < songsToAdd.length; i++) {
  2546. const song = songsToAdd[i];
  2547. const key = this.getSongKey(song);
  2548. if (key && existingKeys.has(key)) {
  2549. Logger.warn(TAG, `检测到重复任务,已跳过: ${song.name}`);
  2550. continue;
  2551. }
  2552. if (key) {
  2553. existingKeys.add(key);
  2554. }
  2555. filteredSongs.push(song);
  2556. }
  2557. Logger.info(TAG, `实际添加: ${filteredSongs.length} 个文件`);
  2558. for (let i = 0; i < filteredSongs.length; i++) {
  2559. const song = filteredSongs[i];
  2560. const task: TransferTask = {
  2561. song,
  2562. account,
  2563. customUploadPath: customUploadPath // 保存自定义上传路径
  2564. };
  2565. this.uploadQueue.push(task);
  2566. Logger.info(TAG, `[${i + 1}/${songsToAdd.length}] ${song.name}`);
  2567. }
  2568. if (songs.length > songsToAdd.length) {
  2569. Logger.warn(TAG, `队列空间不足,仅处理前${songsToAdd.length}/${songs.length}个任务`);
  2570. }
  2571. Logger.info(TAG, `新队列大小: ${this.uploadQueue.length}/${this.MAX_UPLOAD_QUEUE_SIZE}`);
  2572. Logger.info(TAG, '====================================');
  2573. this.notifyObservers(RemoteDriveManagerStates.UploadQueueChanged);
  2574. this.notifyObservers(RemoteDriveManagerStates.ChangeUploadQueue);
  2575. }
  2576. /**
  2577. * 开始处理上传队列
  2578. */
  2579. public async startUploadQueue(): Promise<void> {
  2580. if (this.isProcessingUploadQueue) {
  2581. Logger.warn(TAG, '上传队列正在处理中');
  2582. return;
  2583. }
  2584. if (this.uploadQueue.length === 0) {
  2585. Logger.info(TAG, '上传队列为空');
  2586. return;
  2587. }
  2588. this.isProcessingUploadQueue = true;
  2589. this.isPauseUpload = false;
  2590. Logger.info(TAG, '开始处理上传队列,共', this.uploadQueue.length.toString(), '个任务');
  2591. await this.processNextUploadTask();
  2592. }
  2593. /**
  2594. * 处理下一个上传任务
  2595. */
  2596. private async processNextUploadTask(): Promise<void> {
  2597. if (this.isPauseUpload) {
  2598. Logger.info(TAG, '========== 上传队列已暂停 ==========');
  2599. Logger.info(TAG, `剩余任务数: ${this.uploadQueue.length}`);
  2600. Logger.info(TAG, '====================================');
  2601. this.isProcessingUploadQueue = false;
  2602. return;
  2603. }
  2604. if (this.uploadQueue.length === 0) {
  2605. Logger.info(TAG, '========== 上传队列已完成 ==========');
  2606. Logger.info(TAG, `成功上传: ${this.finishUploadQueue.length} 个文件`);
  2607. Logger.info(TAG, '====================================');
  2608. this.isProcessingUploadQueue = false;
  2609. this.currentUploadTask = null;
  2610. this.notifyObservers(RemoteDriveManagerStates.SetCurrentUploadTask);
  2611. // 检查是否需要自动清理完成队列
  2612. const autoClear = PreferencesUtil.getBooleanSync('webdavUploadAutoClear', false);
  2613. if (autoClear && this.finishUploadQueue.length > 0) {
  2614. Logger.info(TAG, '自动清理完成队列');
  2615. this.finishUploadQueue = [];
  2616. this.notifyObservers(RemoteDriveManagerStates.ChangeFinishUploadQueue);
  2617. }
  2618. return;
  2619. }
  2620. const task = this.uploadQueue[0];
  2621. this.currentUploadTask = task;
  2622. Logger.info(TAG, '========== 处理上传队列 ==========');
  2623. Logger.info(TAG, `当前任务: ${task.song.name}`);
  2624. Logger.info(TAG, `队列位置: 1/${this.uploadQueue.length}`);
  2625. Logger.info(TAG, `已完成: ${this.finishUploadQueue.length}`);
  2626. Logger.info(TAG, '====================================');
  2627. this.notifyObservers(RemoteDriveManagerStates.SetCurrentUploadTask);
  2628. try {
  2629. if(this.currentAccount.webType==RemoteDriveType.WebDav){
  2630. await this.uploadSingleFile(task);
  2631. }else if(this.currentAccount.webType==RemoteDriveType.Baidu){
  2632. await this.uploadBaiduFile(task);
  2633. }
  2634. // 上传成功,移到完成队列
  2635. Logger.info(TAG, `任务成功,移至完成队列: ${task.song.name}`);
  2636. this.uploadQueue.shift();
  2637. this.finishUploadQueue.push(task);
  2638. this.notifyObservers(RemoteDriveManagerStates.UploadSuccess);
  2639. this.notifyObservers(RemoteDriveManagerStates.ChangeUploadQueue);
  2640. this.notifyObservers(RemoteDriveManagerStates.ChangeFinishUploadQueue);
  2641. // 继续处理下一个任务
  2642. await this.processNextUploadTask();
  2643. } catch (error) {
  2644. const err = error as Error;
  2645. Logger.error(TAG, '---------- 任务失败 ----------');
  2646. Logger.error(TAG, `文件: ${task.song.name}`);
  2647. Logger.error(TAG, `错误: ${err.message}`);
  2648. // 获取重试次数设置
  2649. const maxRetryCount = PreferencesUtil.getNumberSync('webdavUploadRetryCount', 3);
  2650. const currentRetryCount = task.retryCount || 0;
  2651. if (currentRetryCount < maxRetryCount) {
  2652. // 还可以重试
  2653. task.retryCount = currentRetryCount + 1;
  2654. Logger.info(TAG, `准备重试上传 (${task.retryCount}/${maxRetryCount}): ${task.song.name}`);
  2655. Logger.info(TAG, '将任务移至队列末尾');
  2656. // 将任务移到队列末尾重试
  2657. this.uploadQueue.shift();
  2658. this.uploadQueue.push(task);
  2659. this.notifyObservers(RemoteDriveManagerStates.ChangeUploadQueue);
  2660. } else {
  2661. // 已达到最大重试次数,放弃上传
  2662. Logger.error(TAG, `上传失败,已达到最大重试次数 (${maxRetryCount}): ${task.song.name}`);
  2663. Logger.error(TAG, '从队列中移除该任务');
  2664. this.uploadQueue.shift();
  2665. this.notifyObservers(RemoteDriveManagerStates.UploadFailed);
  2666. this.notifyObservers(RemoteDriveManagerStates.ChangeUploadQueue);
  2667. }
  2668. Logger.error(TAG, '------------------------------');
  2669. // 继续处理下一个任务
  2670. await this.processNextUploadTask();
  2671. }
  2672. }
  2673. /**
  2674. * 暂停上传队列
  2675. */
  2676. public pauseUploadQueue(): void {
  2677. Logger.info(TAG, '========== 暂停上传队列 ==========');
  2678. if (!this.isProcessingUploadQueue) {
  2679. Logger.warn(TAG, '上传队列未在处理中,无需暂停');
  2680. Logger.info(TAG, '====================================');
  2681. return;
  2682. }
  2683. this.isPauseUpload = true;
  2684. Logger.info(TAG, `当前任务: ${this.currentUploadTask?.song.name || '无'}`);
  2685. Logger.info(TAG, `剩余任务: ${this.uploadQueue.length}`);
  2686. Logger.info(TAG, '上传队列已暂停');
  2687. Logger.info(TAG, '====================================');
  2688. this.notifyObservers(RemoteDriveManagerStates.UploadPaused);
  2689. this.notifyObservers(RemoteDriveManagerStates.SetIsPauseUpload);
  2690. }
  2691. /**
  2692. * 恢复上传队列
  2693. */
  2694. public async resumeUploadQueue(): Promise<void> {
  2695. Logger.info(TAG, '========== 恢复上传队列 ==========');
  2696. if (!this.isPauseUpload) {
  2697. Logger.warn(TAG, '上传队列未暂停,无需恢复');
  2698. Logger.info(TAG, '====================================');
  2699. return;
  2700. }
  2701. this.isPauseUpload = false;
  2702. Logger.info(TAG, `剩余任务: ${this.uploadQueue.length}`);
  2703. Logger.info(TAG, '上传队列已恢复');
  2704. Logger.info(TAG, '====================================');
  2705. this.notifyObservers(RemoteDriveManagerStates.UploadResumed);
  2706. this.notifyObservers(RemoteDriveManagerStates.SetIsPauseUpload);
  2707. if (!this.isProcessingUploadQueue) {
  2708. this.isProcessingUploadQueue = true;
  2709. await this.processNextUploadTask();
  2710. }
  2711. }
  2712. /**
  2713. * 从队列中移除任务
  2714. * @param task 要移除的任务
  2715. */
  2716. public removeFromUploadQueue(task: TransferTask): void {
  2717. const index = this.uploadQueue.indexOf(task);
  2718. if (index >= 0) {
  2719. Logger.info(TAG, '========== 移除上传任务 ==========');
  2720. Logger.info(TAG, `文件名: ${task.song.name}`);
  2721. Logger.info(TAG, `队列位置: ${index + 1}/${this.uploadQueue.length}`);
  2722. this.uploadQueue.splice(index, 1);
  2723. Logger.info(TAG, `新队列大小: ${this.uploadQueue.length}`);
  2724. Logger.info(TAG, '====================================');
  2725. this.notifyObservers(RemoteDriveManagerStates.UploadQueueChanged);
  2726. this.notifyObservers(RemoteDriveManagerStates.ChangeUploadQueue);
  2727. } else {
  2728. Logger.warn(TAG, `任务不在队列中: ${task.song.name}`);
  2729. }
  2730. }
  2731. /**
  2732. * 清空上传队列
  2733. */
  2734. public clearUploadQueue(): void {
  2735. Logger.info(TAG, '========== 清空上传队列 ==========');
  2736. Logger.info(TAG, `清空前队列大小: ${this.uploadQueue.length}`);
  2737. this.uploadQueue = [];
  2738. this.currentUploadTask = null;
  2739. Logger.info(TAG, '上传队列已清空');
  2740. Logger.info(TAG, '====================================');
  2741. this.notifyObservers(RemoteDriveManagerStates.UploadQueueChanged);
  2742. this.notifyObservers(RemoteDriveManagerStates.ChangeUploadQueue);
  2743. this.notifyObservers(RemoteDriveManagerStates.SetCurrentUploadTask);
  2744. }
  2745. /**
  2746. * 检查是否允许在当前网络下上传
  2747. */
  2748. private async checkNetworkAllowed(): Promise<boolean> {
  2749. try {
  2750. Logger.info(TAG, '检查网络连接状态...');
  2751. const allowMobile = PreferencesUtil.getBooleanSync('webdavUploadAllowMobile', false);
  2752. Logger.info(TAG, `用户设置 - 允许移动网络上传: ${allowMobile}`);
  2753. // 如果允许移动网络上传,直接返回true
  2754. if (allowMobile) {
  2755. Logger.info(TAG, '用户已允许移动网络上传,跳过网络类型检查');
  2756. return true;
  2757. }
  2758. // 检查当前网络类型
  2759. Logger.info(TAG, '获取当前网络类型...');
  2760. const netHandle = await connection.getDefaultNet();
  2761. const netCapabilities = await connection.getNetCapabilities(netHandle);
  2762. // 检查是否为移动网络
  2763. const isCellular = netCapabilities.bearerTypes.includes(connection.NetBearType.BEARER_CELLULAR);
  2764. const isWifi = netCapabilities.bearerTypes.includes(connection.NetBearType.BEARER_WIFI);
  2765. const isEthernet = netCapabilities.bearerTypes.includes(connection.NetBearType.BEARER_ETHERNET);
  2766. Logger.info(TAG, `网络类型 - 移动网络: ${isCellular}, WiFi: ${isWifi}, 以太网: ${isEthernet}`);
  2767. if (isCellular) {
  2768. Logger.warn(TAG, '当前为移动网络,且未允许移动网络上传');
  2769. Logger.warn(TAG, '请连接WiFi或在设置中允许移动网络上传');
  2770. return false;
  2771. }
  2772. Logger.info(TAG, '网络检查通过,允许上传');
  2773. return true;
  2774. } catch (error) {
  2775. const err = error as Error;
  2776. Logger.error(TAG, '检查网络类型失败:', err.message);
  2777. Logger.warn(TAG, '网络检查失败,默认允许上传');
  2778. // 检查失败时默认允许上传
  2779. return true;
  2780. }
  2781. }
  2782. /**
  2783. * 上传单个文件
  2784. * @param task 上传任务
  2785. */
  2786. private async uploadSingleFile(task: TransferTask): Promise<void> {
  2787. const song = task.song;
  2788. const account = task.account;
  2789. const startTime = Date.now();
  2790. // 详细日志:上传开始
  2791. Logger.info(TAG, '========== 上传任务开始 ==========');
  2792. Logger.info(TAG, `文件名: ${song.name}`);
  2793. Logger.info(TAG, `文件路径: ${song.filePath}`);
  2794. Logger.info(TAG, `文件大小: ${song.videoSize || 0} 字节`);
  2795. Logger.info(TAG, `目标账户: ${account.name} (ID: ${account.id})`);
  2796. Logger.info(TAG, `目标主机: ${account.host}:${account.port}`);
  2797. Logger.info(TAG, `使用HTTPS: ${account.enableHttps}`);
  2798. Logger.info(TAG, `重试次数: ${task.retryCount || 0}`);
  2799. try {
  2800. // 检查网络是否允许上传
  2801. Logger.info(TAG, '检查网络连接状态...');
  2802. const networkAllowed = await this.checkNetworkAllowed();
  2803. if (!networkAllowed) {
  2804. const errorMsg = '当前网络不允许上传,请连接WiFi或在设置中允许移动网络上传';
  2805. Logger.error(TAG, `网络检查失败: ${errorMsg}`);
  2806. throw new Error(errorMsg);
  2807. }
  2808. Logger.info(TAG, '网络检查通过');
  2809. this.notifyObservers(RemoteDriveManagerStates.UploadStart);
  2810. // 构建远程路径 - 优先使用任务中的自定义路径,否则使用账户默认路径
  2811. const uploadPath = task.customUploadPath || account.uploadFilePath || '/';
  2812. const normalizedPath = this.normalizeFullPath(uploadPath);
  2813. const relativeSegment = this.sanitizeRelativeUploadPath(song.remote_rel_path);
  2814. const fallbackFileName = song.fileName || song.name || `upload-${Date.now()}`;
  2815. const relativePath = relativeSegment.length > 0 ? relativeSegment : fallbackFileName;
  2816. const remotePath = this.combineRemoteUploadPath(normalizedPath, relativePath);
  2817. Logger.info(TAG, '---------- 路径信息 ----------');
  2818. Logger.info(TAG, `任务自定义路径: ${task.customUploadPath || '未指定'}`);
  2819. Logger.info(TAG, `账户默认路径: ${account.uploadFilePath || '未配置'}`);
  2820. Logger.info(TAG, `实际使用路径: ${uploadPath}`);
  2821. Logger.info(TAG, `规范化后的路径: ${normalizedPath}`);
  2822. Logger.info(TAG, `文件名 (fileName): ${song.fileName}`);
  2823. Logger.info(TAG, `文件名 (name): ${song.name}`);
  2824. Logger.info(TAG, `文件相对路径: ${relativePath}`);
  2825. Logger.info(TAG, `最终远程路径: ${remotePath}`);
  2826. Logger.info(TAG, `完整URL将是: ${account.enableHttps ? 'https' : 'http'}://${account.host}:${account.port}${remotePath}`);
  2827. Logger.info(TAG, '------------------------------');
  2828. // 检查文件是否存在
  2829. Logger.info(TAG, '检查远程文件是否存在...');
  2830. const exists = await this.checkFileExists(account, remotePath);
  2831. if (exists) {
  2832. // 根据用户配置的重复文件处理方式来处理
  2833. const duplicateAction = PreferencesUtil.getStringSync('webdavUploadDuplicateAction', 'skip');
  2834. Logger.info(TAG, `文件已存在,处理方式: ${duplicateAction}`);
  2835. if (duplicateAction === 'skip') {
  2836. Logger.warn(TAG, '文件已存在,跳过上传:', remotePath);
  2837. return;
  2838. } else if (duplicateAction === 'overwrite') {
  2839. Logger.warn(TAG, '文件已存在,将覆盖:', remotePath);
  2840. } else if (duplicateAction === 'rename') {
  2841. Logger.warn(TAG, '文件已存在,将重命名上传');
  2842. // 重命名逻辑可以在这里实现
  2843. }
  2844. } else {
  2845. Logger.info(TAG, '远程文件不存在,可以上传');
  2846. }
  2847. const remoteDirectory = this.getDirectoryFromRemotePath(remotePath);
  2848. await this.ensureRemoteDirectories(account, remoteDirectory);
  2849. // 执行上传
  2850. Logger.info(TAG, '开始上传文件数据...');
  2851. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  2852. await this.rcpSocket.uploadFile(
  2853. song.filePath,
  2854. remotePath,
  2855. host,
  2856. account.port,
  2857. account.account,
  2858. account.password,
  2859. account.enableHttps,
  2860. (uploaded: number, total: number) => {
  2861. this.uploadReceivedSize = uploaded;
  2862. this.uploadTotalSize = total;
  2863. // 直接通知UI更新,因为RcpSocketUtil层已经做了节流
  2864. // 详细日志:上传进度
  2865. const progress = total > 0 ? Math.min(100, ((uploaded / total) * 100)) : 0;
  2866. const currentTime = Date.now();
  2867. const elapsedSeconds = (currentTime - startTime) / 1000;
  2868. const speedMBps = uploaded > 0 && elapsedSeconds > 0 ? (uploaded / (1024 * 1024) / elapsedSeconds).toFixed(2) : '0.00';
  2869. Logger.info(TAG, `上传进度: ${progress.toFixed(2)}%, 已上传: ${uploaded}/${total} 字节, 速度: ${speedMBps} MB/s`);
  2870. this.notifyObservers(RemoteDriveManagerStates.UploadProgress);
  2871. }
  2872. );
  2873. // 上传成功
  2874. const endTime = Date.now();
  2875. const duration = ((endTime - startTime) / 1000).toFixed(2);
  2876. const avgSpeed = song.videoSize > 0 ? ((song.videoSize / (1024 * 1024)) / parseFloat(duration)).toFixed(2) : '0.00';
  2877. Logger.info(TAG, '========== 上传任务成功 ==========');
  2878. Logger.info(TAG, `文件名: ${song.name}`);
  2879. Logger.info(TAG, `耗时: ${duration} 秒`);
  2880. Logger.info(TAG, `平均速度: ${avgSpeed} MB/s`);
  2881. Logger.info(TAG, `目标路径: ${remotePath}`);
  2882. Logger.info(TAG, '====================================');
  2883. } catch (error) {
  2884. const err = error as Error;
  2885. const endTime = Date.now();
  2886. const duration = ((endTime - startTime) / 1000).toFixed(2);
  2887. // 详细错误日志
  2888. Logger.error(TAG, '========== 上传任务失败 ==========');
  2889. Logger.error(TAG, `文件名: ${song.name}`);
  2890. Logger.error(TAG, `文件路径: ${song.filePath}`);
  2891. Logger.error(TAG, `目标账户: ${account.name} (ID: ${account.id})`);
  2892. Logger.error(TAG, `目标主机: ${account.host}:${account.port}`);
  2893. Logger.error(TAG, `耗时: ${duration} 秒`);
  2894. Logger.error(TAG, `错误类型: ${this.getErrorType(err)}`);
  2895. Logger.error(TAG, `错误信息: ${err.message}`);
  2896. Logger.error(TAG, `错误堆栈: ${err.stack || '无堆栈信息'}`);
  2897. Logger.error(TAG, `重试次数: ${task.retryCount || 0}`);
  2898. Logger.error(TAG, '====================================');
  2899. // 根据错误类型进行分类处理
  2900. this.handleUploadError(err, task);
  2901. // 重新抛出错误以便上层处理
  2902. throw err;
  2903. }
  2904. }
  2905. /**
  2906. * 上传百度网盘文件(使用TaskPool避免主线程阻塞)
  2907. * @param task 上传任务
  2908. */
  2909. private async uploadBaiduFile(task: TransferTask): Promise<void> {
  2910. const song = task.song;
  2911. const account = task.account;
  2912. const startTime = Date.now();
  2913. // 详细日志:百度网盘上传开始
  2914. Logger.info(TAG, '========== 百度网盘上传任务开始 ==========');
  2915. Logger.info(TAG, `文件名: ${song.name}`);
  2916. Logger.info(TAG, `文件路径: ${song.filePath}`);
  2917. Logger.info(TAG, `文件大小: ${song.videoSize || 0} 字节`);
  2918. Logger.info(TAG, `目标账户: ${account.name} (ID: ${account.id})`);
  2919. Logger.info(TAG, `重试次数: ${task.retryCount || 0}`);
  2920. try {
  2921. // 检查网络是否允许上传
  2922. Logger.info(TAG, '检查网络连接状态...');
  2923. const networkAllowed = await this.checkNetworkAllowed();
  2924. if (!networkAllowed) {
  2925. const errorMsg = '当前网络不允许上传,请连接WiFi或在设置中允许移动网络上传';
  2926. Logger.error(TAG, `网络检查失败: ${errorMsg}`);
  2927. throw new Error(errorMsg);
  2928. }
  2929. Logger.info(TAG, '网络检查通过');
  2930. this.notifyObservers(RemoteDriveManagerStates.UploadStart);
  2931. // 获取访问令牌
  2932. const accessToken = await this.ensureBaiduAccessToken(account);
  2933. if (!accessToken) {
  2934. throw new Error('百度网盘授权已过期,请重新登录');
  2935. }
  2936. // 构建上传路径
  2937. const uploadPath = task.customUploadPath || account.uploadFilePath || '/apps/ttmusic';
  2938. const normalizedPath = this.normalizeFullPath(uploadPath);
  2939. const fileName = song.fileName || song.name || `upload-${Date.now()}`;
  2940. const remotePath = `${normalizedPath === '/' ? '' : normalizedPath}/${fileName}`;
  2941. Logger.info(TAG, '---------- 百度网盘路径信息 ----------');
  2942. Logger.info(TAG, `上传基础路径: ${uploadPath}`);
  2943. Logger.info(TAG, `规范化后的路径: ${normalizedPath}`);
  2944. Logger.info(TAG, `文件名: ${fileName}`);
  2945. Logger.info(TAG, `最终远程路径: ${remotePath}`);
  2946. Logger.info(TAG, '------------------------------');
  2947. // 转换路径为百度网盘要求的格式
  2948. const baiduRemotePath = convertToBaiduPath(remotePath);
  2949. Logger.info(TAG, `原始路径: ${remotePath}, 转换后路径: ${baiduRemotePath}`);
  2950. // 使用TaskPool执行上传任务(支持进度消息)
  2951. Logger.info(TAG, '创建TaskPool上传任务...');
  2952. const taskParams: TaskBaiduUploadParams = {
  2953. filePath: song.filePath,
  2954. accessToken: accessToken,
  2955. remotePath: baiduRemotePath,
  2956. songName: song.name,
  2957. songId: song.id || `file-${Date.now()}`
  2958. };
  2959. const uploadTask: taskpool.Task = new taskpool.Task(executeBaiduUpload, taskParams);
  2960. // 设置进度消息接收器
  2961. uploadTask.onReceiveData((progressData: BaiduUploadProgressMessage) => {
  2962. // 更新上传进度
  2963. this.uploadReceivedSize = progressData.uploadedSize || 0;
  2964. this.uploadTotalSize = progressData.totalSize || 0;
  2965. Logger.info(TAG, `百度网盘上传进度: ${progressData.progress.toFixed(1)}% (${progressData.currentPart || 0}/${progressData.totalParts || 0} ${progressData.stage})`);
  2966. this.notifyObservers(RemoteDriveManagerStates.UploadProgress);
  2967. });
  2968. // 提交任务到TaskPool
  2969. Logger.info(TAG, '提交上传任务到TaskPool...');
  2970. const result = await taskpool.execute(uploadTask) as TaskBaiduUploadResult;
  2971. // 检查上传结果
  2972. if (!result.success) {
  2973. const errorMessage = result.error || '上传失败';
  2974. throw new Error(errorMessage);
  2975. }
  2976. // 上传成功
  2977. const endTime = Date.now();
  2978. const duration = ((endTime - startTime) / 1000).toFixed(2);
  2979. Logger.info(TAG, '========== 百度网盘上传任务成功 ==========');
  2980. Logger.info(TAG, `文件名: ${song.name}`);
  2981. Logger.info(TAG, `文件路径: ${song.filePath}`);
  2982. Logger.info(TAG, `目标路径: ${remotePath}`);
  2983. Logger.info(TAG, `耗时: ${duration} 秒`);
  2984. Logger.info(TAG, '====================================');
  2985. // 更新上传进度到100%
  2986. this.notifyObservers(RemoteDriveManagerStates.UploadProgress);
  2987. } catch (error) {
  2988. const err = error as Error;
  2989. const endTime = Date.now();
  2990. const duration = ((endTime - startTime) / 1000).toFixed(2);
  2991. // 详细错误日志
  2992. Logger.error(TAG, '========== 百度网盘上传任务失败 ==========');
  2993. Logger.error(TAG, `文件名: ${song.name}`);
  2994. Logger.error(TAG, `文件路径: ${song.filePath}`);
  2995. Logger.error(TAG, `目标账户: ${account.name} (ID: ${account.id})`);
  2996. Logger.error(TAG, `耗时: ${duration} 秒`);
  2997. Logger.error(TAG, `错误信息: ${err.message}`);
  2998. Logger.error(TAG, `错误堆栈: ${err.stack || '无堆栈信息'}`);
  2999. Logger.error(TAG, `重试次数: ${task.retryCount || 0}`);
  3000. Logger.error(TAG, '====================================');
  3001. // 根据错误类型进行分类处理
  3002. this.handleUploadError(err, task);
  3003. // 重新抛出错误以便上层处理
  3004. throw err;
  3005. }
  3006. }
  3007. /**
  3008. * 获取错误类型
  3009. * @param error 错误对象
  3010. * @returns 错误类型字符串
  3011. */
  3012. private getErrorType(error: Error): string {
  3013. const message = error.message.toLowerCase();
  3014. if (message.includes('network') || message.includes('连接') || message.includes('timeout') || message.includes('超时')) {
  3015. return '网络错误';
  3016. } else if (message.includes('auth') || message.includes('认证') || message.includes('401') || message.includes('403')) {
  3017. return '认证错误';
  3018. } else if (message.includes('file') || message.includes('文件') || message.includes('not found') || message.includes('不存在')) {
  3019. return '文件错误';
  3020. } else if (message.includes('server') || message.includes('服务器') || message.includes('500') || message.includes('503')) {
  3021. return '服务器错误';
  3022. } else if (message.includes('space') || message.includes('空间') || message.includes('disk') || message.includes('磁盘')) {
  3023. return '存储空间错误';
  3024. } else {
  3025. return '未知错误';
  3026. }
  3027. }
  3028. /**
  3029. * 处理上传错误
  3030. * @param error 错误对象
  3031. * @param task 上传任务
  3032. */
  3033. private handleUploadError(error: Error, task: TransferTask): void {
  3034. const errorType = this.getErrorType(error);
  3035. Logger.info(TAG, `处理上传错误,类型: ${errorType}`);
  3036. // 根据错误类型决定是否自动暂停
  3037. if (errorType === '网络错误') {
  3038. Logger.warn(TAG, '检测到网络错误,自动暂停上传队列');
  3039. this.pauseUploadQueue();
  3040. } else if (errorType === '认证错误') {
  3041. Logger.error(TAG, '检测到认证错误,请检查账户密码是否正确');
  3042. // 认证错误通常不需要暂停整个队列,但应该提示用户
  3043. } else if (errorType === '服务器错误') {
  3044. Logger.error(TAG, '检测到服务器错误,服务器可能暂时不可用');
  3045. // 服务器错误可能是临时的,可以继续尝试其他文件
  3046. } else if (errorType === '存储空间错误') {
  3047. Logger.error(TAG, '检测到存储空间不足,暂停上传队列');
  3048. this.pauseUploadQueue();
  3049. }
  3050. }
  3051. /**
  3052. * 检查文件是否存在
  3053. * @param account WebDAV账户
  3054. * @param remotePath 远程路径
  3055. */
  3056. private async checkFileExists(account: WebDavAccount, remotePath: string): Promise<boolean> {
  3057. try {
  3058. Logger.info(TAG, `检查远程文件是否存在: ${remotePath}`);
  3059. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  3060. const fileSize = await this.rcpSocket.RcpSendHead(
  3061. host,
  3062. account.port,
  3063. account.account,
  3064. account.password,
  3065. remotePath,
  3066. account.enableHttps
  3067. );
  3068. const exists = fileSize > 0;
  3069. Logger.info(TAG, `文件${exists ? '存在' : '不存在'}, 大小: ${fileSize} 字节`);
  3070. return exists;
  3071. } catch (error) {
  3072. const err = error as Error;
  3073. // HEAD请求失败,说明文件不存在或网络错误
  3074. Logger.info(TAG, `HEAD请求失败: ${err.message}, 假定文件不存在`);
  3075. return false;
  3076. }
  3077. }
  3078. /**
  3079. * 处理重复文件
  3080. * @param account WebDAV账户
  3081. * @param remotePath 远程路径
  3082. * @param action 处理动作:'skip' | 'overwrite' | 'rename'
  3083. */
  3084. private async handleDuplicateFile(
  3085. account: WebDavAccount,
  3086. remotePath: string,
  3087. action: string
  3088. ): Promise<string> {
  3089. if (action === 'skip') {
  3090. throw new Error('文件已存在,跳过上传');
  3091. }
  3092. if (action === 'overwrite') {
  3093. // 删除现有文件
  3094. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  3095. await this.rcpSocket.RcpSendDelete(
  3096. host,
  3097. account.port,
  3098. account.account,
  3099. account.password,
  3100. remotePath,
  3101. account.enableHttps
  3102. );
  3103. return remotePath;
  3104. }
  3105. if (action === 'rename') {
  3106. // 生成新文件名
  3107. const lastDotIndex = remotePath.lastIndexOf('.');
  3108. const lastSlashIndex = remotePath.lastIndexOf('/');
  3109. let basePath = '';
  3110. let fileName = '';
  3111. let extension = '';
  3112. if (lastSlashIndex >= 0) {
  3113. basePath = remotePath.substring(0, lastSlashIndex + 1);
  3114. fileName = remotePath.substring(lastSlashIndex + 1);
  3115. } else {
  3116. fileName = remotePath;
  3117. }
  3118. if (lastDotIndex > lastSlashIndex) {
  3119. extension = fileName.substring(lastDotIndex);
  3120. fileName = fileName.substring(0, lastDotIndex - lastSlashIndex - 1);
  3121. }
  3122. // 尝试添加序号
  3123. let counter = 1;
  3124. let newPath = '';
  3125. let exists = true;
  3126. while (exists && counter < 100) {
  3127. newPath = `${basePath}${fileName}(${counter})${extension}`;
  3128. exists = await this.checkFileExists(account, newPath);
  3129. counter++;
  3130. }
  3131. if (exists) {
  3132. throw new Error('无法生成唯一文件名');
  3133. }
  3134. return newPath;
  3135. }
  3136. return remotePath;
  3137. }
  3138. // ==================== 安全认证方法 ====================
  3139. // 获取WebDAV认证信息(用于播放器)
  3140. public async getWebDavAuthHeaders(accountId: string): Promise<WebDavAuthInfo | null> {
  3141. const account = await this.getWebDavAccountById(accountId);
  3142. if (!account || !account.account || !account.password) {
  3143. Logger.error(TAG, '无效的WebDAV账户或缺少认证信息');
  3144. return null;
  3145. }
  3146. // 构建基础URL
  3147. const protocol: string = account.enableHttps ? 'https' : 'http';
  3148. const host: string = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  3149. const port: number = account.port;
  3150. // 构建认证头
  3151. const credentials = buffer
  3152. .from(`${account.account}:${account.password}`)
  3153. .toString("base64");
  3154. const authInfo: WebDavAuthInfo = {
  3155. headers: {
  3156. 'Authorization': `Basic ${credentials}`,
  3157. 'User-Agent': 'TTMusic/1.0'
  3158. },
  3159. url: `${protocol}://${host}:${port}`,
  3160. accountId: accountId
  3161. };
  3162. return authInfo;
  3163. }
  3164. /**
  3165. * 在百度网盘中创建文件夹
  3166. * @param account 百度网盘账户
  3167. * @param folderName 要创建的文件夹名称
  3168. * @param parentPath 父级路径,默认为当前路径
  3169. * @returns Promise<string> 创建成功后的完整路径
  3170. */
  3171. public async createBaiduFolder(account: WebDavAccount, folderName: string, parentPath?: string): Promise<string> {
  3172. if (!account || account.webType !== RemoteDriveType.Baidu) {
  3173. throw new Error('只支持在百度网盘账户中创建文件夹');
  3174. }
  3175. if (!folderName || folderName.trim().length === 0) {
  3176. throw new Error('文件夹名称不能为空');
  3177. }
  3178. // 检查文件夹名称是否包含非法字符
  3179. const invalidChars = /[\\/:*?"<>|]/;
  3180. if (invalidChars.test(folderName.trim())) {
  3181. throw new Error('文件夹名称不能包含 \\ / : * ? " < > | 字符');
  3182. }
  3183. const accessToken = account.baiduAccessToken;
  3184. if (!accessToken) {
  3185. throw new Error('百度网盘授权已过期,请重新登录');
  3186. }
  3187. // 确定父级路径
  3188. const basePath = parentPath || this.currentPath;
  3189. // 构建完整的文件夹路径
  3190. const fullPath = basePath === '' || basePath === '/'
  3191. ? `/${folderName.trim()}`
  3192. : `${basePath.replace(/\/$/, '')}/${folderName.trim()}`;
  3193. Logger.info(TAG, `heanup 准备在百度网盘创建文件夹: ${fullPath}`);
  3194. try {
  3195. const result = await createFolder(accessToken, fullPath, 1); // rtype=1表示自动重命名
  3196. Logger.info(TAG, `heanup 百度网盘文件夹创建成功: ${result.path}`);
  3197. return result.path || fullPath;
  3198. } catch (error) {
  3199. const err = error as Error;
  3200. Logger.error(TAG, `heanup 百度网盘文件夹创建失败: ${err.message}`);
  3201. throw err;
  3202. }
  3203. }
  3204. /**
  3205. * 在WebDAV服务器上创建文件夹
  3206. * @param account WebDAV账户
  3207. * @param folderName 要创建的文件夹名称
  3208. * @param parentPath 父级路径,默认为当前路径
  3209. * @returns Promise<string> 创建成功后的完整路径
  3210. */
  3211. public async createWebDavFolder(account: WebDavAccount, folderName: string, parentPath?: string): Promise<string> {
  3212. if (!account || account.webType !== RemoteDriveType.WebDav) {
  3213. throw new Error('只支持在WebDAV账户中创建文件夹');
  3214. }
  3215. if (!folderName || folderName.trim().length === 0) {
  3216. throw new Error('文件夹名称不能为空');
  3217. }
  3218. // 检查文件夹名称是否包含非法字符
  3219. const invalidChars = /[\\/:*?"<>|]/;
  3220. if (invalidChars.test(folderName.trim())) {
  3221. throw new Error('文件夹名称不能包含 \\ / : * ? " < > | 字符');
  3222. }
  3223. // 确定服务器地址
  3224. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  3225. if (!host || host.length === 0) {
  3226. throw new Error('WebDAV账户缺少服务器地址');
  3227. }
  3228. if (!account.account || account.account.length === 0) {
  3229. throw new Error('WebDAV账户缺少用户名');
  3230. }
  3231. if (!account.password || account.password.length === 0) {
  3232. throw new Error('WebDAV账户缺少密码');
  3233. }
  3234. // 确定父级路径
  3235. const basePath = parentPath || this.currentPath;
  3236. // 构建完整的文件夹路径
  3237. const fullPath = basePath === '' || basePath === '/'
  3238. ? `/${folderName.trim()}`
  3239. : `${basePath.replace(/\/$/, '')}/${folderName.trim()}`;
  3240. Logger.info(TAG, `heanup 准备在WebDAV服务器创建文件夹: ${fullPath}`);
  3241. try {
  3242. // 调用RcpSocket的createDirectory方法(使用MKCOL协议)
  3243. await this.rcpSocket.createDirectory(
  3244. host,
  3245. account.port,
  3246. account.account,
  3247. account.password,
  3248. fullPath,
  3249. account.enableHttps
  3250. );
  3251. Logger.info(TAG, `heanup WebDAV文件夹创建成功: ${fullPath}`);
  3252. return fullPath;
  3253. } catch (error) {
  3254. const err = error as Error;
  3255. Logger.error(TAG, `heanup WebDAV文件夹创建失败: ${err.message}`);
  3256. throw err;
  3257. }
  3258. }
  3259. /**
  3260. * 在SMB服务器上创建文件夹
  3261. * @param account SMB账户
  3262. * @param folderName 要创建的文件夹名称
  3263. * @param parentPath 父级路径,默认为当前路径
  3264. * @returns Promise<string> 创建成功后的完整路径
  3265. */
  3266. public async createSMBFolder(account: WebDavAccount, folderName: string, parentPath?: string): Promise<string> {
  3267. if (!account || account.webType !== RemoteDriveType.Smb) {
  3268. throw new Error('只支持在SMB账户中创建文件夹');
  3269. }
  3270. if (!folderName || folderName.trim().length === 0) {
  3271. throw new Error('文件夹名称不能为空');
  3272. }
  3273. // 检查文件夹名称是否包含非法字符
  3274. const invalidChars = /[\\/:*?"<>|]/;
  3275. if (invalidChars.test(folderName.trim())) {
  3276. throw new Error('文件夹名称不能包含 \\ / : * ? " < > | 字符');
  3277. }
  3278. // 确定服务器地址
  3279. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  3280. if (!host || host.length === 0) {
  3281. throw new Error('SMB账户缺少服务器地址');
  3282. }
  3283. if (!account.smbShare || account.smbShare.length === 0) {
  3284. throw new Error('SMB账户缺少共享名称');
  3285. }
  3286. // 确定父级路径
  3287. const basePath = parentPath || this.currentPath;
  3288. // 构建完整的文件夹路径
  3289. const fullPath = basePath === '' || basePath === '/'
  3290. ? `${folderName.trim()}`
  3291. : `${basePath.replace(/\/$/, '').replace(/^\/+/, '')}/${folderName.trim()}`;
  3292. Logger.info(TAG, `heanup 准备在SMB服务器创建文件夹: ${fullPath}`);
  3293. try {
  3294. // 调用createSmbDirectory函数
  3295. await createSmbDirectory({
  3296. host,
  3297. share: account.smbShare,
  3298. username: account.account || '',
  3299. password: account.password || '',
  3300. domain: account.smbDomain,
  3301. path: fullPath
  3302. });
  3303. Logger.info(TAG, `heanup SMB文件夹创建成功: ${fullPath}`);
  3304. return `/${fullPath}`;
  3305. } catch (error) {
  3306. const err = error as Error;
  3307. Logger.error(TAG, `heanup SMB文件夹创建失败: ${err.message}`);
  3308. throw err;
  3309. }
  3310. }
  3311. }
  3312. /**
  3313. * 构建HTTP请求头,特别处理WebDAV认证
  3314. * @param currentSong - 当前播放的歌曲信息
  3315. * @param videoUrl - 当前歌曲的URL
  3316. * @param webDavAuthItem - 当前WebDAV认证信息(实例变量)
  3317. * @returns Promise<Map<string, string>> HTTP请求头
  3318. */
  3319. export async function buildHttpHeadersWithWebDav(
  3320. currentSong: VideoItem | undefined,
  3321. videoUrl: string,
  3322. webDavAuthItem: WebDavAuthItem
  3323. ): Promise<Map<string, string>> {
  3324. const headers = new Map<string, string>();
  3325. let isWebDavSong = false;
  3326. if (currentSong) {
  3327. Logger.info(`heanup 当前歌曲信息 - name: ${currentSong.name}, type: ${currentSong.type}, filePath: ${currentSong.filePath}`);
  3328. // 检查是否为WebDAV歌曲
  3329. if (currentSong.type === CommonConstants.TYPE_WEBDAV) {
  3330. isWebDavSong = true;
  3331. // 优先从实例变量获取认证信息
  3332. if (webDavAuthItem) {
  3333. Logger.info(`heanup 从实例变量获取到WebDAV认证信息,账户ID: ${webDavAuthItem.accountId}`);
  3334. } else {
  3335. // 回退到全局上下文
  3336. try {
  3337. const globalContext = GlobalContext.getContext();
  3338. webDavAuthItem = globalContext.getObject('webDavAuthInfo') as WebDavAuthItem;
  3339. if (webDavAuthItem) {
  3340. Logger.info(`heanup 从全局上下文获取到WebDAV认证信息,账户ID: ${webDavAuthItem.accountId}`);
  3341. } else {
  3342. Logger.warn(`heanup 全局上下文中没有WebDAV认证信息`);
  3343. }
  3344. } catch (error) {
  3345. Logger.error(`heanup 获取全局上下文失败:`, error.toString());
  3346. }
  3347. }
  3348. // 如果识别为WebDAV但没有认证信息,记录警告
  3349. if (!webDavAuthItem) {
  3350. Logger.warn(`heanup 识别为WebDAV播放但缺少认证信息,可能需要手动配置WebDAV账户`);
  3351. }
  3352. }
  3353. if (isWebDavSong && webDavAuthItem) {
  3354. Logger.info(`heanup 检测到WebDAV播放,添加安全认证头`);
  3355. Logger.info(`heanup 歌曲URL: ${videoUrl}`);
  3356. try {
  3357. if (webDavAuthItem && webDavAuthItem.accountId) {
  3358. Logger.info(`heanup 找到WebDAV认证信息,账户ID: ${webDavAuthItem.accountId}`);
  3359. // 使用RemoteDriveManager获取认证头
  3360. const webdavManager = RemoteDriveManager.getInstance();
  3361. const authHeaders = await webdavManager.getWebDavAuthHeaders(webDavAuthItem.accountId.toString());
  3362. if (authHeaders) {
  3363. // 添加Basic认证头(使用规范字段名)
  3364. headers.set("Authorization", authHeaders.headers.Authorization);
  3365. } else {
  3366. Logger.error(`heanup 无法获取WebDAV认证头`);
  3367. }
  3368. } else {
  3369. Logger.error(`heanup WebDAV认证信息无效或缺少accountId`);
  3370. }
  3371. } catch (error) {
  3372. Logger.error(`heanup 获取WebDAV认证信息失败:`, error.toString());
  3373. }
  3374. // 添加标准的WebDAV请求头
  3375. headers.set("User-Agent", "TTMusic-WebDAV/1.0");
  3376. headers.set("Accept", "*/*");
  3377. // 请求首段数据,触发服务器返回 206(部分内容)以适配流式播放
  3378. headers.set("Range", "bytes=0-");
  3379. Logger.info(`heanup WebDAV安全认证头设置完成`);
  3380. }
  3381. }
  3382. // 输出所有设置的头部信息用于调试
  3383. console.log(`heanup 设置的HTTP头部信息:`);
  3384. const headerIterator = headers.entries();
  3385. let headerEntry = headerIterator.next();
  3386. while (!headerEntry.done) {
  3387. const key = headerEntry.value[0];
  3388. const value = headerEntry.value[1];
  3389. console.log(`heanup ${key}: ${value}`);
  3390. headerEntry = headerIterator.next();
  3391. }
  3392. return headers;
  3393. }
  3394. /**
  3395. * WebDAV认证信息(LocalMusic专用)
  3396. */
  3397. export interface WebDavAuthItem {
  3398. accountId: number;
  3399. host: string;
  3400. port: number;
  3401. account: string;
  3402. password: string;
  3403. enableHttps: boolean;
  3404. }