add avatar feed database cleanup settings and purge function

This commit is contained in:
pa
2026-03-16 21:52:34 +09:00
parent a8a14ae901
commit 357ac1a8bb
5 changed files with 241 additions and 1 deletions

View File

@@ -1059,6 +1059,29 @@
"video_play": "Video Play:",
"event": "Event:"
},
"database_cleanup": {
"header": "Database Cleanup",
"auto_cleanup": "Auto-cleanup avatar data older than",
"auto_cleanup_description": "Checked once per week on startup",
"auto_cleanup_off": "Off",
"auto_cleanup_30": "30 days",
"auto_cleanup_90": "90 days",
"auto_cleanup_180": "6 months",
"auto_cleanup_365": "1 year",
"purge_button": "Purge Avatar Feed Data",
"purge": "Purge",
"purge_older_than": "Delete avatar data older than",
"purge_option_180": "6 months",
"purge_option_365": "1 year",
"purge_option_730": "2 years",
"purge_option_all": "All data",
"purge_confirm_title": "Purge Avatar Feed Data",
"purge_confirm_description": "This will permanently delete avatar change records from the database and reclaim disk space. This action cannot be undone!\n\nIt is strongly recommended to back up your database file before proceeding.\n\nVRCX will restart after the operation completes.",
"purge_confirm_button": "Purge & Restart",
"purge_in_progress": "Purging avatar data...",
"purge_complete": "Avatar data purged successfully. Restarting...",
"purge_failed": "Purge failed: {error}"
},
"user_generated_content": {
"header": "User Generated Content",
"folder": "Open Folder",

View File

@@ -67,6 +67,26 @@ const feed = {
);
},
/**
* Purges avatar feed data from the database.
* !!!!
* @param {string|null} cutoffDate - ISO date string. Deletes records older than this date. If null, deletes all records.
*/
async purgeAvatarFeedData(cutoffDate) {
if (cutoffDate) {
await sqliteService.executeNonQuery(
`DELETE FROM ${dbVars.userPrefix}_feed_avatar WHERE created_at < @cutoff`,
{
'@cutoff': cutoffDate
}
);
} else {
await sqliteService.executeNonQuery(
`DELETE FROM ${dbVars.userPrefix}_feed_avatar`
);
}
},
addOnlineOfflineToDatabase(entry) {
sqliteService.executeNonQuery(
`INSERT OR IGNORE INTO ${dbVars.userPrefix}_feed_online_offline (created_at, user_id, display_name, type, location, world_name, time, group_name) VALUES (@created_at, @user_id, @display_name, @type, @location, @world_name, @time, @group_name)`,

View File

@@ -839,6 +839,7 @@ export const useAuthStore = defineStore('Auth', () => {
*/
async function loginComplete() {
await database.initUserTables(userStore.currentUser.id);
advancedSettingsStore.runAvatarAutoCleanup(userStore.currentUser.id);
watchState.isLoggedIn = true;
AppApi.CheckGameRunning(); // restore state from hot-reload
}

View File

@@ -60,6 +60,8 @@ export const useAdvancedSettingsStore = defineStore('AdvancedSettings', () => {
const showConfirmationOnSwitchAvatar = ref(false);
const gameLogDisabled = ref(false);
const sqliteTableSizes = ref({});
const avatarAutoCleanup = ref('Off');
const purgeInProgress = ref(false);
const ugcFolderPath = ref('');
const autoDeleteOldPrints = ref(false);
const notificationOpacity = ref(100);
@@ -109,6 +111,7 @@ export const useAdvancedSettingsStore = defineStore('AdvancedSettings', () => {
progressPieFilterConfig,
showConfirmationOnSwitchAvatarConfig,
gameLogDisabledConfig,
avatarAutoCleanupConfig,
ugcFolderPathConfig,
autoDeleteOldPrintsConfig,
notificationOpacityConfig,
@@ -157,6 +160,7 @@ export const useAdvancedSettingsStore = defineStore('AdvancedSettings', () => {
false
),
configRepository.getBool('VRCX_gameLogDisabled', false),
configRepository.getString('VRCX_avatarAutoCleanup', 'Off'),
configRepository.getString('VRCX_userGeneratedContentPath', ''),
configRepository.getBool('VRCX_autoDeleteOldPrints', false),
configRepository.getFloat('VRCX_notificationOpacity', 100),
@@ -203,6 +207,7 @@ export const useAdvancedSettingsStore = defineStore('AdvancedSettings', () => {
showConfirmationOnSwitchAvatar.value =
showConfirmationOnSwitchAvatarConfig;
gameLogDisabled.value = gameLogDisabledConfig;
avatarAutoCleanup.value = avatarAutoCleanupConfig;
ugcFolderPath.value = ugcFolderPathConfig;
autoDeleteOldPrints.value = autoDeleteOldPrintsConfig;
notificationOpacity.value = notificationOpacityConfig;
@@ -524,6 +529,101 @@ export const useAdvancedSettingsStore = defineStore('AdvancedSettings', () => {
);
}
async function setAvatarAutoCleanup(value) {
avatarAutoCleanup.value = value;
await configRepository.setString('VRCX_avatarAutoCleanup', value);
}
/**
* @param {number|null} days - Number of days to keep. Null means delete all.
*/
async function purgeAvatarFeedData(days) {
let cutoffDate = null;
if (days !== null) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
cutoffDate = cutoff.toJSON();
}
purgeInProgress.value = true;
const msgBox = toast.warning(
t(
'view.settings.advanced.advanced.database_cleanup.purge_in_progress'
),
{ duration: Infinity }
);
try {
await database.purgeAvatarFeedData(cutoffDate);
await database.vacuum();
toast.dismiss(msgBox);
toast.success(
t(
'view.settings.advanced.advanced.database_cleanup.purge_complete'
)
);
// Brief delay before restart to show success message
await new Promise((resolve) =>
setTimeout(resolve, 1500)
);
VRCXUpdaterStore.restartVRCX(false);
} catch (err) {
console.error(err);
toast.dismiss(msgBox);
toast.error(t('view.settings.advanced.advanced.database_cleanup.purge_failed', { error: err }));
} finally {
purgeInProgress.value = false;
}
}
/**
* Run auto-cleanup on startup if configured and enough time has passed.
* Reads config directly from configRepository to avoid race condition
* with initAdvancedSettings not having completed yet.
* @param {string} userId - Current user ID for per-user cleanup tracking.
*/
async function runAvatarAutoCleanup(userId) {
const cleanupSetting = await configRepository.getString(
'VRCX_avatarAutoCleanup',
'Off'
);
if (cleanupSetting === 'Off') return;
const configKey = `VRCX_lastAvatarCleanupDate_${userId}`;
const lastCleanupStr = await configRepository.getString(
configKey,
''
);
const now = new Date();
if (lastCleanupStr) {
const lastCleanup = new Date(lastCleanupStr);
const daysSinceLastCleanup =
(now - lastCleanup) / (1000 * 60 * 60 * 24);
if (daysSinceLastCleanup < 7) return;
}
const days = parseInt(cleanupSetting, 10);
if (isNaN(days) || days <= 0) return;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffDate = cutoff.toJSON();
try {
await database.purgeAvatarFeedData(cutoffDate);
await configRepository.setString(
configKey,
now.toJSON()
);
console.log(
`Auto-cleaned avatar feed data older than ${days} days`
);
} catch (err) {
console.error('Avatar auto-cleanup failed:', err);
}
}
async function setSaveInstanceEmoji() {
saveInstanceEmoji.value = !saveInstanceEmoji.value;
await configRepository.setBool(
@@ -1030,6 +1130,8 @@ export const useAdvancedSettingsStore = defineStore('AdvancedSettings', () => {
showConfirmationOnSwitchAvatar,
gameLogDisabled,
sqliteTableSizes,
avatarAutoCleanup,
purgeInProgress,
ugcFolderPath,
currentUserInventory,
autoDeleteOldPrints,
@@ -1069,6 +1171,9 @@ export const useAdvancedSettingsStore = defineStore('AdvancedSettings', () => {
setProgressPieFilter,
setShowConfirmationOnSwitchAvatar,
setGameLogDisabled,
setAvatarAutoCleanup,
purgeAvatarFeedData,
runAvatarAutoCleanup,
setUGCFolderPath,
cropPrintsChanged,
setAutoDeleteOldPrints,

View File

@@ -247,7 +247,81 @@
</div>
</SettingsGroup>
<SettingsGroup :title="t('view.settings.advanced.advanced.database_cleanup.header')">
<SettingsItem
:label="t('view.settings.advanced.advanced.database_cleanup.auto_cleanup')"
:description="t('view.settings.advanced.advanced.database_cleanup.auto_cleanup_description')">
<Select :model-value="avatarAutoCleanup" @update:modelValue="setAvatarAutoCleanup">
<SelectTrigger class="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="Off">{{ t('view.settings.advanced.advanced.database_cleanup.auto_cleanup_off') }}</SelectItem>
<SelectItem value="30">{{ t('view.settings.advanced.advanced.database_cleanup.auto_cleanup_30') }}</SelectItem>
<SelectItem value="90">{{ t('view.settings.advanced.advanced.database_cleanup.auto_cleanup_90') }}</SelectItem>
<SelectItem value="180">{{ t('view.settings.advanced.advanced.database_cleanup.auto_cleanup_180') }}</SelectItem>
<SelectItem value="365">{{ t('view.settings.advanced.advanced.database_cleanup.auto_cleanup_365') }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</SettingsItem>
<SettingsItem :label="t('view.settings.advanced.advanced.database_cleanup.purge_button')">
<Button size="sm" variant="outline" @click="isPurgeDialogVisible = true">
<Trash2 class="h-4 w-4 mr-1" />
{{ t('view.settings.advanced.advanced.database_cleanup.purge') }}
</Button>
</SettingsItem>
</SettingsGroup>
<Dialog :open="isPurgeDialogVisible" @update:open="(open) => { if (!open) isPurgeDialogVisible = false; }">
<DialogContent class="x-dialog sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('view.settings.advanced.advanced.database_cleanup.purge_confirm_title') }}</DialogTitle>
</DialogHeader>
<Alert variant="warning" class="mb-3">
<TriangleAlert />
<AlertDescription>
{{ t('view.settings.advanced.advanced.database_cleanup.purge_confirm_description') }}
</AlertDescription>
</Alert>
<SettingsItem :label="t('view.settings.advanced.advanced.database_cleanup.purge_older_than')">
<Select v-model="selectedPurgePeriod">
<SelectTrigger class="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="180">{{ t('view.settings.advanced.advanced.database_cleanup.purge_option_180') }}</SelectItem>
<SelectItem value="365">{{ t('view.settings.advanced.advanced.database_cleanup.purge_option_365') }}</SelectItem>
<SelectItem value="730">{{ t('view.settings.advanced.advanced.database_cleanup.purge_option_730') }}</SelectItem>
<SelectItem value="all">{{ t('view.settings.advanced.advanced.database_cleanup.purge_option_all') }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</SettingsItem>
<DialogFooter>
<Button variant="outline" size="sm" @click="isPurgeDialogVisible = false">
{{ t('confirm.cancel_button') }}
</Button>
<Button
size="sm"
variant="destructive"
:disabled="purgeInProgress"
@click="handlePurge">
<Trash2 class="h-4 w-4 mr-1" />
{{ t('view.settings.advanced.advanced.database_cleanup.purge_confirm_button') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SettingsGroup :title="t('view.profile.config_json')">
<div class="flex items-center gap-2">
<TooltipWrapper side="top" :content="t('view.profile.refresh_tooltip')">
<Button class="rounded-full" size="icon-sm" variant="outline" @click="refreshConfigTreeData()">
@@ -280,10 +354,13 @@
</template>
<script setup>
import { Languages, RefreshCcw, Trash2 } from 'lucide-vue-next';
import { Languages, RefreshCcw, Trash2, TriangleAlert } from 'lucide-vue-next';
import { computed, reactive, ref } from 'vue';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -368,6 +445,8 @@
showConfirmationOnSwitchAvatar,
gameLogDisabled,
sqliteTableSizes,
avatarAutoCleanup,
purgeInProgress,
sentryErrorReporting
} = storeToRefs(advancedSettingsStore);
@@ -382,6 +461,8 @@
setEnableAppLauncherRunProcessOnce,
setShowConfirmationOnSwitchAvatar,
getSqliteTableSizes,
setAvatarAutoCleanup,
purgeAvatarFeedData,
showVRChatConfig,
promptAutoClearVRCXCacheFrequency,
setSentryErrorReporting
@@ -394,6 +475,8 @@
const isTranslationApiDialogVisible = ref(false);
const configTreeData = ref({});
const visits = ref(0);
const selectedPurgePeriod = ref('180');
const isPurgeDialogVisible = ref(false);
const cacheSize = reactive({
cachedUsers: 0,
@@ -406,6 +489,14 @@
const isLinux = computed(() => LINUX);
function handlePurge() {
const days =
selectedPurgePeriod.value === 'all'
? null
: parseInt(selectedPurgePeriod.value, 10);
purgeAvatarFeedData(days);
}
/**
*
*/