library.service.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import Store from 'electron-store'
  2. import type { WallpaperMeta, WallpaperType, ContentRating, LibraryFilters, WallpaperFolder } from '@shared/types'
  3. import { getWorkshopPath } from '../utils/paths'
  4. import * as path from 'path'
  5. import * as fs from 'fs'
  6. import { randomUUID } from 'crypto'
  7. interface LibraryStore {
  8. wallpapers: Record<string, WallpaperMeta>
  9. tags: string[]
  10. folders: WallpaperFolder[]
  11. }
  12. const store = new Store<LibraryStore>({
  13. name: 'library',
  14. defaults: {
  15. wallpapers: {},
  16. tags: [],
  17. folders: []
  18. }
  19. })
  20. export function initDatabase(): void {
  21. console.log('[Library] Store initialized at', store.path)
  22. }
  23. export function getAllWallpapers(filters?: LibraryFilters): WallpaperMeta[] {
  24. const all = Object.values(store.get('wallpapers'))
  25. let results = all
  26. if (filters?.type) {
  27. results = results.filter((w) => w.type === filters.type)
  28. }
  29. if (filters?.searchText) {
  30. const q = filters.searchText.toLowerCase()
  31. results = results.filter(
  32. (w) => w.title.toLowerCase().includes(q) || w.description?.toLowerCase().includes(q)
  33. )
  34. }
  35. if (filters?.contentRating) {
  36. results = results.filter((w) => w.contentRating === filters.contentRating)
  37. }
  38. if (filters?.tags && filters.tags.length > 0) {
  39. results = results.filter((w) => filters.tags!.every((tag) => w.tags.includes(tag)))
  40. }
  41. if (filters?.categories && filters.categories.length > 0) {
  42. results = results.filter((w) => filters.categories!.some((cat) => w.categories.includes(cat)))
  43. }
  44. // Sort
  45. const sortBy = filters?.sortBy ?? 'updatedAt'
  46. const dir = filters?.sortDir === 'asc' ? 1 : -1
  47. results = [...results].sort((a, b) => {
  48. switch (sortBy) {
  49. case 'title':
  50. return dir * a.title.localeCompare(b.title)
  51. case 'createdAt':
  52. return dir * (a.createdAt - b.createdAt)
  53. case 'updatedAt':
  54. return dir * (a.updatedAt - b.updatedAt)
  55. case 'lastApplied':
  56. return dir * ((a.lastAppliedAt ?? 0) - (b.lastAppliedAt ?? 0))
  57. case 'appliedCount':
  58. return dir * (a.appliedCount - b.appliedCount)
  59. case 'fileSize':
  60. return dir * ((a.fileSize ?? 0) - (b.fileSize ?? 0))
  61. default:
  62. return dir * (a.updatedAt - b.updatedAt)
  63. }
  64. })
  65. return results
  66. }
  67. export function getWallpaper(id: string): WallpaperMeta | null {
  68. const wallpapers = store.get('wallpapers')
  69. return wallpapers[id] ?? null
  70. }
  71. export function upsertWallpaper(meta: WallpaperMeta): void {
  72. const wallpapers = store.get('wallpapers')
  73. wallpapers[meta.id] = meta
  74. store.set('wallpapers', wallpapers)
  75. }
  76. export function updateWallpaper(id: string, patch: Partial<WallpaperMeta>): void {
  77. const current = getWallpaper(id)
  78. if (!current) throw new Error(`Wallpaper ${id} not found`)
  79. upsertWallpaper({ ...current, ...patch, updatedAt: Date.now() })
  80. }
  81. export function deleteWallpaper(id: string): void {
  82. const wallpapers = store.get('wallpapers')
  83. delete wallpapers[id]
  84. store.set('wallpapers', wallpapers)
  85. }
  86. export function addTag(wallpaperId: string, tagName: string): void {
  87. const wallpaper = getWallpaper(wallpaperId)
  88. if (!wallpaper) return
  89. if (!wallpaper.tags.includes(tagName)) {
  90. wallpaper.tags.push(tagName)
  91. upsertWallpaper(wallpaper)
  92. }
  93. const tags = store.get('tags')
  94. if (!tags.includes(tagName)) {
  95. tags.push(tagName)
  96. store.set('tags', tags.sort())
  97. }
  98. }
  99. export function removeTag(wallpaperId: string, tagName: string): void {
  100. const wallpaper = getWallpaper(wallpaperId)
  101. if (!wallpaper) return
  102. wallpaper.tags = wallpaper.tags.filter((t) => t !== tagName)
  103. upsertWallpaper(wallpaper)
  104. }
  105. export function getAllTags(): string[] {
  106. return store.get('tags')
  107. }
  108. export function getDistinctTags(): string[] {
  109. const all = Object.values(store.get('wallpapers'))
  110. const set = new Set<string>()
  111. for (const w of all) {
  112. for (const t of w.tags) set.add(t)
  113. }
  114. return [...set].sort()
  115. }
  116. interface ProjectJson {
  117. title?: string
  118. type?: string
  119. file?: string
  120. tags?: string[]
  121. description?: string
  122. preview?: string
  123. contentrating?: string
  124. workshopid?: string
  125. }
  126. function readProjectJson(localPath: string): ProjectJson | null {
  127. try {
  128. const raw = fs.readFileSync(path.join(localPath, 'project.json'), 'utf8')
  129. return JSON.parse(raw) as ProjectJson
  130. } catch {
  131. return null
  132. }
  133. }
  134. function normalizeType(raw?: string): WallpaperType {
  135. switch (raw?.toLowerCase()) {
  136. case 'video': return 'video'
  137. case 'web': return 'web'
  138. case 'application': return 'application'
  139. default: return 'scene'
  140. }
  141. }
  142. function normalizeRating(raw?: string): ContentRating {
  143. switch (raw?.toLowerCase()) {
  144. case 'questionable': return 'questionable'
  145. case 'mature': return 'mature'
  146. default: return 'everyone'
  147. }
  148. }
  149. function buildMeta(
  150. workshopId: string,
  151. localPath: string,
  152. pj: ProjectJson | null,
  153. existing?: WallpaperMeta
  154. ): WallpaperMeta {
  155. const now = Date.now()
  156. return {
  157. ...(existing ?? { appliedCount: 0, categories: [] }),
  158. id: workshopId,
  159. title: pj?.title ?? `Wallpaper ${workshopId}`,
  160. type: normalizeType(pj?.type),
  161. contentRating: normalizeRating(pj?.contentrating),
  162. description: pj?.description,
  163. previewLocal: pj?.preview ? path.join(localPath, pj.preview) : undefined,
  164. previewUrl: undefined,
  165. localPath,
  166. file: pj?.file,
  167. createdAt: existing?.createdAt ?? now,
  168. updatedAt: now,
  169. subscribed: true,
  170. source: 'workshop',
  171. tags: pj?.tags ?? existing?.tags ?? []
  172. }
  173. }
  174. export function importWallpaperById(workshopId: string): WallpaperMeta | null {
  175. const localPath = path.join(getWorkshopPath(), workshopId)
  176. if (!fs.existsSync(localPath)) return null
  177. const meta = buildMeta(workshopId, localPath, readProjectJson(localPath), getWallpaper(workshopId) ?? undefined)
  178. upsertWallpaper(meta)
  179. return meta
  180. }
  181. export function scanLibrary(): { imported: number; skipped: number } {
  182. const workshopPath = getWorkshopPath()
  183. if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0 }
  184. const entries = fs.readdirSync(workshopPath, { withFileTypes: true })
  185. // Load entire store once, mutate in memory, write once at the end
  186. const wallpapers = store.get('wallpapers')
  187. let imported = 0
  188. let skipped = 0
  189. for (const entry of entries) {
  190. if (!entry.isDirectory()) continue
  191. // Skip already-imported entries that have all required fields (cache hit)
  192. const existing = wallpapers[entry.name]
  193. if (existing?.previewLocal !== undefined && existing?.contentRating !== undefined) {
  194. skipped++
  195. continue
  196. }
  197. const localPath = path.join(workshopPath, entry.name)
  198. const pj = readProjectJson(localPath)
  199. if (!pj) { skipped++; continue }
  200. wallpapers[entry.name] = buildMeta(entry.name, localPath, pj)
  201. imported++
  202. }
  203. if (imported > 0) {
  204. store.set('wallpapers', wallpapers) // single write
  205. }
  206. console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped`)
  207. return { imported, skipped }
  208. }
  209. // ── Folder CRUD ──
  210. export function getAllFolders(): WallpaperFolder[] {
  211. return store.get('folders')
  212. }
  213. export function createFolder(title: string): WallpaperFolder {
  214. const folder: WallpaperFolder = { id: randomUUID(), title, items: [] }
  215. const folders = store.get('folders')
  216. folders.push(folder)
  217. store.set('folders', folders)
  218. return folder
  219. }
  220. export function renameFolder(id: string, title: string): WallpaperFolder | null {
  221. const folders = store.get('folders')
  222. const folder = folders.find((f) => f.id === id)
  223. if (!folder) return null
  224. folder.title = title
  225. store.set('folders', folders)
  226. return folder
  227. }
  228. export function deleteFolder(id: string): void {
  229. const folders = store.get('folders').filter((f) => f.id !== id)
  230. store.set('folders', folders)
  231. }
  232. export function addItemsToFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
  233. const folders = store.get('folders')
  234. const folder = folders.find((f) => f.id === folderId)
  235. if (!folder) return null
  236. const set = new Set(folder.items)
  237. for (const id of itemIds) set.add(id)
  238. folder.items = [...set]
  239. store.set('folders', folders)
  240. return folder
  241. }
  242. export function removeItemsFromFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
  243. const folders = store.get('folders')
  244. const folder = folders.find((f) => f.id === folderId)
  245. if (!folder) return null
  246. const remove = new Set(itemIds)
  247. folder.items = folder.items.filter((id) => !remove.has(id))
  248. store.set('folders', folders)
  249. return folder
  250. }
  251. export function importWEConfig(configPath: string): { folders: number; playlists: number } {
  252. const raw = fs.readFileSync(configPath, 'utf8')
  253. const data = JSON.parse(raw)
  254. const workshopPath = getWorkshopPath()
  255. const imported: WallpaperFolder[] = []
  256. const existingTitles = new Set(store.get('folders').map((f) => f.title))
  257. // Try each profile — pick the one with the most folders (usually '~')
  258. let bestProfile: { folders: any[]; playlists: any[] } = { folders: [], playlists: [] }
  259. for (const key of Object.keys(data)) {
  260. const val = data[key]
  261. if (!val || typeof val !== 'object') continue
  262. const general = val.general
  263. if (!general || typeof general !== 'object') continue
  264. const browser = general.browser
  265. const folders = browser?.folders ?? []
  266. const playlists = general.playlists ?? []
  267. const total = folders.length + playlists.length
  268. if (total > bestProfile.folders.length + bestProfile.playlists.length) {
  269. bestProfile = { folders, playlists }
  270. }
  271. }
  272. // Import folders (items are workshop IDs)
  273. for (const f of bestProfile.folders) {
  274. const title = f.title ?? 'Untitled'
  275. if (existingTitles.has(title)) continue
  276. const items = Object.keys(f.items ?? {}).filter((k) => /^\d+$/.test(k))
  277. imported.push({ id: randomUUID(), title, items })
  278. existingTitles.add(title)
  279. }
  280. // Import playlists (items are full file paths — extract workshop ID)
  281. for (const p of bestProfile.playlists) {
  282. const title = p.name ?? 'Untitled Playlist'
  283. if (existingTitles.has(title)) continue
  284. const items: string[] = []
  285. for (const filePath of p.items ?? []) {
  286. // Extract workshop ID from path like ".../431960/888689688/scene.pkg"
  287. const match = filePath.match(/\/431960\/(\d+)\//)
  288. if (match) items.push(match[1])
  289. }
  290. if (items.length > 0) {
  291. imported.push({ id: randomUUID(), title, items })
  292. existingTitles.add(title)
  293. }
  294. }
  295. if (imported.length > 0) {
  296. const folders = store.get('folders')
  297. folders.push(...imported)
  298. store.set('folders', folders)
  299. }
  300. const fCount = bestProfile.folders.length
  301. const pCount = bestProfile.playlists.length
  302. console.log(`[Library] WE config imported: ${imported.length} folders/playlists from ${fCount} folders + ${pCount} playlists`)
  303. return { folders: fCount, playlists: pCount }
  304. }