tangled
alpha
login
or
join now
dunkirk.sh
/
pstream-ng
1
fork
atom
pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
1
fork
atom
overview
issues
pulls
pipelines
add watch history
Pas
2 months ago
e544334b
45ecb9d8
+749
-6
12 changed files
expand all
collapse all
unified
split
src
assets
locales
en.json
backend
accounts
user.ts
watchHistory.ts
components
LinksDropdown.tsx
hooks
auth
useAuth.ts
useAuthData.ts
index.tsx
pages
watchHistory
WatchHistory.tsx
setup
App.tsx
stores
progress
index.ts
watchHistory
WatchHistorySyncer.tsx
index.ts
+4
src/assets/locales/en.json
···
378
378
"mediaList": {
379
379
"stopEditing": "Stop editing"
380
380
},
381
381
+
"watchHistory": {
382
382
+
"sectionTitle": "Watch History",
383
383
+
"recentlyWatched": "Recently Watched"
384
384
+
},
381
385
"search": {
382
386
"allResults": "That's all we have...",
383
387
"failed": "Failed to find media, try again!",
+62
src/backend/accounts/user.ts
···
4
4
import { AccountWithToken } from "@/stores/auth";
5
5
import { BookmarkMediaItem } from "@/stores/bookmarks";
6
6
import { ProgressMediaItem } from "@/stores/progress";
7
7
+
import { WatchHistoryItem } from "@/stores/watchHistory";
7
8
8
9
export interface UserResponse {
9
10
id: string;
···
60
61
updatedAt: string;
61
62
}
62
63
64
64
+
export interface WatchHistoryResponse {
65
65
+
tmdbId: string;
66
66
+
season: {
67
67
+
id?: string;
68
68
+
number?: number;
69
69
+
};
70
70
+
episode: {
71
71
+
id?: string;
72
72
+
number?: number;
73
73
+
};
74
74
+
meta: {
75
75
+
title: string;
76
76
+
year: number;
77
77
+
poster?: string;
78
78
+
type: "show" | "movie";
79
79
+
};
80
80
+
duration: string;
81
81
+
watched: string;
82
82
+
watchedAt: string;
83
83
+
completed: boolean;
84
84
+
}
85
85
+
63
86
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
64
87
const entries = responses.map((bookmark) => {
65
88
const item: BookmarkMediaItem = {
···
128
151
return items;
129
152
}
130
153
154
154
+
export function watchHistoryResponsesToEntries(
155
155
+
responses: WatchHistoryResponse[],
156
156
+
) {
157
157
+
const items: Record<string, WatchHistoryItem> = {};
158
158
+
159
159
+
responses.forEach((v) => {
160
160
+
const key = v.episode?.id ? `${v.tmdbId}-${v.episode.id}` : v.tmdbId;
161
161
+
162
162
+
items[key] = {
163
163
+
type: v.meta.type,
164
164
+
title: v.meta.title,
165
165
+
poster: v.meta.poster,
166
166
+
year: v.meta.year,
167
167
+
progress: {
168
168
+
duration: Number(v.duration),
169
169
+
watched: Number(v.watched),
170
170
+
},
171
171
+
watchedAt: new Date(v.watchedAt).getTime(),
172
172
+
completed: v.completed,
173
173
+
episodeId: v.episode?.id,
174
174
+
seasonId: v.season?.id,
175
175
+
seasonNumber: v.season?.number,
176
176
+
episodeNumber: v.episode?.number,
177
177
+
};
178
178
+
});
179
179
+
180
180
+
return items;
181
181
+
}
182
182
+
131
183
export async function getUser(
132
184
url: string,
133
185
token: string,
···
181
233
baseURL: url,
182
234
});
183
235
}
236
236
+
237
237
+
export async function getWatchHistory(url: string, account: AccountWithToken) {
238
238
+
return ofetch<WatchHistoryResponse[]>(
239
239
+
`/users/${account.userId}/watch-history`,
240
240
+
{
241
241
+
headers: getAuthHeaders(account.token),
242
242
+
baseURL: url,
243
243
+
},
244
244
+
);
245
245
+
}
+111
src/backend/accounts/watchHistory.ts
···
1
1
+
import { ofetch } from "ofetch";
2
2
+
3
3
+
import { getAuthHeaders } from "@/backend/accounts/auth";
4
4
+
import { AccountWithToken } from "@/stores/auth";
5
5
+
import {
6
6
+
WatchHistoryItem,
7
7
+
WatchHistoryUpdateItem,
8
8
+
} from "@/stores/watchHistory";
9
9
+
10
10
+
export interface WatchHistoryInput {
11
11
+
meta?: {
12
12
+
title: string;
13
13
+
year: number;
14
14
+
poster?: string;
15
15
+
type: string;
16
16
+
};
17
17
+
tmdbId: string;
18
18
+
watched: number;
19
19
+
duration: number;
20
20
+
watchedAt: string;
21
21
+
completed: boolean;
22
22
+
seasonId?: string;
23
23
+
episodeId?: string;
24
24
+
seasonNumber?: number;
25
25
+
episodeNumber?: number;
26
26
+
}
27
27
+
28
28
+
export interface WatchHistoryResponse {
29
29
+
success: boolean;
30
30
+
}
31
31
+
32
32
+
export function watchHistoryUpdateItemToInput(
33
33
+
item: WatchHistoryUpdateItem,
34
34
+
): WatchHistoryInput {
35
35
+
return {
36
36
+
duration: item.progress?.duration ?? 0,
37
37
+
watched: item.progress?.watched ?? 0,
38
38
+
watchedAt: item.watchedAt
39
39
+
? new Date(item.watchedAt).toISOString()
40
40
+
: new Date().toISOString(),
41
41
+
completed: item.completed ?? false,
42
42
+
tmdbId: item.tmdbId,
43
43
+
meta: {
44
44
+
title: item.title ?? "",
45
45
+
type: item.type ?? "",
46
46
+
year: item.year ?? NaN,
47
47
+
poster: item.poster,
48
48
+
},
49
49
+
episodeId: item.episodeId,
50
50
+
seasonId: item.seasonId,
51
51
+
episodeNumber: item.episodeNumber,
52
52
+
seasonNumber: item.seasonNumber,
53
53
+
};
54
54
+
}
55
55
+
56
56
+
export function watchHistoryItemToInputs(
57
57
+
id: string,
58
58
+
item: WatchHistoryItem,
59
59
+
): WatchHistoryInput {
60
60
+
return {
61
61
+
duration: item.progress.duration,
62
62
+
watched: item.progress.watched,
63
63
+
watchedAt: new Date(item.watchedAt).toISOString(),
64
64
+
completed: item.completed,
65
65
+
tmdbId: item.episodeId ? item.seasonId || id.split("-")[0] : id,
66
66
+
meta: {
67
67
+
title: item.title,
68
68
+
type: item.type,
69
69
+
year: item.year ?? NaN,
70
70
+
poster: item.poster,
71
71
+
},
72
72
+
episodeId: item.episodeId,
73
73
+
seasonId: item.seasonId,
74
74
+
episodeNumber: item.episodeNumber,
75
75
+
seasonNumber: item.seasonNumber,
76
76
+
};
77
77
+
}
78
78
+
79
79
+
export async function setWatchHistory(
80
80
+
url: string,
81
81
+
account: AccountWithToken,
82
82
+
input: WatchHistoryInput,
83
83
+
) {
84
84
+
return ofetch<WatchHistoryResponse>(
85
85
+
`/users/${account.userId}/watch-history/${input.tmdbId}`,
86
86
+
{
87
87
+
method: "PUT",
88
88
+
headers: getAuthHeaders(account.token),
89
89
+
baseURL: url,
90
90
+
body: input,
91
91
+
},
92
92
+
);
93
93
+
}
94
94
+
95
95
+
export async function removeWatchHistory(
96
96
+
url: string,
97
97
+
account: AccountWithToken,
98
98
+
id: string,
99
99
+
episodeId?: string,
100
100
+
seasonId?: string,
101
101
+
) {
102
102
+
await ofetch(`/users/${account.userId}/watch-history/${id}`, {
103
103
+
method: "DELETE",
104
104
+
headers: getAuthHeaders(account.token),
105
105
+
baseURL: url,
106
106
+
body: {
107
107
+
episodeId,
108
108
+
seasonId,
109
109
+
},
110
110
+
});
111
111
+
}
+3
src/components/LinksDropdown.tsx
···
291
291
<DropdownLink href="/settings" icon={Icons.SETTINGS}>
292
292
{t("navigation.menu.settings")}
293
293
</DropdownLink>
294
294
+
<DropdownLink href="/watch-history" icon={Icons.CLOCK}>
295
295
+
{t("home.watchHistory.sectionTitle")}
296
296
+
</DropdownLink>
294
297
{process.env.NODE_ENV === "development" ? (
295
298
<DropdownLink href="/dev" icon={Icons.COMPRESS}>
296
299
{t("navigation.menu.development")}
+10
-6
src/hooks/auth/useAuth.ts
···
27
27
getBookmarks,
28
28
getProgress,
29
29
getUser,
30
30
+
getWatchHistory,
30
31
} from "@/backend/accounts/user";
31
32
import { useAuthData } from "@/hooks/auth/useAuthData";
32
33
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
···
238
239
throw err;
239
240
}
240
241
241
241
-
const [bookmarks, progress, settings, groupOrder] = await Promise.all([
242
242
-
getBookmarks(backendUrl, account),
243
243
-
getProgress(backendUrl, account),
244
244
-
getSettings(backendUrl, account),
245
245
-
getGroupOrder(backendUrl, account),
246
246
-
]);
242
242
+
const [bookmarks, progress, watchHistory, settings, groupOrder] =
243
243
+
await Promise.all([
244
244
+
getBookmarks(backendUrl, account),
245
245
+
getProgress(backendUrl, account),
246
246
+
getWatchHistory(backendUrl, account),
247
247
+
getSettings(backendUrl, account),
248
248
+
getGroupOrder(backendUrl, account),
249
249
+
]);
247
250
248
251
// Update account store with fresh user data (including nickname)
249
252
const { setAccount } = useAuthStore.getState();
···
260
263
user.session,
261
264
progress,
262
265
bookmarks,
266
266
+
watchHistory,
263
267
settings,
264
268
groupOrder,
265
269
);
+10
src/hooks/auth/useAuthData.ts
···
6
6
BookmarkResponse,
7
7
ProgressResponse,
8
8
UserResponse,
9
9
+
WatchHistoryResponse,
9
10
bookmarkResponsesToEntries,
10
11
progressResponsesToEntries,
12
12
+
watchHistoryResponsesToEntries,
11
13
} from "@/backend/accounts/user";
12
14
import { useAuthStore } from "@/stores/auth";
13
15
import { useBookmarkStore } from "@/stores/bookmarks";
···
17
19
import { useProgressStore } from "@/stores/progress";
18
20
import { useSubtitleStore } from "@/stores/subtitles";
19
21
import { useThemeStore } from "@/stores/theme";
22
22
+
import { useWatchHistoryStore } from "@/stores/watchHistory";
20
23
21
24
export function useAuthData() {
22
25
const loggedIn = !!useAuthStore((s) => s.account);
···
25
28
const setProxySet = useAuthStore((s) => s.setProxySet);
26
29
const clearBookmarks = useBookmarkStore((s) => s.clear);
27
30
const clearProgress = useProgressStore((s) => s.clear);
31
31
+
const clearWatchHistory = useWatchHistoryStore((s) => s.clear);
28
32
const clearGroupOrder = useGroupOrderStore((s) => s.clear);
29
33
const setTheme = useThemeStore((s) => s.setTheme);
30
34
const setAppLanguage = useLanguageStore((s) => s.setLanguage);
···
37
41
38
42
const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks);
39
43
const replaceItems = useProgressStore((s) => s.replaceItems);
44
44
+
const replaceWatchHistory = useWatchHistoryStore((s) => s.replaceItems);
40
45
41
46
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
42
47
const setEnableAutoplay = usePreferencesStore((s) => s.setEnableAutoplay);
···
122
127
removeAccount();
123
128
clearBookmarks();
124
129
clearProgress();
130
130
+
clearWatchHistory();
125
131
clearGroupOrder();
126
132
setFebboxKey(null);
127
133
}, [
128
134
removeAccount,
129
135
clearBookmarks,
130
136
clearProgress,
137
137
+
clearWatchHistory,
131
138
clearGroupOrder,
132
139
setFebboxKey,
133
140
]);
···
138
145
_session: SessionResponse,
139
146
progress: ProgressResponse[],
140
147
bookmarks: BookmarkResponse[],
148
148
+
watchHistory: WatchHistoryResponse[],
141
149
settings: SettingsResponse,
142
150
groupOrder: { groupOrder: string[] },
143
151
) => {
144
152
replaceBookmarks(bookmarkResponsesToEntries(bookmarks));
145
153
replaceItems(progressResponsesToEntries(progress));
154
154
+
replaceWatchHistory(watchHistoryResponsesToEntries(watchHistory));
146
155
147
156
if (groupOrder?.groupOrder) {
148
157
useGroupOrderStore.getState().setGroupOrder(groupOrder.groupOrder);
···
283
292
[
284
293
replaceBookmarks,
285
294
replaceItems,
295
295
+
replaceWatchHistory,
286
296
setAppLanguage,
287
297
importSubtitleLanguage,
288
298
setTheme,
+2
src/index.tsx
···
30
30
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
31
31
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
32
32
import { ThemeProvider } from "@/stores/theme";
33
33
+
import { WatchHistorySyncer } from "@/stores/watchHistory/WatchHistorySyncer";
33
34
import { detectRegion, useRegionStore } from "@/utils/detectRegion";
34
35
35
36
import {
···
248
249
<ThemeProvider applyGlobal>
249
250
<ProgressSyncer />
250
251
<BookmarkSyncer />
252
252
+
<WatchHistorySyncer />
251
253
<GroupSyncer />
252
254
<SettingsSyncer />
253
255
<TheRouter>
+209
src/pages/watchHistory/WatchHistory.tsx
···
1
1
+
import { useAutoAnimate } from "@formkit/auto-animate/react";
2
2
+
import { useMemo, useState } from "react";
3
3
+
import { useTranslation } from "react-i18next";
4
4
+
import { useNavigate } from "react-router-dom";
5
5
+
6
6
+
import { Button } from "@/components/buttons/Button";
7
7
+
import { EditButton } from "@/components/buttons/EditButton";
8
8
+
import { Icon, Icons } from "@/components/Icon";
9
9
+
import { SectionHeading } from "@/components/layout/SectionHeading";
10
10
+
import { WideContainer } from "@/components/layout/WideContainer";
11
11
+
import { MediaCard } from "@/components/media/MediaCard";
12
12
+
import { MediaGrid } from "@/components/media/MediaGrid";
13
13
+
import { Heading1 } from "@/components/utils/Text";
14
14
+
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
15
15
+
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
16
16
+
import { useOverlayStack } from "@/stores/interface/overlayStack";
17
17
+
import { WatchHistoryItem, useWatchHistoryStore } from "@/stores/watchHistory";
18
18
+
import { MediaItem } from "@/utils/mediaTypes";
19
19
+
20
20
+
interface WatchHistoryProps {
21
21
+
onShowDetails?: (media: MediaItem) => void;
22
22
+
}
23
23
+
24
24
+
function formatWatchHistorySeries(historyItem: WatchHistoryItem) {
25
25
+
if (
26
26
+
!historyItem.episodeId ||
27
27
+
!historyItem.seasonId ||
28
28
+
!historyItem.episodeNumber
29
29
+
)
30
30
+
return undefined;
31
31
+
return {
32
32
+
episode: historyItem.episodeNumber,
33
33
+
season: historyItem.seasonNumber,
34
34
+
episodeId: historyItem.episodeId,
35
35
+
seasonId: historyItem.seasonId,
36
36
+
};
37
37
+
}
38
38
+
39
39
+
function getWatchHistoryPercentage(
40
40
+
historyItem: WatchHistoryItem,
41
41
+
): number | undefined {
42
42
+
const { progress } = historyItem;
43
43
+
if (!progress.duration || progress.duration <= 0) return undefined;
44
44
+
if (!progress.watched || progress.watched < 0) return undefined;
45
45
+
46
46
+
const percentage = Math.min(
47
47
+
(progress.watched / progress.duration) * 100,
48
48
+
100,
49
49
+
);
50
50
+
return percentage;
51
51
+
}
52
52
+
53
53
+
export function WatchHistory({ onShowDetails }: WatchHistoryProps) {
54
54
+
const { t } = useTranslation();
55
55
+
const { t: randomT } = useRandomTranslation();
56
56
+
const emptyText = randomT(`home.search.empty`);
57
57
+
const navigate = useNavigate();
58
58
+
const watchHistory = useWatchHistoryStore((s) => s.items);
59
59
+
const removeItem = useWatchHistoryStore((s) => s.removeItem);
60
60
+
const [editing, setEditing] = useState(false);
61
61
+
const [gridRef] = useAutoAnimate<HTMLDivElement>();
62
62
+
const { showModal } = useOverlayStack();
63
63
+
64
64
+
const handleShowDetails = async (media: MediaItem) => {
65
65
+
if (onShowDetails) {
66
66
+
onShowDetails(media);
67
67
+
} else {
68
68
+
showModal("details", {
69
69
+
id: Number(media.id),
70
70
+
type: media.type === "movie" ? "movie" : "show",
71
71
+
});
72
72
+
}
73
73
+
};
74
74
+
75
75
+
const items = useMemo(() => {
76
76
+
// Group items by show/movie
77
77
+
const groupedItems: Record<string, WatchHistoryItem[]> = {};
78
78
+
79
79
+
Object.entries(watchHistory).forEach(([key, historyItem]) => {
80
80
+
// For shows, group by the base show ID (remove episode/season suffix)
81
81
+
// For movies, use the full key
82
82
+
const groupKey =
83
83
+
historyItem.type === "show"
84
84
+
? key.split("-")[0] // Remove episode ID suffix for shows
85
85
+
: key;
86
86
+
87
87
+
if (!groupedItems[groupKey]) {
88
88
+
groupedItems[groupKey] = [];
89
89
+
}
90
90
+
groupedItems[groupKey].push(historyItem);
91
91
+
});
92
92
+
93
93
+
// For each group, get the most recent item
94
94
+
const output: Array<{ media: MediaItem; historyItem: WatchHistoryItem }> =
95
95
+
[];
96
96
+
Object.entries(groupedItems).forEach(([groupKey, groupItems]) => {
97
97
+
// Sort group by most recent watchedAt
98
98
+
const sortedGroup = groupItems.sort((a, b) => b.watchedAt - a.watchedAt);
99
99
+
const mostRecentItem = sortedGroup[0];
100
100
+
101
101
+
output.push({
102
102
+
media: {
103
103
+
id: groupKey,
104
104
+
title: mostRecentItem.title,
105
105
+
year: mostRecentItem.year,
106
106
+
poster: mostRecentItem.poster,
107
107
+
type: mostRecentItem.type,
108
108
+
},
109
109
+
historyItem: mostRecentItem,
110
110
+
});
111
111
+
});
112
112
+
113
113
+
// Sort by most recently watched
114
114
+
output.sort((a, b) => b.historyItem.watchedAt - a.historyItem.watchedAt);
115
115
+
116
116
+
return output;
117
117
+
}, [watchHistory]);
118
118
+
119
119
+
if (items.length === 0) {
120
120
+
return (
121
121
+
<SubPageLayout>
122
122
+
<WideContainer>
123
123
+
<div className="flex flex-col items-center justify-center translate-y-1/2">
124
124
+
<p className="text-[18.5px] pb-3">{emptyText}</p>
125
125
+
<Button
126
126
+
theme="purple"
127
127
+
onClick={() => navigate("/")}
128
128
+
className="mt-4"
129
129
+
>
130
130
+
{t("notFound.goHome")}
131
131
+
</Button>
132
132
+
</div>
133
133
+
</WideContainer>
134
134
+
</SubPageLayout>
135
135
+
);
136
136
+
}
137
137
+
138
138
+
return (
139
139
+
<SubPageLayout>
140
140
+
<WideContainer>
141
141
+
<div className="flex items-center justify-between gap-8">
142
142
+
<Heading1 className="text-2xl font-bold text-white">
143
143
+
{t("home.watchHistory.sectionTitle")}
144
144
+
</Heading1>
145
145
+
</div>
146
146
+
147
147
+
<div className="flex items-center gap-4 pb-8">
148
148
+
<button
149
149
+
type="button"
150
150
+
onClick={() => navigate("/")}
151
151
+
className="flex items-center text-white hover:text-gray-300 transition-colors"
152
152
+
>
153
153
+
<Icon icon={Icons.ARROW_LEFT} className="text-xl" />
154
154
+
<span className="ml-2">{t("discover.page.back")}</span>
155
155
+
</button>
156
156
+
</div>
157
157
+
158
158
+
<SectionHeading
159
159
+
title={t("home.watchHistory.recentlyWatched")}
160
160
+
icon={Icons.CLOCK}
161
161
+
>
162
162
+
<div className="flex items-center gap-2">
163
163
+
<EditButton
164
164
+
editing={editing}
165
165
+
onEdit={setEditing}
166
166
+
id="edit-button-watch-history"
167
167
+
/>
168
168
+
</div>
169
169
+
</SectionHeading>
170
170
+
171
171
+
<MediaGrid ref={gridRef}>
172
172
+
{items.map(({ media, historyItem }) => (
173
173
+
<div
174
174
+
key={media.id}
175
175
+
style={{ userSelect: "none" }}
176
176
+
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
177
177
+
e.preventDefault()
178
178
+
}
179
179
+
>
180
180
+
<MediaCard
181
181
+
media={media}
182
182
+
series={formatWatchHistorySeries(historyItem)}
183
183
+
linkable
184
184
+
percentage={getWatchHistoryPercentage(historyItem)}
185
185
+
onClose={
186
186
+
editing
187
187
+
? () => {
188
188
+
// Remove all watch history items for this show/movie
189
189
+
Object.keys(watchHistory).forEach((key) => {
190
190
+
const item = watchHistory[key];
191
191
+
const groupKey =
192
192
+
item.type === "show" ? key.split("-")[0] : key;
193
193
+
if (groupKey === media.id) {
194
194
+
removeItem(key);
195
195
+
}
196
196
+
});
197
197
+
}
198
198
+
: undefined
199
199
+
}
200
200
+
closable={editing}
201
201
+
onShowDetails={handleShowDetails}
202
202
+
/>
203
203
+
</div>
204
204
+
))}
205
205
+
</MediaGrid>
206
206
+
</WideContainer>
207
207
+
</SubPageLayout>
208
208
+
);
209
209
+
}
+3
src/setup/App.tsx
···
40
40
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
41
41
import { RegisterPage } from "@/pages/Register";
42
42
import { SupportPage } from "@/pages/Support";
43
43
+
import { WatchHistory } from "@/pages/watchHistory/WatchHistory";
43
44
import { Layout } from "@/setup/Layout";
44
45
import { useHistoryListener } from "@/stores/history";
45
46
import { useClearModalsOnNavigation } from "@/stores/interface/overlayStack";
···
201
202
<Route path="/discover/all" element={<DiscoverMore />} />
202
203
{/* Bookmarks page */}
203
204
<Route path="/bookmarks" element={<AllBookmarks />} />
205
205
+
{/* Watch History page */}
206
206
+
<Route path="/watch-history" element={<WatchHistory />} />
204
207
{/* Settings page */}
205
208
<Route
206
209
path="/settings"
+12
src/stores/progress/index.ts
···
3
3
import { immer } from "zustand/middleware/immer";
4
4
5
5
import { PlayerMeta } from "@/stores/player/slices/source";
6
6
+
import { useWatchHistoryStore } from "@/stores/watchHistory";
6
7
import {
7
8
ProgressModificationOptions,
8
9
ProgressModificationResult,
···
141
142
watched: 0,
142
143
};
143
144
item.progress = { ...progress };
145
145
+
146
146
+
// Update watch history
147
147
+
const completed =
148
148
+
progress.duration > 0 &&
149
149
+
progress.watched / progress.duration > 0.9;
150
150
+
useWatchHistoryStore.getState().addItem(meta, progress, completed);
144
151
return;
145
152
}
146
153
···
167
174
};
168
175
169
176
item.episodes[meta.episode.tmdbId].progress = { ...progress };
177
177
+
178
178
+
// Update watch history
179
179
+
const completed =
180
180
+
progress.duration > 0 && progress.watched / progress.duration > 0.9;
181
181
+
useWatchHistoryStore.getState().addItem(meta, progress, completed);
170
182
});
171
183
},
172
184
clear() {
+134
src/stores/watchHistory/WatchHistorySyncer.tsx
···
1
1
+
import { useEffect } from "react";
2
2
+
3
3
+
import {
4
4
+
removeWatchHistory,
5
5
+
setWatchHistory,
6
6
+
watchHistoryUpdateItemToInput,
7
7
+
} from "@/backend/accounts/watchHistory";
8
8
+
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
9
9
+
import { AccountWithToken, useAuthStore } from "@/stores/auth";
10
10
+
import {
11
11
+
WatchHistoryUpdateItem,
12
12
+
useWatchHistoryStore,
13
13
+
} from "@/stores/watchHistory";
14
14
+
15
15
+
const syncIntervalMs = 1 * 60 * 1000; // 1 minute intervals
16
16
+
17
17
+
async function syncWatchHistory(
18
18
+
items: WatchHistoryUpdateItem[],
19
19
+
finish: (id: string) => void,
20
20
+
url: string,
21
21
+
account: AccountWithToken | null,
22
22
+
) {
23
23
+
for (const item of items) {
24
24
+
// complete it beforehand so it doesn't get handled while in progress
25
25
+
finish(item.id);
26
26
+
27
27
+
if (!account) continue; // not logged in, dont sync to server
28
28
+
29
29
+
try {
30
30
+
if (item.action === "delete") {
31
31
+
await removeWatchHistory(
32
32
+
url,
33
33
+
account,
34
34
+
item.tmdbId,
35
35
+
item.episodeId,
36
36
+
item.seasonId,
37
37
+
);
38
38
+
continue;
39
39
+
}
40
40
+
41
41
+
if (item.action === "add" || item.action === "update") {
42
42
+
await setWatchHistory(
43
43
+
url,
44
44
+
account,
45
45
+
watchHistoryUpdateItemToInput(item),
46
46
+
);
47
47
+
continue;
48
48
+
}
49
49
+
} catch (err) {
50
50
+
console.error(
51
51
+
`Failed to sync watch history: ${item.tmdbId} - ${item.action}`,
52
52
+
err,
53
53
+
);
54
54
+
}
55
55
+
}
56
56
+
}
57
57
+
58
58
+
export function WatchHistorySyncer() {
59
59
+
const clearUpdateQueue = useWatchHistoryStore((s) => s.clearUpdateQueue);
60
60
+
const removeUpdateItem = useWatchHistoryStore((s) => s.removeUpdateItem);
61
61
+
const url = useBackendUrl();
62
62
+
63
63
+
// when booting for the first time, clear update queue.
64
64
+
// we dont want to process persisted update items
65
65
+
useEffect(() => {
66
66
+
clearUpdateQueue();
67
67
+
}, [clearUpdateQueue]);
68
68
+
69
69
+
// Immediate sync when items are added to queue
70
70
+
useEffect(() => {
71
71
+
let lastQueueLength = 0;
72
72
+
73
73
+
const checkAndSync = async () => {
74
74
+
const currentQueueLength =
75
75
+
useWatchHistoryStore.getState().updateQueue.length;
76
76
+
// Only sync immediately if queue grew (items were added)
77
77
+
if (currentQueueLength > lastQueueLength && currentQueueLength > 0) {
78
78
+
if (!url) return;
79
79
+
const state = useWatchHistoryStore.getState();
80
80
+
const user = useAuthStore.getState();
81
81
+
await syncWatchHistory(
82
82
+
state.updateQueue,
83
83
+
removeUpdateItem,
84
84
+
url,
85
85
+
user.account,
86
86
+
);
87
87
+
}
88
88
+
lastQueueLength = currentQueueLength;
89
89
+
};
90
90
+
91
91
+
// Override the addItem function to trigger immediate sync
92
92
+
const originalAddItem = useWatchHistoryStore.getState().addItem;
93
93
+
useWatchHistoryStore.setState({
94
94
+
addItem: (...args) => {
95
95
+
originalAddItem(...args);
96
96
+
// Trigger sync after adding item
97
97
+
setTimeout(checkAndSync, 100);
98
98
+
},
99
99
+
});
100
100
+
101
101
+
// Also override removeItem
102
102
+
const originalRemoveItem = useWatchHistoryStore.getState().removeItem;
103
103
+
useWatchHistoryStore.setState({
104
104
+
removeItem: (...args) => {
105
105
+
originalRemoveItem(...args);
106
106
+
// Trigger sync after removing item
107
107
+
setTimeout(checkAndSync, 100);
108
108
+
},
109
109
+
});
110
110
+
}, [removeUpdateItem, url]);
111
111
+
112
112
+
// Regular interval sync
113
113
+
useEffect(() => {
114
114
+
const interval = setInterval(() => {
115
115
+
(async () => {
116
116
+
if (!url) return;
117
117
+
const state = useWatchHistoryStore.getState();
118
118
+
const user = useAuthStore.getState();
119
119
+
await syncWatchHistory(
120
120
+
state.updateQueue,
121
121
+
removeUpdateItem,
122
122
+
url,
123
123
+
user.account,
124
124
+
);
125
125
+
})();
126
126
+
}, syncIntervalMs);
127
127
+
128
128
+
return () => {
129
129
+
clearInterval(interval);
130
130
+
};
131
131
+
}, [removeUpdateItem, url]);
132
132
+
133
133
+
return null;
134
134
+
}
+189
src/stores/watchHistory/index.ts
···
1
1
+
import { create } from "zustand";
2
2
+
import { persist } from "zustand/middleware";
3
3
+
import { immer } from "zustand/middleware/immer";
4
4
+
5
5
+
import { PlayerMeta } from "@/stores/player/slices/source";
6
6
+
7
7
+
export interface WatchHistoryItem {
8
8
+
title: string;
9
9
+
year?: number;
10
10
+
poster?: string;
11
11
+
type: "show" | "movie";
12
12
+
progress: {
13
13
+
watched: number;
14
14
+
duration: number;
15
15
+
};
16
16
+
watchedAt: number; // timestamp when last watched
17
17
+
completed: boolean; // whether the item was completed
18
18
+
episodeId?: string;
19
19
+
seasonId?: string;
20
20
+
seasonNumber?: number;
21
21
+
episodeNumber?: number;
22
22
+
}
23
23
+
24
24
+
export interface WatchHistoryUpdateItem {
25
25
+
title?: string;
26
26
+
year?: number;
27
27
+
poster?: string;
28
28
+
type?: "show" | "movie";
29
29
+
progress?: {
30
30
+
watched: number;
31
31
+
duration: number;
32
32
+
};
33
33
+
watchedAt?: number;
34
34
+
completed?: boolean;
35
35
+
tmdbId: string;
36
36
+
id: string;
37
37
+
episodeId?: string;
38
38
+
seasonId?: string;
39
39
+
seasonNumber?: number;
40
40
+
episodeNumber?: number;
41
41
+
action: "add" | "update" | "delete";
42
42
+
}
43
43
+
44
44
+
export interface WatchHistoryStore {
45
45
+
items: Record<string, WatchHistoryItem>;
46
46
+
updateQueue: WatchHistoryUpdateItem[];
47
47
+
addItem(
48
48
+
meta: PlayerMeta,
49
49
+
progress: { watched: number; duration: number },
50
50
+
completed: boolean,
51
51
+
): void;
52
52
+
updateItem(
53
53
+
id: string,
54
54
+
progress: { watched: number; duration: number },
55
55
+
completed: boolean,
56
56
+
): void;
57
57
+
removeItem(id: string): void;
58
58
+
replaceItems(items: Record<string, WatchHistoryItem>): void;
59
59
+
clear(): void;
60
60
+
clearUpdateQueue(): void;
61
61
+
removeUpdateItem(id: string): void;
62
62
+
}
63
63
+
64
64
+
let updateId = 0;
65
65
+
66
66
+
export const useWatchHistoryStore = create(
67
67
+
persist(
68
68
+
immer<WatchHistoryStore>((set) => ({
69
69
+
items: {},
70
70
+
updateQueue: [],
71
71
+
addItem(meta, progress, completed) {
72
72
+
set((s) => {
73
73
+
// add to updateQueue
74
74
+
updateId += 1;
75
75
+
s.updateQueue.push({
76
76
+
tmdbId: meta.tmdbId,
77
77
+
title: meta.title,
78
78
+
year: meta.releaseYear,
79
79
+
poster: meta.poster,
80
80
+
type: meta.type,
81
81
+
progress: { ...progress },
82
82
+
watchedAt: Date.now(),
83
83
+
completed,
84
84
+
id: updateId.toString(),
85
85
+
episodeId: meta.episode?.tmdbId,
86
86
+
seasonId: meta.season?.tmdbId,
87
87
+
seasonNumber: meta.season?.number,
88
88
+
episodeNumber: meta.episode?.number,
89
89
+
action: "add",
90
90
+
});
91
91
+
92
92
+
// add to watch history store
93
93
+
const key = meta.episode
94
94
+
? `${meta.tmdbId}-${meta.episode.tmdbId}`
95
95
+
: meta.tmdbId;
96
96
+
s.items[key] = {
97
97
+
type: meta.type,
98
98
+
title: meta.title,
99
99
+
year: meta.releaseYear,
100
100
+
poster: meta.poster,
101
101
+
progress: { ...progress },
102
102
+
watchedAt: Date.now(),
103
103
+
completed,
104
104
+
episodeId: meta.episode?.tmdbId,
105
105
+
seasonId: meta.season?.tmdbId,
106
106
+
seasonNumber: meta.season?.number,
107
107
+
episodeNumber: meta.episode?.number,
108
108
+
};
109
109
+
});
110
110
+
},
111
111
+
updateItem(id, progress, completed) {
112
112
+
set((s) => {
113
113
+
if (!s.items[id]) return;
114
114
+
115
115
+
// add to updateQueue
116
116
+
updateId += 1;
117
117
+
const item = s.items[id];
118
118
+
s.updateQueue.push({
119
119
+
tmdbId: item.episodeId ? item.seasonId || id.split("-")[0] : id,
120
120
+
title: item.title,
121
121
+
year: item.year,
122
122
+
poster: item.poster,
123
123
+
type: item.type,
124
124
+
progress: { ...progress },
125
125
+
watchedAt: Date.now(),
126
126
+
completed,
127
127
+
id: updateId.toString(),
128
128
+
episodeId: item.episodeId,
129
129
+
seasonId: item.seasonId,
130
130
+
seasonNumber: item.seasonNumber,
131
131
+
episodeNumber: item.episodeNumber,
132
132
+
action: "update",
133
133
+
});
134
134
+
135
135
+
// update item
136
136
+
item.progress = { ...progress };
137
137
+
item.watchedAt = Date.now();
138
138
+
item.completed = completed;
139
139
+
});
140
140
+
},
141
141
+
removeItem(id) {
142
142
+
set((s) => {
143
143
+
updateId += 1;
144
144
+
145
145
+
// Parse the key to extract TMDB ID and episode ID for episodes
146
146
+
const isEpisode = id.includes("-");
147
147
+
const tmdbId = isEpisode ? id.split("-")[0] : id;
148
148
+
const episodeId = isEpisode ? id.split("-")[1] : undefined;
149
149
+
150
150
+
s.updateQueue.push({
151
151
+
id: updateId.toString(),
152
152
+
action: "delete",
153
153
+
tmdbId,
154
154
+
episodeId,
155
155
+
// For movies, seasonId will be undefined, for episodes it might need to be derived from the item
156
156
+
seasonId: s.items[id]?.seasonId,
157
157
+
seasonNumber: s.items[id]?.seasonNumber,
158
158
+
episodeNumber: s.items[id]?.episodeNumber,
159
159
+
});
160
160
+
161
161
+
delete s.items[id];
162
162
+
});
163
163
+
},
164
164
+
replaceItems(items: Record<string, WatchHistoryItem>) {
165
165
+
set((s) => {
166
166
+
s.items = items;
167
167
+
});
168
168
+
},
169
169
+
clear() {
170
170
+
set((s) => {
171
171
+
s.items = {};
172
172
+
});
173
173
+
},
174
174
+
clearUpdateQueue() {
175
175
+
set((s) => {
176
176
+
s.updateQueue = [];
177
177
+
});
178
178
+
},
179
179
+
removeUpdateItem(id: string) {
180
180
+
set((s) => {
181
181
+
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
182
182
+
});
183
183
+
},
184
184
+
})),
185
185
+
{
186
186
+
name: "__MW::watchHistory",
187
187
+
},
188
188
+
),
189
189
+
);