library.service.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. import Store from 'electron-store'
  2. import type { WallpaperMeta, WallpaperType, ContentRating, LibraryFilters, WallpaperFolder } from '@shared/types'
  3. import { getWorkshopPath } from '../utils/paths'
  4. import * as path from 'path'
  5. import * as fs from 'fs'
  6. import { randomUUID } from 'crypto'
  7. import { cleanupPlaylists } from './playlist.service'
  8. import {
  9. getWorkshopTimesUpdated,
  10. getWorkshopTags,
  11. getWorkshopAuthors,
  12. getUnavailableWorkshopItems
  13. } from './workshop.service'
  14. import { findAndMutate } from '../utils/collections'
  15. import { isSteamRunning, getItemDownloadStatus, getSubscribedItems } from './steam.service'
  16. interface LibraryStore {
  17. wallpapers: Record<string, WallpaperMeta>
  18. tags: string[]
  19. folders: WallpaperFolder[]
  20. }
  21. const store = new Store<LibraryStore>({
  22. name: 'library',
  23. defaults: {
  24. wallpapers: {},
  25. tags: [],
  26. folders: []
  27. }
  28. })
  29. export function initLibrary(): void {
  30. console.log('[Library] Store initialized at', store.path)
  31. }
  32. export function getAllWallpapers(filters?: LibraryFilters): WallpaperMeta[] {
  33. const all = Object.values(store.get('wallpapers'))
  34. let results = all
  35. if (filters?.type) {
  36. results = results.filter((w) => w.type === filters.type)
  37. }
  38. if (filters?.searchText) {
  39. const q = filters.searchText.toLowerCase()
  40. results = results.filter(
  41. (w) => w.title.toLowerCase().includes(q) || w.description?.toLowerCase().includes(q)
  42. )
  43. }
  44. if (filters?.contentRating) {
  45. results = results.filter((w) => w.contentRating === filters.contentRating)
  46. }
  47. if (filters?.tags && filters.tags.length > 0) {
  48. results = results.filter((w) => filters.tags!.every((tag) => w.tags.includes(tag)))
  49. }
  50. if (filters?.categories && filters.categories.length > 0) {
  51. results = results.filter((w) => filters.categories!.some((cat) => w.categories.includes(cat)))
  52. }
  53. const sortBy = filters?.sortBy ?? 'updatedAt'
  54. const dir = filters?.sortDir === 'asc' ? 1 : -1
  55. results = [...results].sort((a, b) => {
  56. switch (sortBy) {
  57. case 'title':
  58. return dir * a.title.localeCompare(b.title)
  59. case 'createdAt':
  60. return dir * (a.createdAt - b.createdAt)
  61. case 'updatedAt':
  62. return dir * (a.updatedAt - b.updatedAt)
  63. case 'lastApplied':
  64. return dir * ((a.lastAppliedAt ?? 0) - (b.lastAppliedAt ?? 0))
  65. case 'appliedCount':
  66. return dir * (a.appliedCount - b.appliedCount)
  67. case 'fileSize':
  68. return dir * ((a.fileSize ?? 0) - (b.fileSize ?? 0))
  69. default:
  70. return dir * (a.updatedAt - b.updatedAt)
  71. }
  72. })
  73. return results
  74. }
  75. export function getWallpaper(id: string): WallpaperMeta | null {
  76. const wallpapers = store.get('wallpapers')
  77. return wallpapers[id] ?? null
  78. }
  79. export function upsertWallpaper(meta: WallpaperMeta): void {
  80. const wallpapers = store.get('wallpapers')
  81. wallpapers[meta.id] = meta
  82. store.set('wallpapers', wallpapers)
  83. }
  84. // updatedAt tracks the workshop item's own timestamp (set in buildMeta), not local edits.
  85. export function updateWallpaper(id: string, patch: Partial<WallpaperMeta>): void {
  86. const current = getWallpaper(id)
  87. if (!current) throw new Error(`Wallpaper ${id} not found`)
  88. upsertWallpaper({ ...current, ...patch, updatedAt: current.updatedAt })
  89. }
  90. export function deleteWallpaper(id: string): void {
  91. const wallpapers = store.get('wallpapers')
  92. delete wallpapers[id]
  93. store.set('wallpapers', wallpapers)
  94. }
  95. export function resetAllFpsOverrides(): { count: number } {
  96. const wallpapers = store.get('wallpapers')
  97. let count = 0
  98. for (const id of Object.keys(wallpapers)) {
  99. if (wallpapers[id].fpsOverride !== undefined) {
  100. delete wallpapers[id].fpsOverride
  101. count++
  102. }
  103. }
  104. store.set('wallpapers', wallpapers)
  105. return { count }
  106. }
  107. export function addTag(wallpaperId: string, tagName: string): void {
  108. const wallpaper = getWallpaper(wallpaperId)
  109. if (!wallpaper) return
  110. if (!wallpaper.tags.includes(tagName)) {
  111. wallpaper.tags.push(tagName)
  112. upsertWallpaper(wallpaper)
  113. }
  114. const tags = store.get('tags')
  115. if (!tags.includes(tagName)) {
  116. tags.push(tagName)
  117. store.set('tags', tags.sort())
  118. }
  119. }
  120. export function removeTag(wallpaperId: string, tagName: string): void {
  121. const wallpaper = getWallpaper(wallpaperId)
  122. if (!wallpaper) return
  123. wallpaper.tags = wallpaper.tags.filter((t) => t !== tagName)
  124. upsertWallpaper(wallpaper)
  125. }
  126. export function getAllTags(): string[] {
  127. return store.get('tags')
  128. }
  129. export function getDistinctTags(): string[] {
  130. const all = Object.values(store.get('wallpapers'))
  131. const set = new Set<string>()
  132. for (const w of all) {
  133. for (const t of w.tags) set.add(t)
  134. }
  135. return [...set].sort()
  136. }
  137. export interface ProjectJson {
  138. title?: string
  139. type?: string
  140. file?: string
  141. tags?: string[]
  142. description?: string
  143. preview?: string
  144. contentrating?: string
  145. workshopid?: string
  146. }
  147. export function readProjectJson(localPath: string): ProjectJson | null {
  148. try {
  149. const raw = fs.readFileSync(path.join(localPath, 'project.json'), 'utf8')
  150. return JSON.parse(raw) as ProjectJson
  151. } catch {
  152. return null
  153. }
  154. }
  155. export function normalizeType(raw?: string): WallpaperType {
  156. switch (raw?.toLowerCase()) {
  157. case 'video': return 'video'
  158. case 'web': return 'web'
  159. case 'application': return 'application'
  160. default: return 'scene'
  161. }
  162. }
  163. export function normalizeRating(raw?: string): ContentRating {
  164. switch (raw?.toLowerCase()) {
  165. case 'everyone': return 'everyone'
  166. case 'questionable': return 'questionable'
  167. case 'mature': return 'mature'
  168. default: return 'uncategorized'
  169. }
  170. }
  171. // Derived from the item's Steam Workshop tags for wallpapers that never finished
  172. // downloading (no local project.json), so failed downloads still land in the
  173. // right type/rating filters instead of "uncategorized".
  174. function deriveTypeFromTags(tags: string[]): WallpaperType | undefined {
  175. const match = tags.find((t) => ['Video', 'Web', 'Application', 'Scene'].includes(t))
  176. return match ? normalizeType(match) : undefined
  177. }
  178. function deriveRatingFromTags(tags: string[]): ContentRating | undefined {
  179. const match = tags.find((t) => ['Everyone', 'Questionable', 'Mature'].includes(t))
  180. return match ? normalizeRating(match) : undefined
  181. }
  182. async function safeGetWorkshopTags(ids: string[]): Promise<Map<string, string[]>> {
  183. try {
  184. return await getWorkshopTags(ids)
  185. } catch {
  186. return new Map()
  187. }
  188. }
  189. async function safeGetWorkshopAuthors(ids: string[]): Promise<Map<string, string>> {
  190. try {
  191. return await getWorkshopAuthors(ids)
  192. } catch {
  193. return new Map()
  194. }
  195. }
  196. export function getDirSize(dirPath: string): number {
  197. let total = 0
  198. try {
  199. for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
  200. const full = path.join(dirPath, entry.name)
  201. if (entry.isDirectory()) {
  202. total += getDirSize(full)
  203. } else if (entry.isFile()) {
  204. try { total += fs.statSync(full).size } catch { /* skip */ }
  205. }
  206. }
  207. } catch { /* skip unreadable dirs */ }
  208. return total
  209. }
  210. async function safeGetWorkshopTimesUpdated(ids: string[]): Promise<Map<string, number>> {
  211. try {
  212. return await getWorkshopTimesUpdated(ids)
  213. } catch {
  214. return new Map()
  215. }
  216. }
  217. function buildMeta(
  218. workshopId: string,
  219. localPath: string,
  220. pj: ProjectJson | null,
  221. existing?: WallpaperMeta,
  222. workshopTimeUpdated?: number,
  223. authorSteamId?: string
  224. ): WallpaperMeta {
  225. const now = Date.now()
  226. const { downloading: _d, downloadFailed: _df, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
  227. // project.json can land on disk before the preview image it references has
  228. // finished downloading (the watcher imports shortly after the folder shows
  229. // up) - pointing previewLocal at a file that doesn't exist yet makes the
  230. // wallpaper:// protocol handler fail with net::ERR_FILE_NOT_FOUND. Leaving
  231. // it unset here falls back to the "No preview" placeholder, and the next
  232. // library scan picks up the real file once it exists.
  233. const previewPath = pj?.preview ? path.join(localPath, pj.preview) : undefined
  234. return {
  235. ...existingRest,
  236. id: workshopId,
  237. title: pj?.title ?? `Wallpaper ${workshopId}`,
  238. type: normalizeType(pj?.type),
  239. contentRating: normalizeRating(pj?.contentrating),
  240. description: pj?.description,
  241. previewLocal: previewPath && fs.existsSync(previewPath) ? previewPath : undefined,
  242. previewUrl: undefined,
  243. localPath,
  244. file: pj?.file,
  245. fileSize: getDirSize(localPath),
  246. createdAt: existing?.createdAt ?? now,
  247. updatedAt: workshopTimeUpdated ? workshopTimeUpdated * 1000 : (existing?.updatedAt ?? now),
  248. subscribed: true,
  249. source: 'workshop',
  250. tags: pj?.tags ?? existing?.tags ?? [],
  251. authorSteamId: authorSteamId ?? existing?.authorSteamId
  252. }
  253. }
  254. function buildIncompleteMeta(
  255. workshopId: string,
  256. localPath: string,
  257. existing?: WallpaperMeta,
  258. workshopTags?: string[],
  259. authorSteamId?: string
  260. ): WallpaperMeta {
  261. const now = Date.now()
  262. const { downloading, failed } = isSteamRunning()
  263. ? getItemDownloadStatus(BigInt(workshopId))
  264. : { downloading: true, failed: false }
  265. const tags = workshopTags
  266. ? [...new Set([...(existing?.tags ?? []), ...workshopTags])]
  267. : (existing?.tags ?? [])
  268. return {
  269. id: workshopId,
  270. title: existing?.title ?? `Wallpaper ${workshopId}`,
  271. type: (workshopTags && deriveTypeFromTags(workshopTags)) ?? existing?.type ?? 'scene',
  272. contentRating:
  273. (workshopTags && deriveRatingFromTags(workshopTags)) ?? existing?.contentRating ?? 'uncategorized',
  274. localPath,
  275. createdAt: existing?.createdAt ?? now,
  276. updatedAt: now,
  277. subscribed: true,
  278. appliedCount: existing?.appliedCount ?? 0,
  279. source: 'workshop',
  280. tags,
  281. categories: existing?.categories ?? [],
  282. downloading,
  283. downloadFailed: failed,
  284. authorSteamId: authorSteamId ?? existing?.authorSteamId
  285. }
  286. }
  287. export async function importWallpaperById(workshopId: string): Promise<WallpaperMeta | null> {
  288. const localPath = path.join(getWorkshopPath(), workshopId)
  289. if (!fs.existsSync(localPath)) return null
  290. const existing = getWallpaper(workshopId) ?? undefined
  291. const authorSteamId =
  292. existing?.authorSteamId ?? (await safeGetWorkshopAuthors([workshopId])).get(workshopId)
  293. const pj = readProjectJson(localPath)
  294. if (!pj) {
  295. const tags = await safeGetWorkshopTags([workshopId])
  296. const meta = buildIncompleteMeta(workshopId, localPath, existing, tags.get(workshopId), authorSteamId)
  297. upsertWallpaper(meta)
  298. return meta
  299. }
  300. const timesUpdated = await safeGetWorkshopTimesUpdated([workshopId])
  301. const meta = buildMeta(workshopId, localPath, pj, existing, timesUpdated.get(workshopId), authorSteamId)
  302. upsertWallpaper(meta)
  303. return meta
  304. }
  305. export async function scanLibrary(): Promise<{ imported: number; skipped: number; removed: number }> {
  306. const workshopPath = getWorkshopPath()
  307. if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0, removed: 0 }
  308. const entries = fs.readdirSync(workshopPath, { withFileTypes: true })
  309. const onDisk = new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
  310. const subscribedIds = isSteamRunning() ? new Set(getSubscribedItems()) : new Set<string>()
  311. // Load entire store once, mutate in memory, write once at the end
  312. const wallpapers = store.get('wallpapers')
  313. let imported = 0
  314. let skipped = 0
  315. let removed = 0
  316. // Remove workshop wallpapers whose directories no longer exist on disk, unless Steam
  317. // still has them subscribed (those get tracked below instead, so a failed
  318. // or never-started download doesn't just vanish from the library). Only source:'workshop'
  319. // entries live under workshopPath - local imports and backups (source:'local'/'backup') are
  320. // never on disk here, so including them in this check would delete them on every scan.
  321. for (const id of Object.keys(wallpapers)) {
  322. if (wallpapers[id].source !== 'workshop') continue
  323. if (!onDisk.has(id) && !subscribedIds.has(id)) {
  324. delete wallpapers[id]
  325. removed++
  326. }
  327. }
  328. const incomplete: { id: string; localPath: string }[] = []
  329. const toRebuild: { entry: fs.Dirent; localPath: string; pj: ProjectJson }[] = []
  330. const cacheHits: string[] = []
  331. for (const entry of entries) {
  332. if (!entry.isDirectory()) continue
  333. const localPath = path.join(workshopPath, entry.name)
  334. const pj = readProjectJson(localPath)
  335. if (!pj) {
  336. incomplete.push({ id: entry.name, localPath })
  337. continue
  338. }
  339. const existing = wallpapers[entry.name]
  340. if (!existing?.downloading && existing?.previewLocal !== undefined && existing?.contentRating !== undefined && existing?.fileSize !== undefined) {
  341. cacheHits.push(entry.name)
  342. skipped++
  343. continue
  344. }
  345. toRebuild.push({ entry, localPath, pj })
  346. }
  347. // Items Steam still lists as subscribed but that never got a local folder
  348. // at all (the download failed before writing anything, or hasn't started)
  349. for (const id of subscribedIds) {
  350. if (onDisk.has(id)) continue
  351. incomplete.push({ id, localPath: path.join(workshopPath, id) })
  352. }
  353. // Author never changes once known, so only fetch it for items that don't have it yet
  354. const missingAuthorIds = [
  355. ...incomplete.map((i) => i.id),
  356. ...toRebuild.map(({ entry }) => entry.name),
  357. ...cacheHits
  358. ].filter((id) => !wallpapers[id]?.authorSteamId)
  359. const authors = await safeGetWorkshopAuthors(missingAuthorIds)
  360. const incompleteTags = await safeGetWorkshopTags(incomplete.map((i) => i.id))
  361. for (const { id, localPath } of incomplete) {
  362. const existing = wallpapers[id]
  363. const meta = buildIncompleteMeta(id, localPath, existing, incompleteTags.get(id), authors.get(id))
  364. if (!existing) {
  365. wallpapers[id] = meta
  366. imported++
  367. } else if (
  368. existing.downloading !== meta.downloading ||
  369. existing.downloadFailed !== meta.downloadFailed ||
  370. existing.type !== meta.type ||
  371. existing.contentRating !== meta.contentRating ||
  372. existing.authorSteamId !== meta.authorSteamId
  373. ) {
  374. wallpapers[id] = {
  375. ...existing,
  376. downloading: meta.downloading,
  377. downloadFailed: meta.downloadFailed,
  378. type: meta.type,
  379. contentRating: meta.contentRating,
  380. tags: meta.tags,
  381. authorSteamId: meta.authorSteamId
  382. }
  383. skipped++
  384. } else {
  385. skipped++
  386. }
  387. }
  388. const timesUpdated = await safeGetWorkshopTimesUpdated([
  389. ...toRebuild.map(({ entry }) => entry.name),
  390. ...cacheHits
  391. ])
  392. for (const { entry, localPath, pj } of toRebuild) {
  393. wallpapers[entry.name] = buildMeta(
  394. entry.name,
  395. localPath,
  396. pj,
  397. wallpapers[entry.name] ?? undefined,
  398. timesUpdated.get(entry.name),
  399. authors.get(entry.name)
  400. )
  401. imported++
  402. }
  403. let resynced = false
  404. for (const id of cacheHits) {
  405. const seconds = timesUpdated.get(id)
  406. const authorId = authors.get(id)
  407. const patch: Partial<WallpaperMeta> = {}
  408. if (seconds !== undefined && wallpapers[id].updatedAt !== seconds * 1000) {
  409. patch.updatedAt = seconds * 1000
  410. }
  411. if (authorId && wallpapers[id].authorSteamId !== authorId) {
  412. patch.authorSteamId = authorId
  413. }
  414. if (Object.keys(patch).length > 0) {
  415. wallpapers[id] = { ...wallpapers[id], ...patch }
  416. resynced = true
  417. }
  418. }
  419. if (imported > 0 || removed > 0 || resynced) {
  420. store.set('wallpapers', wallpapers)
  421. }
  422. console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped, ${removed} removed`)
  423. // Auto-cleanup folder/playlist items that reference non-existent wallpapers
  424. cleanupFolders()
  425. cleanupPlaylists(new Set(Object.keys(wallpapers)))
  426. return { imported, skipped, removed }
  427. }
  428. // Flags library items whose Steam Workshop listing has been taken down (the
  429. // author deleted it, Valve removed it, etc). Those items can never be
  430. // re-downloaded once lost locally, which is what "backup all unavailable"
  431. // is for - only workshop-sourced items with local content are worth checking.
  432. export async function checkUnavailableWallpapers(): Promise<{ checked: number; unavailable: number }> {
  433. if (!isSteamRunning()) return { checked: 0, unavailable: 0 }
  434. const wallpapers = store.get('wallpapers')
  435. const candidates = Object.values(wallpapers).filter(
  436. (w) => w.source === 'workshop' && !!w.localPath && !w.downloading
  437. )
  438. if (candidates.length === 0) return { checked: 0, unavailable: 0 }
  439. let unavailableIds: Set<string>
  440. try {
  441. unavailableIds = await getUnavailableWorkshopItems(candidates.map((w) => w.id))
  442. } catch {
  443. // Steam request failed outright - leave existing unavailable flags alone
  444. // rather than reporting everything as available again.
  445. return { checked: 0, unavailable: 0 }
  446. }
  447. let changed = false
  448. for (const w of candidates) {
  449. const flag = unavailableIds.has(w.id)
  450. if (!!w.unavailable !== flag) {
  451. wallpapers[w.id] = { ...w, unavailable: flag }
  452. changed = true
  453. }
  454. }
  455. if (changed) store.set('wallpapers', wallpapers)
  456. return { checked: candidates.length, unavailable: unavailableIds.size }
  457. }
  458. export function getAllFolders(): WallpaperFolder[] {
  459. return store.get('folders')
  460. }
  461. export function createFolder(title: string): WallpaperFolder {
  462. const folder: WallpaperFolder = { id: randomUUID(), title, items: [] }
  463. const folders = store.get('folders')
  464. folders.push(folder)
  465. store.set('folders', folders)
  466. return folder
  467. }
  468. function updateFolder(id: string, mutate: (folder: WallpaperFolder) => void): WallpaperFolder | null {
  469. const folders = store.get('folders')
  470. const folder = findAndMutate(folders, id, mutate)
  471. if (folder) store.set('folders', folders)
  472. return folder
  473. }
  474. export function renameFolder(id: string, title: string): WallpaperFolder | null {
  475. return updateFolder(id, (folder) => {
  476. folder.title = title
  477. })
  478. }
  479. export function deleteFolder(id: string): void {
  480. const folders = store.get('folders').filter((f) => f.id !== id)
  481. store.set('folders', folders)
  482. }
  483. /** A wallpaper can only live in one folder at a time, so adding it here pulls it out of any other. */
  484. export function addItemsToFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
  485. const folders = store.get('folders')
  486. const moving = new Set(itemIds)
  487. for (const folder of folders) {
  488. if (folder.id === folderId) continue
  489. folder.items = folder.items.filter((id) => !moving.has(id))
  490. }
  491. const target = findAndMutate(folders, folderId, (folder) => {
  492. const set = new Set(folder.items)
  493. for (const id of itemIds) set.add(id)
  494. folder.items = [...set]
  495. })
  496. if (target) store.set('folders', folders)
  497. return target
  498. }
  499. export function removeItemsFromFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
  500. return updateFolder(folderId, (folder) => {
  501. const remove = new Set(itemIds)
  502. folder.items = folder.items.filter((id) => !remove.has(id))
  503. })
  504. }
  505. /**
  506. * Remove folder item IDs that don't exist in the wallpapers store, and drop
  507. * items from any folder beyond the first that also claims them (a wallpaper
  508. * can only belong to one folder).
  509. * Returns the number of stale/duplicate IDs removed.
  510. */
  511. export function cleanupFolders(): number {
  512. const wallpapers = store.get('wallpapers')
  513. const validIds = new Set(Object.keys(wallpapers))
  514. const folders = store.get('folders')
  515. const seen = new Set<string>()
  516. let removed = 0
  517. for (const folder of folders) {
  518. const before = folder.items.length
  519. folder.items = folder.items.filter((id) => {
  520. if (!validIds.has(id) || seen.has(id)) return false
  521. seen.add(id)
  522. return true
  523. })
  524. removed += before - folder.items.length
  525. }
  526. if (removed > 0) {
  527. store.set('folders', folders)
  528. console.log(`[Library] Folder cleanup: removed ${removed} stale item(s)`)
  529. }
  530. return removed
  531. }
  532. export function importWEConfig(configPath: string): { folders: number; playlists: number } {
  533. const raw = fs.readFileSync(configPath, 'utf8')
  534. const data = JSON.parse(raw)
  535. const workshopPath = getWorkshopPath()
  536. const imported: WallpaperFolder[] = []
  537. const existingTitles = new Set(store.get('folders').map((f) => f.title))
  538. // Try each profile and pick the one with the most folders (usually '~')
  539. let bestProfile: { folders: any[]; playlists: any[] } = { folders: [], playlists: [] }
  540. for (const key of Object.keys(data)) {
  541. const val = data[key]
  542. if (!val || typeof val !== 'object') continue
  543. const general = val.general
  544. if (!general || typeof general !== 'object') continue
  545. const browser = general.browser
  546. const folders = browser?.folders ?? []
  547. const playlists = general.playlists ?? []
  548. const total = folders.length + playlists.length
  549. if (total > bestProfile.folders.length + bestProfile.playlists.length) {
  550. bestProfile = { folders, playlists }
  551. }
  552. }
  553. // Import folders (items are workshop IDs)
  554. for (const f of bestProfile.folders) {
  555. const title = f.title ?? 'Untitled'
  556. if (existingTitles.has(title)) continue
  557. const items = Object.keys(f.items ?? {}).filter((k) => /^\d+$/.test(k))
  558. imported.push({ id: randomUUID(), title, items })
  559. existingTitles.add(title)
  560. }
  561. // Import playlists (items are full file paths, so extract the workshop ID)
  562. for (const p of bestProfile.playlists) {
  563. const title = p.name ?? 'Untitled Playlist'
  564. if (existingTitles.has(title)) continue
  565. const items: string[] = []
  566. for (const filePath of p.items ?? []) {
  567. // Extract workshop ID from path like ".../431960/888689688/scene.pkg"
  568. const match = filePath.match(/\/431960\/(\d+)\//)
  569. if (match) items.push(match[1])
  570. }
  571. if (items.length > 0) {
  572. imported.push({ id: randomUUID(), title, items })
  573. existingTitles.add(title)
  574. }
  575. }
  576. if (imported.length > 0) {
  577. const folders = store.get('folders')
  578. folders.push(...imported)
  579. store.set('folders', folders)
  580. }
  581. const fCount = bestProfile.folders.length
  582. const pCount = bestProfile.playlists.length
  583. console.log(`[Library] WE config imported: ${imported.length} folders/playlists from ${fCount} folders + ${pCount} playlists`)
  584. return { folders: fCount, playlists: pCount }
  585. }