mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-03 04:28:31 +01:00
fix: Improve perf download database and correct crash when changing episode (#1547)
Co-authored-by: BuildTools <unconfigured@null.spigotmc.org> Co-authored-by: Gauvain <contact@uruk.dev>
This commit is contained in:
committed by
GitHub
parent
ceb9b5a1ae
commit
7d16e7d5c7
@@ -274,6 +274,11 @@ export default function DirectPlayerPage() {
|
||||
};
|
||||
|
||||
if (itemId) {
|
||||
setItem(null);
|
||||
setDownloadedItem(null);
|
||||
// Clear the previous episode's stream so the loader gate stays closed
|
||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||
setStream(null);
|
||||
fetchItemData();
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
@@ -316,6 +321,12 @@ export default function DirectPlayerPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure item matches the current itemId to avoid race conditions
|
||||
if (item.Id !== itemId) {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
@@ -388,6 +399,7 @@ export default function DirectPlayerPage() {
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
offline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot, so refetches after
|
||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -365,7 +365,9 @@ export const Controls: FC<Props> = ({
|
||||
{ applyLanguagePreferences: true },
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||
router.setParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
@@ -374,11 +376,17 @@ export const Controls: FC<Props> = ({
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
});
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||
[
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
offline,
|
||||
],
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
|
||||
@@ -57,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
||||
|
||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||
|
||||
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
||||
let cachedDb: DownloadsDatabase | null = null;
|
||||
let cacheVersion = 0;
|
||||
|
||||
// Performance optimization: Cache the flattened items array
|
||||
let cachedItems: DownloadedItem[] | null = null;
|
||||
let itemsCacheVersion = -1;
|
||||
|
||||
// Performance optimization: Index for O(1) item lookups by ID
|
||||
let itemIndex: Map<string, DownloadedItem> | null = null;
|
||||
let indexCacheVersion = -1;
|
||||
|
||||
/**
|
||||
* Get the downloads database from storage
|
||||
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
||||
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
||||
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
||||
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
||||
*/
|
||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||
// Return cached database if available
|
||||
if (cachedDb !== null) {
|
||||
return cachedDb;
|
||||
}
|
||||
|
||||
// Parse from storage and cache the result
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
||||
return cachedDb;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} };
|
||||
|
||||
const emptyDb = { movies: {}, series: {}, other: {} };
|
||||
cachedDb = emptyDb;
|
||||
return emptyDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the downloads database to storage
|
||||
* PERFORMANCE: Updates cache and invalidates derived caches
|
||||
*/
|
||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
// Update the cache with the new database
|
||||
cachedDb = db;
|
||||
// Invalidate derived caches (items array and index)
|
||||
cachedItems = null;
|
||||
itemIndex = null;
|
||||
cacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded items as a flat array
|
||||
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
||||
*/
|
||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
// Return cached items if available and up-to-date
|
||||
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
||||
return cachedItems;
|
||||
}
|
||||
|
||||
// Build the items array from the database
|
||||
const db = getDownloadsDatabase();
|
||||
const items: DownloadedItem[] = [];
|
||||
|
||||
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cachedItems = items;
|
||||
itemsCacheVersion = cacheVersion;
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* Build or refresh the item index for O(1) lookups
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
if (db.movies[id]) {
|
||||
return db.movies[id];
|
||||
function ensureItemIndex(): void {
|
||||
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
||||
return; // Index is up-to-date
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
// Build new index from all items
|
||||
itemIndex = new Map<string, DownloadedItem>();
|
||||
const items = getAllDownloadedItems();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.item.Id) {
|
||||
itemIndex.set(item.item.Id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other?.[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
indexCacheVersion = cacheVersion;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
ensureItemIndex();
|
||||
return itemIndex!.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
|
||||
*/
|
||||
export function clearAllDownloadedItems(): void {
|
||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||
// saveDownloadsDatabase already invalidates caches
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user