Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Path resolution highlighting: red if invalid, green if path exists or pass the check.

### Added
- Added a Browse blacklist. A title can be blacklisted from its right-click menu or with the new Blacklist button next to Add to Wishlist on its detail page, and it is then hidden from Browse. Entries are stored in a new `blacklist_entries` table, so they survive a restart, and the new Settings > Blacklist section lists them with a Remove button to bring a title back. The exclusion is part of the shared catalog WHERE clause on both the index and the union query paths (`electron/db/blacklistSql.js`), so the result count and scrollbar stay correct instead of leaving gaps. Blacklisting a wishlisted title also removes it from the wishlist, and titles that are installed are never hidden.
- Custom media uploads in the Game Details Media tab: add preview images from local files, a drag-and-drop zone, or an image URL, with live progress. Previews can be reordered by drag and the order persists in a new `preview_sort` table keyed by remote URL (or relative path for custom uploads), so it survives re-downloads, stream/download switches and metadata refreshes. Previews now carry a source logo and a storage-location badge, and custom previews can be deleted independently of downloaded ones.
- Add Buzzheavier host support (`buzzheavier.com`, `bzzhr.to`, `bzzhr.co`). The download route is behind a Cloudflare challenge, so the resolve runs in a browser window: it clicks the htmx download button, captures the `HX-Redirect` (or an attachment via `will-download`), and hands the resolved CDN link plus the browser's own cookies/UA back to the downloader. The resolve partition is persistent so a solved challenge survives a restart -- only Cloudflare's own challenge cookies are kept, everything else is stripped after each resolve -- and resolves run one at a time, since a shared session cannot carry two concurrently. Each time your IP changes there is a brief auto-resolve window while the challenge is re-solved.
- The version readout in the topnav and sidebar is now a button that opens that version's GitHub release page. The tag it builds matches what the release workflows publish -- `v<version>` for stable and `v<version>-nightly.<run>` for nightly -- so it lands on the real release rather than a 404. (#143)
Expand Down
150 changes: 150 additions & 0 deletions electron/db/blacklist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
'use strict'

const dbModule = require('./index')
const {
normalizeWishlistEntry,
resolveMissingIds,
getWishlistEntry,
removeWishlistEntry,
} = require('./wishlist')
// Read through dbModule at call time: the handle does not exist until
// initializeDatabase runs, which is after this module is first required.
const getDb = () => dbModule.db

const normalizeId = (value) => {
const number = Number(value)
return Number.isInteger(number) && number > 0 ? number : null
}

// Identity is deliberately the wishlist's (normalizeWishlistEntry), so a title
// has the same key on both lists and the renderer's getWishlistIdentityKey
// works for either.
//
// The one extension is GOG. The wishlist never learned about gog_id, so a
// GOG-only Browse row falls back to a title:creator key there. The blacklist
// has to match that row in SQL, which needs the id, so it is stored and used
// as the key whenever nothing better exists.
const normalizeBlacklistEntry = (entry = {}) => {
const normalized = normalizeWishlistEntry(entry)
const gogId = normalizeId(entry.gog_id ?? entry.gogId)
const hasProviderId = normalized.atlasId || normalized.f95Id || normalized.lcId || normalized.steamId
return {
...normalized,
gogId,
identityKey: !hasProviderId && gogId ? `gog:${gogId}` : normalized.identityKey,
}
}

// Accepts a bare key, a stored row, or a game object, the same three shapes
// removeWishlistEntry takes, so the Settings list can pass its rows straight
// back.
const normalizeBlacklistIdentity = (identity = {}) => {
if (typeof identity === 'string' && identity.trim()) return identity.trim()
if (identity?.identity_key) return String(identity.identity_key).trim()
return normalizeBlacklistEntry(identity).identityKey
}

// Blacklisting also clears the title from the wishlist: a title the user never
// wants to see again should not linger in the Wishlist view.
//
// resolveMissingIds runs first so the row carries every id the catalog knows
// for the title. Browse lists the same game once per source (an F95 row, an
// Atlas row, a LewdCorner row), and the exclusion SQL can only hide the
// siblings of the row that was clicked if their ids were stored too.
//
// The wishlist lookup uses getWishlistEntry rather than the key alone because
// the wishlist row may have been stored under a different key form (atlas: vs
// f95:) than the one rebuilt here.
const addBlacklistEntry = async (entry = {}) => {
const normalized = await resolveMissingIds(normalizeBlacklistEntry(entry))
const blacklistedAt = Math.floor(Date.now() / 1000)

await new Promise((resolve, reject) => {
getDb().run(
`INSERT INTO blacklist_entries
(identity_key, source, atlas_id, f95_id, lc_id, steam_id, gog_id, title, creator,
banner_url, site_url, blacklisted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(identity_key) DO UPDATE SET
atlas_id = COALESCE(excluded.atlas_id, blacklist_entries.atlas_id),
f95_id = COALESCE(excluded.f95_id, blacklist_entries.f95_id),
lc_id = COALESCE(excluded.lc_id, blacklist_entries.lc_id),
steam_id = COALESCE(excluded.steam_id, blacklist_entries.steam_id),
gog_id = COALESCE(excluded.gog_id, blacklist_entries.gog_id),
banner_url = COALESCE(excluded.banner_url, blacklist_entries.banner_url),
site_url = COALESCE(excluded.site_url, blacklist_entries.site_url)`,
[
normalized.identityKey,
normalized.source,
normalized.atlasId,
normalized.f95Id,
normalized.lcId,
normalized.steamId,
normalized.gogId,
normalized.title,
normalized.creator,
normalized.bannerUrl,
normalized.siteUrl,
blacklistedAt,
],
(err) => (err ? reject(err) : resolve()),
)
})

let removedFromWishlist = false
const wishlisted = await getWishlistEntry({
...entry,
atlas_id: normalized.atlasId,
f95_id: normalized.f95Id,
lc_id: normalized.lcId,
steam_id: normalized.steamId,
})
if (wishlisted) {
const removal = await removeWishlistEntry(wishlisted)
removedFromWishlist = removal?.removed === true
}

return {
success: true,
isBlacklisted: true,
identityKey: normalized.identityKey,
removedFromWishlist,
}
}

// Removal is by stored key only. Unlike the wishlist there is no need to match
// on provider ids: the only caller is the Settings list, which hands back the
// exact row it was given.
const removeBlacklistEntry = (identity = {}) => {
const identityKey = normalizeBlacklistIdentity(identity)
return new Promise((resolve, reject) => {
getDb().run(
`DELETE FROM blacklist_entries WHERE identity_key = ?`,
[identityKey],
function (err) {
if (err) reject(err)
else resolve({ success: true, removed: this.changes > 0, isBlacklisted: false, identityKey })
},
)
})
}

// Newest first, so something blacklisted by mistake a moment ago is at the top
// of the Settings list where the user goes looking for it.
const getBlacklistEntries = () => {
return new Promise((resolve, reject) => {
getDb().all(
`SELECT * FROM blacklist_entries
ORDER BY blacklisted_at DESC, title COLLATE NOCASE ASC`,
[],
(err, rows) => (err ? reject(err) : resolve(rows || [])),
)
})
}

module.exports = {
addBlacklistEntry,
removeBlacklistEntry,
getBlacklistEntries,
normalizeBlacklistEntry,
}
32 changes: 32 additions & 0 deletions electron/db/blacklistSql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict'

// The one WHERE fragment that hides blacklisted titles from Browse.
//
// It lives in its own dependency-free module because BOTH catalog query paths
// need it: buildIndexWhere (catalogIndex.js) serves the normal case, and
// getCatalogGamesFromUnion (versions.js) serves the index-not-ready and
// updateAvailable cases and every fast-path failure. A copy in each file is
// how the wishlistOnly and rating clauses drifted apart; one builder cannot.
// Requiring blacklist.js instead would drag wishlist.js and db/index.js into
// catalogIndex.js's require graph for the sake of a string.
//
// The exclusion is in the shared WHERE rather than applied to a fetched page so
// the COUNT and the page queries agree -- otherwise the grid's scrollbar is
// sized for rows that never render and Browse shows holes.
//
// One NOT EXISTS per provider id, not one EXISTS with an OR inside: SQLite can
// only use the per-column idx_blacklist_entries_* indexes when each probe
// matches a single column (same reasoning as wishlistOnly).
//
// Rows linked to a local record are never hidden. The blacklist is offered only
// on titles that are not in the library, and a title the user later installed
// anyway should not vanish from Browse because of an old entry.
const BLACKLIST_ID_COLUMNS = ['atlas_id', 'f95_id', 'lc_id', 'steam_id', 'gog_id']

const buildBlacklistExclusionSql = (alias, installedExpr) => {
const probes = BLACKLIST_ID_COLUMNS.map((column) =>
`NOT EXISTS (SELECT 1 FROM blacklist_entries bl WHERE bl.${column} IS NOT NULL AND bl.${column} = ${alias}.${column})`)
return `(${installedExpr} OR (${probes.join(' AND ')}))`
}

module.exports = { buildBlacklistExclusionSql, BLACKLIST_ID_COLUMNS }
6 changes: 6 additions & 0 deletions electron/db/catalogIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const {
const { extractUrlId } = require('./urlIdExtractor')
const { buildTagsFilterValue, normalizeTagText, normalizeTagList, splitTagSources } = require('./tagTokens')
const { tokenPredicate, escapeLike } = require('./tagFilterSql')
const { buildBlacklistExclusionSql } = require('./blacklistSql')

// A search payload may carry `fields` (current) or `type` (legacy). Neither
// present means the caller wants the default set.
Expand Down Expand Up @@ -1189,6 +1190,11 @@ const buildIndexWhere = (search = {}, filters = {}) => {
OR EXISTS (SELECT 1 FROM wishlist_entries w WHERE w.steam_id IS NOT NULL AND w.steam_id = ci.steam_id))`)
}

// Unconditional, not a filter flag: a blacklisted title stays hidden no matter
// which filters or saved filter is active. Mirrored in versions.js from the
// same builder.
parts.push(buildBlacklistExclusionSql('ci', 'ci.is_installed = 1'))

// Generated from ratingCategories.js. This was a second hand-written copy of
// the same average, and it drifted from the one in versions.js: both still
// counted fappability, and both treated an explicit 0 as a real score.
Expand Down
30 changes: 30 additions & 0 deletions electron/db/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,36 @@ const initializeDatabase = (dataDir) => {
db.run(`CREATE INDEX IF NOT EXISTS idx_wishlist_entries_f95_id ON wishlist_entries(f95_id);`, () => {});
db.run(`CREATE INDEX IF NOT EXISTS idx_wishlist_entries_lc_id ON wishlist_entries(lc_id);`, () => {});
db.run(`CREATE INDEX IF NOT EXISTS idx_wishlist_entries_steam_id ON wishlist_entries(steam_id);`, () => {});
// Browse titles the user never wants to see again. Kept separate from
// wishlist_entries (rather than a flag on it) because the two lists are
// mutually exclusive and the wishlist's rows are hydrated and installable,
// which a blacklisted title must never be. Only the ids the Browse exclusion
// matches on and what the Settings list displays are stored.
db.run(`
CREATE TABLE IF NOT EXISTS blacklist_entries
(
blacklist_id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_key TEXT NOT NULL UNIQUE,
source TEXT NOT NULL,
atlas_id INTEGER,
f95_id INTEGER,
lc_id INTEGER,
steam_id INTEGER,
gog_id INTEGER,
title TEXT NOT NULL,
creator TEXT,
banner_url TEXT,
site_url TEXT,
blacklisted_at INTEGER NOT NULL
);
`);
// The Browse exclusion (electron/db/blacklistSql.js) probes this table once
// per provider id for every catalog row; unindexed, each probe is a scan.
db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_atlas_id ON blacklist_entries(atlas_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_f95_id ON blacklist_entries(f95_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_lc_id ON blacklist_entries(lc_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_steam_id ON blacklist_entries(steam_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_blacklist_entries_gog_id ON blacklist_entries(gog_id);`);
// User-set manual source IDs (F95 / Steam / LewdCorner) entered from the
// game properties Mappings tab. Stored as a JSON blob on the per-game
// override row so it survives metadata refreshes and is independent of the
Expand Down
5 changes: 5 additions & 0 deletions electron/db/versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
const { extractUrlId } = require('./urlIdExtractor')
const { normalizeTagText, normalizeTagList } = require('./tagTokens')
const { tokenPredicate, tagColumnExpr, stripSpaces, escapeLike } = require('./tagFilterSql')
const { buildBlacklistExclusionSql } = require('./blacklistSql')

// A search payload may carry `fields` (current) or `type` (legacy, still in
// saved_filters.json). Neither means "use the default set".
Expand Down Expand Up @@ -1594,6 +1595,10 @@ const getCatalogGamesFromUnion = (appPath, isDev, options = {}) => {
WHERE wishlist.steam_id IS NOT NULL AND wishlist.steam_id = catalog.steam_id)
)`);
}
// Same clause as buildIndexWhere in catalogIndex.js. Without it a blacklisted
// title reappears whenever Browse falls back to this path (index not ready
// yet, updateAvailable, or a fast-path error).
filterWhereParts.push(buildBlacklistExclusionSql('catalog', 'catalog.is_installed = 1'));
// Generated from ratingCategories.js. The previous literal version listed
// the columns by hand, still counted fappability, and treated an explicit 0
// as a real score, so rating one category 0 dragged the average down instead
Expand Down
4 changes: 4 additions & 0 deletions electron/db/wishlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -580,4 +580,8 @@ module.exports = {
getWishlistEntries,
getWishlistEntryIdentities,
normalizeWishlistEntry,
// Exported for electron/db/blacklist.js, which needs a blacklisted row to
// carry every provider id the catalog knows so the Browse exclusion can hide
// the same title's rows from the other sources too.
resolveMissingIds,
}
38 changes: 38 additions & 0 deletions electron/ipc/games.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const { runDatabaseAudit, getInvalidMappingCount } = require('../db/audit')
const { getCatalogIndexStatus, rebuildCatalogIndex } = require('../db/catalogIndex')
const { runClientAudit, repairClientAuditSection } = require('../db/clientAudit')
const { auditSeasonMerges, applySeasonMerge, applyAllSeasonMerges } = require('../db/seasonMerge')
// Required directly for the same curated-ctx reason as tagOverrides above.
const { addBlacklistEntry, removeBlacklistEntry, getBlacklistEntries } = require('../db/blacklist')

// Guards against two full rebuilds interleaving their chunked transactions on
// the single shared sqlite connection.
Expand All @@ -30,6 +32,17 @@ let catalogIndexRebuildInFlight = false
// may run at a time.
let clientAuditRepairInFlight = false

// Every window, not just the sender: the Settings list and Browse live in
// different BrowserWindows, and each has to redraw when the other changes the
// blacklist. Browse cannot patch its rows in place -- the grid is a sparse array
// indexed by row offset, so one removed title shifts every loaded page -- which
// is why this is a signal to refetch rather than a diff.
function broadcastBlacklistUpdated(payload) {
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) win.webContents.send('blacklist-updated', payload)
})
}

function emitGameUpdated(recordId) {
if (!recordId) return
BrowserWindow.getAllWindows().forEach((win) => {
Expand Down Expand Up @@ -472,6 +485,31 @@ function registerGamesHandlers(ctx) {
return await getWishlistEntryIdentities()
})

// Used by the detail page's Blacklist button. The broadcast carries
// removedFromWishlist because blacklisting can also delete a wishlist row, and
// the main window must refresh its wishlist state from that one event rather
// than a second wishlist-updated that would refetch Browse again.
ipcMain.handle('blacklist-add', async (_, entry = {}) => {
const result = await addBlacklistEntry(entry)
broadcastBlacklistUpdated({ removedFromWishlist: result.removedFromWishlist === true })
return result
})

// Called from Settings > Blacklist. The title has to reappear in Browse in the
// main window, which is a different window from the one that made the call.
ipcMain.handle('blacklist-remove', async (_, identity = {}) => {
const result = await removeBlacklistEntry(identity)
broadcastBlacklistUpdated({ removedFromWishlist: false })
return result
})

// Raw rows, deliberately not passed through withMedia: the Settings list only
// shows a small remote thumbnail, and localising media for every entry is work
// the list does not need.
ipcMain.handle('blacklist-list', async () => {
return await getBlacklistEntries()
})

ipcMain.handle('validate-library-paths', async (event) => {
if (ctx.activeLibraryValidation?.running) {
return { success: true, alreadyRunning: true }
Expand Down
22 changes: 22 additions & 0 deletions electron/ipc/windows.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,28 @@ async function handleContextAction(data, sender, ctx) {
});
break;
}
case "blacklistGame": {
// Unlike toggleWishlist there is no optimistic flip in the renderer to
// reconcile -- a title cannot be hidden from a sparse grid in place -- so
// the broadcast goes out only when the write landed, and a failure is
// returned so the renderer can say so instead of the click doing nothing.
const { addBlacklistEntry } = require("../db/blacklist");
let result;
try {
result = await addBlacklistEntry(data);
} catch (err) {
console.error("blacklistGame failed", err);
return { success: false, error: err?.message || String(err) };
}
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send("blacklist-updated", {
removedFromWishlist: result?.removedFromWishlist === true,
});
}
});
return result;
}
case "collectionBulkTagRequested": {
// Same round-trip as rename/delete: a native menu cannot host a form, so
// the renderer owns the dialog and already knows which records belong to
Expand Down
9 changes: 9 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
isWishlistEntry: (identity) => ipcRenderer.invoke("wishlist-check", identity),
getWishlistEntries: () => ipcRenderer.invoke("wishlist-list"),
getWishlistEntryIdentities: () => ipcRenderer.invoke("wishlist-identities"),
addBlacklistEntry: (entry) => ipcRenderer.invoke("blacklist-add", entry),
removeBlacklistEntry: (identity) =>
ipcRenderer.invoke("blacklist-remove", identity),
getBlacklistEntries: () => ipcRenderer.invoke("blacklist-list"),
validateLibraryPaths: () => ipcRenderer.invoke("validate-library-paths"),
removeGame: (id) => ipcRenderer.invoke("remove-game", id),
checkUpdates: () => ipcRenderer.invoke("check-updates"),
Expand Down Expand Up @@ -423,6 +427,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("wishlist-updated", handler);
return () => ipcRenderer.removeListener("wishlist-updated", handler);
},
onBlacklistUpdated: (callback) => {
const handler = (event, payload) => callback(payload);
ipcRenderer.on("blacklist-updated", handler);
return () => ipcRenderer.removeListener("blacklist-updated", handler);
},
onDbUpdateProgress: (callback) => {
ipcRenderer.on("db-update-progress", (event, progress) =>
callback(progress),
Expand Down
Loading