mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 01:13:27 +01:00
Compare commits
3 Commits
jellyfin-1
...
fix/episod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e3190db86 | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 |
@@ -85,7 +85,8 @@ configureReanimatedLogger({
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -350,9 +351,12 @@ function Layout() {
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(notification: Notification) => {
|
||||
// Log only the title — serializing the whole notification touches
|
||||
// the deprecated dataString getter (deprecation warning) and dumps
|
||||
// noisy payloads into the console.
|
||||
console.log(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
"Notification received while app running:",
|
||||
notification.request.content.title,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
|
||||
}, [item, useEpisodePoster]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -221,33 +221,22 @@ const HomeMobile = () => {
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
// Use getItems (not getLatestMedia) so we get item-level results
|
||||
// filtered by type from a specific library. getLatestMedia is
|
||||
// episode-oriented and groups results, which drops Series when
|
||||
// combined with a parentId + includeItemTypes filter.
|
||||
//
|
||||
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
|
||||
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
|
||||
//
|
||||
// For Series we sort by DateLastContentAdded so shows bubble up when
|
||||
// a new episode is added (series cards for new episodes, matching how
|
||||
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId,
|
||||
includeItemTypes,
|
||||
recursive: true,
|
||||
sortBy: includeItemTypes.includes("Series")
|
||||
? ["DateLastContentAdded"]
|
||||
: ["DateCreated"],
|
||||
sortOrder: ["Descending"],
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
return response.data.Items || [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
@@ -261,7 +250,9 @@ const HomeMobile = () => {
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
|
||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
// For episodes, always use series thumb
|
||||
// For episodes, always use series thumb.
|
||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
if (item.SeriesId) {
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
} finally {
|
||||
// Reflect the freed space immediately instead of waiting for
|
||||
// the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
|
||||
@@ -139,9 +139,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
if (orientation === "horizontal") {
|
||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||
if (item.Type === "Episode") {
|
||||
// First try parent/series thumb (horizontal series artwork)
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// First try parent/series thumb (horizontal series artwork).
|
||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
// Fall back to episode's own primary image
|
||||
if (item.ImageTags?.Primary) {
|
||||
|
||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
return items.reduce((sum, item) => {
|
||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||
// as video + trickplay, the total must match.
|
||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||
// Read the live file size on disk so the total reflects actual usage and
|
||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||
// value if the file can't be stat'd.
|
||||
if (item.videoFilePath) {
|
||||
try {
|
||||
const file = new File(filePathToUri(item.videoFilePath));
|
||||
if (file.exists) {
|
||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to stat downloaded file for size:", error);
|
||||
}
|
||||
}
|
||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -289,7 +289,24 @@ export function useDownloadOperations({
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
let totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
// Also count in-progress downloads (they write straight to their final
|
||||
// path) so the growing file shows up as app usage instead of drifting
|
||||
// into the generic device share until completion.
|
||||
for (const process of processes) {
|
||||
try {
|
||||
const file = new File(
|
||||
Paths.document,
|
||||
`${generateFilename(process.item)}.mp4`,
|
||||
);
|
||||
if (file.exists) {
|
||||
totalSize += file.size ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// File not created yet — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
||||
appSize: totalSize,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}, [processes]);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
|
||||
@@ -173,7 +173,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const protocol = api.basePath.includes("https") ? "wss" : "ws";
|
||||
const url = `${protocol}://${api.basePath
|
||||
.replace("https://", "")
|
||||
.replace("http://", "")}/socket?ApiKey=${
|
||||
.replace("http://", "")}/socket?api_key=${
|
||||
api.accessToken
|
||||
}&deviceId=${deviceId}`;
|
||||
|
||||
|
||||
@@ -384,6 +384,8 @@
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} used",
|
||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Music cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear music cache",
|
||||
|
||||
@@ -52,7 +52,7 @@ export const getAudioStreamUrl = async (
|
||||
container: mediaSource?.Container || "mp3",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
deviceId: api.deviceInfo.id,
|
||||
ApiKey: api.accessToken,
|
||||
api_key: api.accessToken,
|
||||
userId,
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
|
||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||
console.log("Downloading item directly");
|
||||
return {
|
||||
url: `${api.basePath}/Items/${mediaSource.Id}/Download?ApiKey=${api.accessToken}`,
|
||||
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
|
||||
mediaSource: streamDetails?.mediaSource ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const getPlaybackUrl = async (
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
deviceId: api.deviceInfo?.id || "",
|
||||
ApiKey: api.accessToken || "",
|
||||
api_key: api.accessToken || "",
|
||||
Tag: ETag || "",
|
||||
MediaSourceId: Id || "",
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ const getPlaybackUrl = (
|
||||
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: params.audioStreamIndex?.toString() || "",
|
||||
deviceId: params.deviceId || api.deviceInfo.id,
|
||||
ApiKey: api.accessToken,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: params.startTimeTicks?.toString() || "0",
|
||||
maxStreamingBitrate: params.maxStreamingBitrate?.toString() || "",
|
||||
userId: params.userId,
|
||||
|
||||
@@ -61,5 +61,5 @@ export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => {
|
||||
const api = store.get(apiAtom);
|
||||
const resolution = getTrickplayInfo(item)?.resolution;
|
||||
if (!resolution || !api) return null;
|
||||
return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?ApiKey=${api.accessToken}`;
|
||||
return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user