增加浏览器离线通知功能

This commit is contained in:
mtvpls
2026-06-02 20:50:33 +08:00
parent 16e48bef8b
commit d600317ade
20 changed files with 1739 additions and 91 deletions
+104 -1
View File
@@ -11,8 +11,9 @@ import {
MusicV2PlaylistRecord,
} from './music-v2';
import { RedisAdapter } from './redis-adapter';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
import { Favorite, IStorage, Notification, PlayRecord, PushSubscriptionRecord, SkipConfig } from './types';
import { userInfoCache } from './user-cache';
import { dispatchWebPushNotification } from './web-push';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -2268,6 +2269,8 @@ export abstract class BaseRedisStorage implements IStorage {
JSON.stringify(notifications)
)
);
await dispatchWebPushNotification(this, userName, notification);
}
async markNotificationAsRead(
@@ -2465,6 +2468,106 @@ export abstract class BaseRedisStorage implements IStorage {
userInfoCache?.delete(userName);
}
private pushSubscriptionsKey(userName: string): string {
return `u:${userName}:push_subscriptions`;
}
async upsertPushSubscription(
userName: string,
subscription: PushSubscriptionRecord
): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.pushSubscriptionsKey(userName),
subscription.id,
JSON.stringify({ ...subscription, username: userName, updatedAt: Date.now() })
)
);
}
async getEnabledPushSubscriptions(userName: string): Promise<PushSubscriptionRecord[]> {
const all = await this.withRetry(() =>
this.adapter.hGetAll(this.pushSubscriptionsKey(userName))
);
if (!all || typeof all !== 'object') return [];
return Object.values(all)
.map((raw) => {
try {
return JSON.parse(raw as string) as PushSubscriptionRecord;
} catch {
return null;
}
})
.filter((item): item is PushSubscriptionRecord => Boolean(item?.enabled));
}
async deletePushSubscriptionByEndpoint(userName: string, endpoint: string): Promise<void> {
const subscriptions = await this.getEnabledPushSubscriptions(userName);
const target = subscriptions.find((item) => item.endpoint === endpoint);
if (!target) return;
await this.withRetry(() =>
this.adapter.hDel(this.pushSubscriptionsKey(userName), target.id)
);
}
async deletePushSubscriptionsByTokenId(userName: string, tokenId: string): Promise<void> {
const all = await this.withRetry(() =>
this.adapter.hGetAll(this.pushSubscriptionsKey(userName))
);
if (!all || typeof all !== 'object') return;
for (const [id, raw] of Object.entries(all)) {
try {
const subscription = JSON.parse(raw as string) as PushSubscriptionRecord;
if (subscription.tokenId === tokenId) {
await this.withRetry(() =>
this.adapter.hDel(this.pushSubscriptionsKey(userName), id)
);
}
} catch {
// ignore malformed record
}
}
}
async deleteAllPushSubscriptions(userName: string): Promise<void> {
await this.withRetry(() => this.adapter.del(this.pushSubscriptionsKey(userName)));
}
async updatePushSubscriptionDeliveryStats(
userName: string,
endpoint: string,
success: boolean
): Promise<void> {
const all = await this.withRetry(() =>
this.adapter.hGetAll(this.pushSubscriptionsKey(userName))
);
if (!all || typeof all !== 'object') return;
for (const [id, raw] of Object.entries(all)) {
try {
const subscription = JSON.parse(raw as string) as PushSubscriptionRecord;
if (subscription.endpoint !== endpoint) continue;
const now = Date.now();
const next = {
...subscription,
updatedAt: now,
lastSuccessAt: success ? now : subscription.lastSuccessAt || null,
lastFailureAt: success ? subscription.lastFailureAt || null : now,
failureCount: success ? 0 : (subscription.failureCount || 0) + 1,
};
await this.withRetry(() =>
this.adapter.hSet(this.pushSubscriptionsKey(userName), id, JSON.stringify(next))
);
return;
} catch {
// ignore malformed record
}
}
}
// ---------- TVBox订阅token相关 ----------
async getTvboxSubscribeToken(userName: string): Promise<string | null> {
// 直接从数据库读取,不使用缓存