library.service.ts 28 KB

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