| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844 |
- import Store from 'electron-store'
- import type { WallpaperMeta, WallpaperType, ContentRating, LibraryFilters, WallpaperFolder } from '@shared/types'
- import { getThumbnailsPath, getWorkshopPath } from '../utils/paths'
- import * as path from 'path'
- import * as fs from 'fs'
- import { randomUUID } from 'crypto'
- import { cleanupPlaylists } from './playlist.service'
- import {
- getWorkshopTimesUpdated,
- getWorkshopTags,
- getWorkshopAuthors,
- getUnavailableWorkshopItems
- } from './workshop.service'
- import { findAndMutate } from '../utils/collections'
- import { resolutionsFromTags } from '@shared/resolutions'
- import {
- isSteamRunning,
- getItemDownloadStatus,
- getSubscribedItems,
- isItemStuckNeverDownloaded,
- downloadItem
- } from './steam.service'
- interface LibraryStore {
- wallpapers: Record<string, WallpaperMeta>
- tags: string[]
- folders: WallpaperFolder[]
- resolutionsVersion: number
- }
- const store = new Store<LibraryStore>({
- name: 'library',
- defaults: {
- wallpapers: {},
- tags: [],
- folders: [],
- resolutionsVersion: 0
- }
- })
- export function initLibrary(): void {
- console.log('[Library] Store initialized at', store.path)
- repairStaleDownloadFlags()
- }
- function repairStaleDownloadFlags(): void {
- const wallpapers = store.get('wallpapers')
- let changed = false
- for (const wallpaper of Object.values(wallpapers)) {
- if (!wallpaper.subscribed && (wallpaper.downloading || wallpaper.downloadFailed)) {
- wallpaper.downloading = false
- wallpaper.downloadFailed = false
- changed = true
- }
- }
- if (changed) store.set('wallpapers', wallpapers)
- }
- export function getAllWallpapers(filters?: LibraryFilters): WallpaperMeta[] {
- const all = Object.values(store.get('wallpapers'))
- let results = all
- if (filters?.type) {
- results = results.filter((w) => w.type === filters.type)
- }
- if (filters?.searchText) {
- const q = filters.searchText.toLowerCase()
- results = results.filter(
- (w) => w.title.toLowerCase().includes(q) || w.description?.toLowerCase().includes(q)
- )
- }
- if (filters?.contentRating) {
- results = results.filter((w) => w.contentRating === filters.contentRating)
- }
- if (filters?.tags && filters.tags.length > 0) {
- results = results.filter((w) => filters.tags!.every((tag) => w.tags.includes(tag)))
- }
- if (filters?.categories && filters.categories.length > 0) {
- results = results.filter((w) => filters.categories!.some((cat) => w.categories.includes(cat)))
- }
- const sortBy = filters?.sortBy ?? 'updatedAt'
- const dir = filters?.sortDir === 'asc' ? 1 : -1
- results = [...results].sort((a, b) => {
- switch (sortBy) {
- case 'title':
- return dir * a.title.localeCompare(b.title)
- case 'createdAt':
- return dir * (a.createdAt - b.createdAt)
- case 'updatedAt':
- return dir * (a.updatedAt - b.updatedAt)
- case 'lastApplied':
- return dir * ((a.lastAppliedAt ?? 0) - (b.lastAppliedAt ?? 0))
- case 'appliedCount':
- return dir * (a.appliedCount - b.appliedCount)
- case 'fileSize':
- return dir * ((a.fileSize ?? 0) - (b.fileSize ?? 0))
- default:
- return dir * (a.updatedAt - b.updatedAt)
- }
- })
- return results
- }
- export function getWallpaper(id: string): WallpaperMeta | null {
- const wallpapers = store.get('wallpapers')
- return wallpapers[id] ?? null
- }
- export function upsertWallpaper(meta: WallpaperMeta): void {
- const wallpapers = store.get('wallpapers')
- wallpapers[meta.id] = meta
- store.set('wallpapers', wallpapers)
- }
- // updatedAt tracks the workshop item's own timestamp (set in buildMeta), not local edits.
- export function updateWallpaper(id: string, patch: Partial<WallpaperMeta>): void {
- const current = getWallpaper(id)
- if (!current) throw new Error(`Wallpaper ${id} not found`)
- upsertWallpaper({ ...current, ...patch, updatedAt: current.updatedAt })
- }
- export function deleteWallpaper(id: string): void {
- const wallpapers = store.get('wallpapers')
- const thumbnail = wallpapers[id]?.customPreview
- if (thumbnail && path.dirname(thumbnail) === getThumbnailsPath()) fs.rmSync(thumbnail, { force: true })
- delete wallpapers[id]
- store.set('wallpapers', wallpapers)
- }
- // reads the store fresh so changes made during a long scan aren't overwritten
- export function applyWallpaperChanges(changes: {
- upserts?: WallpaperMeta[]
- patches?: { id: string; patch: Partial<WallpaperMeta> }[]
- deletes?: string[]
- }): void {
- const wallpapers = store.get('wallpapers')
- for (const meta of changes.upserts ?? []) wallpapers[meta.id] = meta
- for (const { id, patch } of changes.patches ?? []) {
- if (wallpapers[id]) wallpapers[id] = { ...wallpapers[id], ...patch, updatedAt: wallpapers[id].updatedAt }
- }
- for (const id of changes.deletes ?? []) {
- const thumbnail = wallpapers[id]?.customPreview
- if (thumbnail && path.dirname(thumbnail) === getThumbnailsPath()) fs.rmSync(thumbnail, { force: true })
- delete wallpapers[id]
- }
- store.set('wallpapers', wallpapers)
- }
- export function resetAllFpsOverrides(): { count: number } {
- const wallpapers = store.get('wallpapers')
- let count = 0
- for (const id of Object.keys(wallpapers)) {
- if (wallpapers[id].fpsOverride !== undefined) {
- delete wallpapers[id].fpsOverride
- count++
- }
- }
- store.set('wallpapers', wallpapers)
- return { count }
- }
- export function addTag(wallpaperId: string, tagName: string): void {
- const wallpaper = getWallpaper(wallpaperId)
- if (!wallpaper) return
- if (!wallpaper.tags.includes(tagName)) {
- wallpaper.tags.push(tagName)
- upsertWallpaper(wallpaper)
- }
- const tags = store.get('tags')
- if (!tags.includes(tagName)) {
- tags.push(tagName)
- store.set('tags', tags.sort())
- }
- }
- export function removeTag(wallpaperId: string, tagName: string): void {
- const wallpaper = getWallpaper(wallpaperId)
- if (!wallpaper) return
- wallpaper.tags = wallpaper.tags.filter((t) => t !== tagName)
- upsertWallpaper(wallpaper)
- }
- export function getAllTags(): string[] {
- return store.get('tags')
- }
- export function getDistinctTags(): string[] {
- const all = Object.values(store.get('wallpapers'))
- const set = new Set<string>()
- for (const w of all) {
- for (const t of w.tags) set.add(t)
- }
- return [...set].sort()
- }
- export interface ProjectJson {
- title?: string
- type?: string
- file?: string
- tags?: string[]
- description?: string
- preview?: string
- contentrating?: string
- workshopid?: string
- }
- export function readProjectJson(localPath: string): ProjectJson | null {
- try {
- const raw = fs.readFileSync(path.join(localPath, 'project.json'), 'utf8')
- return JSON.parse(raw) as ProjectJson
- } catch {
- return null
- }
- }
- export function normalizeType(raw?: string): WallpaperType {
- switch (raw?.toLowerCase()) {
- case 'video': return 'video'
- case 'web': return 'web'
- case 'application': return 'application'
- default: return 'scene'
- }
- }
- export function normalizeRating(raw?: string): ContentRating {
- switch (raw?.toLowerCase()) {
- case 'everyone': return 'everyone'
- case 'questionable': return 'questionable'
- case 'mature': return 'mature'
- default: return 'uncategorized'
- }
- }
- // Derived from the item's Steam Workshop tags for wallpapers that never finished
- // downloading (no local project.json), so failed downloads still land in the
- // right type/rating filters instead of "uncategorized".
- function deriveTypeFromTags(tags: string[]): WallpaperType | undefined {
- const match = tags.find((t) => ['Video', 'Web', 'Application', 'Scene'].includes(t))
- return match ? normalizeType(match) : undefined
- }
- function deriveRatingFromTags(tags: string[]): ContentRating | undefined {
- const match = tags.find((t) => ['Everyone', 'Questionable', 'Mature'].includes(t))
- return match ? normalizeRating(match) : undefined
- }
- function sameTags(a: string[] | undefined, b: string[] | undefined): boolean {
- if (!a || !b) return a === b
- return a.length === b.length && a.every((t) => b.includes(t))
- }
- const RATING_RESTRICTIVENESS: Record<ContentRating, number> = {
- uncategorized: 0,
- everyone: 1,
- questionable: 2,
- mature: 3
- }
- // project.json's "contentrating" field and its "tags" array can disagree (an author can tag
- // something "Mature" without setting the formal rating) - always trust the stricter one.
- function stricterRating(a: ContentRating, b?: ContentRating): ContentRating {
- if (!b) return a
- return RATING_RESTRICTIVENESS[b] > RATING_RESTRICTIVENESS[a] ? b : a
- }
- async function safeGetWorkshopTags(ids: string[]): Promise<Map<string, string[]>> {
- try {
- return await getWorkshopTags(ids)
- } catch {
- return new Map()
- }
- }
- async function safeGetWorkshopAuthors(ids: string[]): Promise<Map<string, string>> {
- try {
- return await getWorkshopAuthors(ids)
- } catch {
- return new Map()
- }
- }
- export function getDirSize(dirPath: string): number {
- let total = 0
- try {
- for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
- const full = path.join(dirPath, entry.name)
- if (entry.isDirectory()) {
- total += getDirSize(full)
- } else if (entry.isFile()) {
- try { total += fs.statSync(full).size } catch { /* skip */ }
- }
- }
- } catch { /* skip unreadable dirs */ }
- return total
- }
- async function safeGetWorkshopTimesUpdated(ids: string[]): Promise<Map<string, number>> {
- try {
- return await getWorkshopTimesUpdated(ids)
- } catch {
- return new Map()
- }
- }
- function buildMeta(
- workshopId: string,
- localPath: string,
- pj: ProjectJson | null,
- existing?: WallpaperMeta,
- workshopTimeUpdated?: number,
- authorSteamId?: string,
- liveWorkshopTags?: string[]
- ): WallpaperMeta {
- const now = Date.now()
- const { downloading: _d, downloadFailed: _df, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
- // project.json can land on disk before the preview image it references has
- // finished downloading (the watcher imports shortly after the folder shows
- // up) - pointing previewLocal at a file that doesn't exist yet makes the
- // wallpaper:// protocol handler fail with net::ERR_FILE_NOT_FOUND. Leaving
- // it unset here falls back to the "No preview" placeholder, and the next
- // library scan picks up the real file once it exists.
- const previewPath = pj?.preview ? path.join(localPath, pj.preview) : undefined
- // Steam can flag an item Mature after the fact (moderation, community reports) without the
- // author ever republishing - project.json keeps saying whatever it said at publish time, so
- // the live Workshop tags are the only place that catches it.
- const fromProject = stricterRating(normalizeRating(pj?.contentrating), deriveRatingFromTags(pj?.tags ?? []))
- const contentRating = stricterRating(fromProject, deriveRatingFromTags(liveWorkshopTags ?? []))
- return {
- ...existingRest,
- id: workshopId,
- title: pj?.title ?? `Wallpaper ${workshopId}`,
- type: normalizeType(pj?.type),
- contentRating,
- description: pj?.description,
- previewLocal: previewPath && fs.existsSync(previewPath) ? previewPath : undefined,
- previewUrl: undefined,
- localPath,
- file: pj?.file,
- fileSize: getDirSize(localPath),
- createdAt: existing?.createdAt ?? now,
- updatedAt: workshopTimeUpdated ? workshopTimeUpdated * 1000 : (existing?.updatedAt ?? now),
- subscribed: true,
- source: 'workshop',
- backupDir: existing?.backupDir ?? (existing?.source === 'backup' ? existing.localPath : undefined),
- tags: pj?.tags ?? existing?.tags ?? [],
- resolutions: liveWorkshopTags ? resolutionsFromTags(liveWorkshopTags) : existing?.resolutions,
- authorSteamId: authorSteamId ?? existing?.authorSteamId
- }
- }
- async function buildIncompleteMeta(
- workshopId: string,
- localPath: string,
- existing?: WallpaperMeta,
- workshopTags?: string[],
- authorSteamId?: string
- ): Promise<WallpaperMeta> {
- const now = Date.now()
- const { downloading, failed } = isSteamRunning()
- ? await getItemDownloadStatus(BigInt(workshopId))
- : { downloading: true, failed: false }
- const tags = workshopTags
- ? [...new Set([...(existing?.tags ?? []), ...workshopTags])]
- : (existing?.tags ?? [])
- return {
- id: workshopId,
- title: existing?.title ?? `Wallpaper ${workshopId}`,
- type: (workshopTags && deriveTypeFromTags(workshopTags)) ?? existing?.type ?? 'scene',
- contentRating:
- (workshopTags && deriveRatingFromTags(workshopTags)) ?? existing?.contentRating ?? 'uncategorized',
- localPath,
- createdAt: existing?.createdAt ?? now,
- updatedAt: now,
- subscribed: true,
- appliedCount: existing?.appliedCount ?? 0,
- source: 'workshop',
- tags,
- resolutions: workshopTags ? resolutionsFromTags(workshopTags) : existing?.resolutions,
- categories: existing?.categories ?? [],
- downloading,
- downloadFailed: failed,
- authorSteamId: authorSteamId ?? existing?.authorSteamId,
- backedUp: existing?.backedUp,
- backupDir: existing?.backupDir ?? (existing?.source === 'backup' ? existing.localPath : undefined)
- }
- }
- export async function importWallpaperById(workshopId: string): Promise<WallpaperMeta | null> {
- const localPath = path.join(getWorkshopPath(), workshopId)
- if (!fs.existsSync(localPath)) return null
- const existing = getWallpaper(workshopId) ?? undefined
- const authorSteamId =
- existing?.authorSteamId ?? (await safeGetWorkshopAuthors([workshopId])).get(workshopId)
- const pj = readProjectJson(localPath)
- if (!pj) {
- const tags = await safeGetWorkshopTags([workshopId])
- const meta = await buildIncompleteMeta(workshopId, localPath, existing, tags.get(workshopId), authorSteamId)
- upsertWallpaper(meta)
- return meta
- }
- const timesUpdated = await safeGetWorkshopTimesUpdated([workshopId])
- const liveTags = await safeGetWorkshopTags([workshopId])
- const meta = buildMeta(
- workshopId,
- localPath,
- pj,
- existing,
- timesUpdated.get(workshopId),
- authorSteamId,
- liveTags.get(workshopId)
- )
- upsertWallpaper(meta)
- return meta
- }
- export async function scanLibrary(): Promise<{ imported: number; skipped: number; removed: number }> {
- const workshopPath = getWorkshopPath()
- if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0, removed: 0 }
- const entries = fs.readdirSync(workshopPath, { withFileTypes: true })
- const onDisk = new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
- const subscribedIds = isSteamRunning()
- ? new Set(await getSubscribedItems().catch(() => []))
- : new Set<string>()
- // Load entire store once, mutate in memory, write once at the end
- const wallpapers = store.get('wallpapers')
- let imported = 0
- let skipped = 0
- let removed = 0
- let resynced = false
- // Remove workshop wallpapers whose directories no longer exist on disk, unless Steam
- // still has them subscribed (those get tracked below instead, so a failed
- // or never-started download doesn't just vanish from the library). Only source:'workshop'
- // entries live under workshopPath - local imports and backups (source:'local'/'backup') are
- // never on disk here, so including them in this check would delete them on every scan.
- for (const id of Object.keys(wallpapers)) {
- if (wallpapers[id].source !== 'workshop') continue
- if (!onDisk.has(id) && !subscribedIds.has(id)) {
- delete wallpapers[id]
- removed++
- }
- }
- const incomplete: { id: string; localPath: string }[] = []
- const toRebuild: { entry: fs.Dirent; localPath: string; pj: ProjectJson }[] = []
- const cacheHits: string[] = []
- for (const entry of entries) {
- if (!entry.isDirectory()) continue
- const localPath = path.join(workshopPath, entry.name)
- const pj = readProjectJson(localPath)
- if (!pj) {
- incomplete.push({ id: entry.name, localPath })
- continue
- }
- const existing = wallpapers[entry.name]
- if (!existing?.downloading && existing?.previewLocal !== undefined && existing?.contentRating !== undefined && existing?.fileSize !== undefined) {
- cacheHits.push(entry.name)
- skipped++
- continue
- }
- toRebuild.push({ entry, localPath, pj })
- }
- // Items Steam still lists as subscribed but that never got a local folder at all (the download
- // failed before writing anything, or Steam never actually started it - see
- // isItemStuckNeverDownloaded, this is more common than it sounds).
- for (const id of subscribedIds) {
- if (onDisk.has(id)) continue
- if (isSteamRunning() && (await isItemStuckNeverDownloaded(BigInt(id)))) {
- await downloadItem(BigInt(id))
- }
- incomplete.push({ id, localPath: path.join(workshopPath, id) })
- }
- // Author never changes once known, so only fetch it for items that don't have it yet
- const missingAuthorIds = [
- ...incomplete.map((i) => i.id),
- ...toRebuild.map(({ entry }) => entry.name),
- ...cacheHits
- ].filter((id) => !wallpapers[id]?.authorSteamId)
- const authors = await safeGetWorkshopAuthors(missingAuthorIds)
- const incompleteTags = await safeGetWorkshopTags(incomplete.map((i) => i.id))
- for (const { id, localPath } of incomplete) {
- const existing = wallpapers[id]
- const meta = await buildIncompleteMeta(id, localPath, existing, incompleteTags.get(id), authors.get(id))
- if (!existing) {
- wallpapers[id] = meta
- imported++
- } else if (
- existing.downloading !== meta.downloading ||
- existing.downloadFailed !== meta.downloadFailed ||
- existing.type !== meta.type ||
- existing.contentRating !== meta.contentRating ||
- existing.authorSteamId !== meta.authorSteamId ||
- !sameTags(existing.resolutions, meta.resolutions)
- ) {
- wallpapers[id] = {
- ...existing,
- downloading: meta.downloading,
- downloadFailed: meta.downloadFailed,
- type: meta.type,
- contentRating: meta.contentRating,
- tags: meta.tags,
- resolutions: meta.resolutions,
- authorSteamId: meta.authorSteamId
- }
- resynced = true
- skipped++
- } else {
- skipped++
- }
- }
- const timesUpdated = await safeGetWorkshopTimesUpdated([
- ...toRebuild.map(({ entry }) => entry.name),
- ...cacheHits
- ])
- const liveTags = await safeGetWorkshopTags([
- ...toRebuild.map(({ entry }) => entry.name),
- ...cacheHits
- ])
- for (const { entry, localPath, pj } of toRebuild) {
- wallpapers[entry.name] = buildMeta(
- entry.name,
- localPath,
- pj,
- wallpapers[entry.name] ?? undefined,
- timesUpdated.get(entry.name),
- authors.get(entry.name),
- liveTags.get(entry.name)
- )
- imported++
- }
- for (const id of cacheHits) {
- const seconds = timesUpdated.get(id)
- const authorId = authors.get(id)
- const live = liveTags.get(id)
- // A time_updated bump means the author changed something on the Workshop side
- // (tags, content rating, a republish) - re-read project.json instead of just
- // touching updatedAt, otherwise a corrected rating/genre never leaves the cache
- // until the item is removed and reimported.
- const localPath = wallpapers[id].localPath
- if (seconds !== undefined && wallpapers[id].updatedAt !== seconds * 1000 && localPath) {
- const pj = readProjectJson(localPath)
- if (pj) {
- wallpapers[id] = buildMeta(
- id,
- localPath,
- pj,
- wallpapers[id],
- seconds,
- authorId ?? wallpapers[id].authorSteamId,
- live
- )
- resynced = true
- continue
- }
- }
- const patch: Partial<WallpaperMeta> = {}
- if (seconds !== undefined && wallpapers[id].updatedAt !== seconds * 1000) {
- patch.updatedAt = seconds * 1000
- }
- if (authorId && wallpapers[id].authorSteamId !== authorId) {
- patch.authorSteamId = authorId
- }
- // Only tighten, never loosen - a failed/empty tag fetch must not walk a rating back down.
- const liveRating = live ? deriveRatingFromTags(live) : undefined
- if (liveRating) {
- const stricter = stricterRating(wallpapers[id].contentRating ?? 'uncategorized', liveRating)
- if (stricter !== wallpapers[id].contentRating) {
- patch.contentRating = stricter
- }
- }
- if (live) {
- const resolutions = resolutionsFromTags(live)
- if (!sameTags(wallpapers[id].resolutions, resolutions)) {
- patch.resolutions = resolutions
- }
- }
- if (Object.keys(patch).length > 0) {
- wallpapers[id] = { ...wallpapers[id], ...patch }
- resynced = true
- }
- }
- if (imported > 0 || removed > 0 || resynced) {
- store.set('wallpapers', wallpapers)
- }
- console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped, ${removed} removed`)
- // Auto-cleanup folder/playlist items that reference non-existent wallpapers
- cleanupFolders()
- cleanupPlaylists(new Set(Object.keys(wallpapers)))
- return { imported, skipped, removed }
- }
- // Flags library items whose Steam Workshop listing has been taken down (the
- // author deleted it, Valve removed it, etc). Those items can never be
- // re-downloaded once lost locally, which is what "backup all unavailable"
- // is for - only workshop-sourced items with local content are worth checking.
- export async function checkUnavailableWallpapers(): Promise<{
- checked: number
- unavailable: number
- changed: boolean
- }> {
- if (!isSteamRunning()) return { checked: 0, unavailable: 0, changed: false }
- const wallpapers = store.get('wallpapers')
- const candidates = Object.values(wallpapers).filter(
- (w) => w.source === 'workshop' && !!w.localPath && !w.downloading
- )
- if (candidates.length === 0) return { checked: 0, unavailable: 0, changed: false }
- let unavailableIds: Set<string>
- try {
- unavailableIds = await getUnavailableWorkshopItems(candidates.map((w) => w.id))
- } catch {
- // Steam request failed outright - leave existing unavailable flags alone
- // rather than reporting everything as available again.
- return { checked: 0, unavailable: 0, changed: false }
- }
- let changed = false
- for (const w of candidates) {
- const flag = unavailableIds.has(w.id)
- if (!!w.unavailable !== flag) {
- wallpapers[w.id] = { ...w, unavailable: flag }
- changed = true
- }
- }
- if (changed) store.set('wallpapers', wallpapers)
- return { checked: candidates.length, unavailable: unavailableIds.size, changed }
- }
- // Bump whenever resolutionsFromTags() changes, stored resolutions then get re-derived once
- const RESOLUTIONS_VERSION = 2
- // Resolution only comes from the live Workshop tags (project.json never has it), so items
- // imported before that was stored, or while Steam was offline, need a separate pass.
- export async function backfillResolutions(): Promise<boolean> {
- if (!isSteamRunning()) return false
- const rederive = store.get('resolutionsVersion') !== RESOLUTIONS_VERSION
- const ids = Object.values(store.get('wallpapers'))
- .filter((w) => w.source === 'workshop' && (rederive || w.resolutions === undefined))
- .map((w) => w.id)
- let tags = new Map<string, string[]>()
- if (ids.length > 0) {
- try {
- tags = await getWorkshopTags(ids)
- } catch {
- return false
- }
- }
- // re-read, the fetch takes a while and other writes may have landed meanwhile
- const wallpapers = store.get('wallpapers')
- let changed = false
- for (const id of ids) {
- const w = wallpapers[id]
- if (!w) continue
- const live = tags.get(id)
- // Steam no longer returns removed or private items, record "none" instead of asking every tick
- const resolutions = live ? resolutionsFromTags(live) : (w.resolutions ?? [])
- if (sameTags(w.resolutions, resolutions)) continue
- wallpapers[id] = { ...w, resolutions }
- changed = true
- }
- if (changed) store.set('wallpapers', wallpapers)
- store.set('resolutionsVersion', RESOLUTIONS_VERSION)
- if (ids.length > 0) {
- console.log(`[Library] Resolution tags fetched for ${tags.size} of ${ids.length} items`)
- }
- return changed
- }
- export function getAllFolders(): WallpaperFolder[] {
- return store.get('folders')
- }
- export function createFolder(title: string): WallpaperFolder {
- const folder: WallpaperFolder = { id: randomUUID(), title, items: [] }
- const folders = store.get('folders')
- folders.push(folder)
- store.set('folders', folders)
- return folder
- }
- function updateFolder(id: string, mutate: (folder: WallpaperFolder) => void): WallpaperFolder | null {
- const folders = store.get('folders')
- const folder = findAndMutate(folders, id, mutate)
- if (folder) store.set('folders', folders)
- return folder
- }
- export function renameFolder(id: string, title: string): WallpaperFolder | null {
- return updateFolder(id, (folder) => {
- folder.title = title
- })
- }
- export function deleteFolder(id: string): void {
- const folders = store.get('folders').filter((f) => f.id !== id)
- store.set('folders', folders)
- }
- /** A wallpaper can only live in one folder at a time, so adding it here pulls it out of any other. */
- export function addItemsToFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
- const folders = store.get('folders')
- const moving = new Set(itemIds)
- for (const folder of folders) {
- if (folder.id === folderId) continue
- folder.items = folder.items.filter((id) => !moving.has(id))
- }
- const target = findAndMutate(folders, folderId, (folder) => {
- const set = new Set(folder.items)
- for (const id of itemIds) set.add(id)
- folder.items = [...set]
- })
- if (target) store.set('folders', folders)
- return target
- }
- export function removeItemsFromFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
- return updateFolder(folderId, (folder) => {
- const remove = new Set(itemIds)
- folder.items = folder.items.filter((id) => !remove.has(id))
- })
- }
- /**
- * Remove folder item IDs that don't exist in the wallpapers store, and drop
- * items from any folder beyond the first that also claims them (a wallpaper
- * can only belong to one folder).
- * Returns the number of stale/duplicate IDs removed.
- */
- export function cleanupFolders(): number {
- const wallpapers = store.get('wallpapers')
- const validIds = new Set(Object.keys(wallpapers))
- const folders = store.get('folders')
- const seen = new Set<string>()
- let removed = 0
- for (const folder of folders) {
- const before = folder.items.length
- folder.items = folder.items.filter((id) => {
- if (!validIds.has(id) || seen.has(id)) return false
- seen.add(id)
- return true
- })
- removed += before - folder.items.length
- }
- if (removed > 0) {
- store.set('folders', folders)
- console.log(`[Library] Folder cleanup: removed ${removed} stale item(s)`)
- }
- return removed
- }
- export function importWEConfig(configPath: string): { folders: number; playlists: number } {
- const raw = fs.readFileSync(configPath, 'utf8')
- const data = JSON.parse(raw)
- const workshopPath = getWorkshopPath()
- const imported: WallpaperFolder[] = []
- const existingTitles = new Set(store.get('folders').map((f) => f.title))
- // Try each profile and pick the one with the most folders (usually '~')
- let bestProfile: { folders: any[]; playlists: any[] } = { folders: [], playlists: [] }
- for (const key of Object.keys(data)) {
- const val = data[key]
- if (!val || typeof val !== 'object') continue
- const general = val.general
- if (!general || typeof general !== 'object') continue
- const browser = general.browser
- const folders = browser?.folders ?? []
- const playlists = general.playlists ?? []
- const total = folders.length + playlists.length
- if (total > bestProfile.folders.length + bestProfile.playlists.length) {
- bestProfile = { folders, playlists }
- }
- }
- // Import folders (items are workshop IDs)
- for (const f of bestProfile.folders) {
- const title = f.title ?? 'Untitled'
- if (existingTitles.has(title)) continue
- const items = Object.keys(f.items ?? {}).filter((k) => /^\d+$/.test(k))
- imported.push({ id: randomUUID(), title, items })
- existingTitles.add(title)
- }
- // Import playlists (items are full file paths, so extract the workshop ID)
- for (const p of bestProfile.playlists) {
- const title = p.name ?? 'Untitled Playlist'
- if (existingTitles.has(title)) continue
- const items: string[] = []
- for (const filePath of p.items ?? []) {
- // Extract workshop ID from path like ".../431960/888689688/scene.pkg"
- const match = filePath.match(/\/431960\/(\d+)\//)
- if (match) items.push(match[1])
- }
- if (items.length > 0) {
- imported.push({ id: randomUUID(), title, items })
- existingTitles.add(title)
- }
- }
- if (imported.length > 0) {
- const folders = store.get('folders')
- folders.push(...imported)
- store.set('folders', folders)
- }
- const fCount = bestProfile.folders.length
- const pCount = bestProfile.playlists.length
- console.log(`[Library] WE config imported: ${imported.length} folders/playlists from ${fCount} folders + ${pCount} playlists`)
- return { folders: fCount, playlists: pCount }
- }
|