library.service.ts 28 KB

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