Skip to content

Commit

Permalink
Add thumbnail scraping as Lemmy fallback (aeharding#1531)
Browse files Browse the repository at this point in the history
Note: Currently only supports youtube.com. Enabled by default.
  • Loading branch information
aeharding authored Jul 29, 2024
1 parent 9a6e933 commit 876f80a
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 5 deletions.
52 changes: 47 additions & 5 deletions src/features/post/link/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { LinkData } from "../../comment/CommentLinks";
import useLemmyUrlHandler from "../../shared/useLemmyUrlHandler";
import { getImageSrc } from "../../../services/lemmy";
import { determineTypeFromUrl, isUrlImage } from "../../../helpers/url";
import { useAppDispatch, useAppSelector } from "../../../store";
import { fetchThumbnail } from "./thumbnail/thumbnailSlice";

const TRANSPARENT_PIXEL =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>';

const Container = styled(LinkInterceptor)`
display: flex;
Expand Down Expand Up @@ -129,15 +134,22 @@ interface EmbedProps {
export default function Link({
url,
text,
thumbnail,
thumbnail: lemmyThubmnail,
compact = true,
blur,
className,
onClick,
small,
commentType,
}: EmbedProps) {
const dispatch = useAppDispatch();
const { determineObjectTypeFromUrl } = useLemmyUrlHandler();
const thumbnailinatorEnabled = useAppSelector(
(state) => state.settings.general.thumbnailinatorEnabled,
);
const thumbnailinatorResult = useAppSelector(
(state) => state.thumbnail.thumbnailSrcByUrl[url],
);

const [error, setError] = useState(false);

Expand All @@ -155,15 +167,45 @@ export default function Link({
onClick?.(e);
};

const compactIcon = (() => {
const thumbnail = useMemo(() => {
if (lemmyThubmnail) return lemmyThubmnail;

if (!thumbnailinatorEnabled) return;

if (
thumbnailinatorResult === "none" ||
thumbnailinatorResult === "failed"
) {
return;
}

if (!thumbnailinatorResult || thumbnailinatorResult === "pending") {
if (!thumbnailinatorResult) dispatch(fetchThumbnail(url));
return TRANSPARENT_PIXEL;
}

return thumbnailinatorResult;
}, [
lemmyThubmnail,
dispatch,
url,
thumbnailinatorResult,
thumbnailinatorEnabled,
]);

const compactIcon = useMemo(() => {
if (commentType === "image" || isImage)
return <ThumbnailImg src={getImageSrc(url, { size: 50 })} />;

if (linkType || !compact || !thumbnail)
return <LinkPreview type={linkType} />;

return <ThumbnailImg src={thumbnail} />;
})();
return (
<ThumbnailImg
src={typeof thumbnail === "string" ? thumbnail : thumbnail.sm}
/>
);
}, [commentType, compact, isImage, linkType, thumbnail, url]);

return (
<Container
Expand All @@ -174,7 +216,7 @@ export default function Link({
>
{!compact && thumbnail && !error && (
<Img
src={thumbnail}
src={typeof thumbnail === "string" ? thumbnail : thumbnail.lg}
draggable="false"
className={blur ? blurImgCss : undefined}
onError={() => setError(true)}
Expand Down
27 changes: 27 additions & 0 deletions src/features/post/link/thumbnail/sites/youtube.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Thumbnail } from "../thumbnailinator";

export default function determineThumbnailForYoutube(
url: string,
): Thumbnail | undefined {
const videoId = getYoutubeVideoId(url);
if (videoId) return getYoutubeThumbnailSrc(url);
}

// https://stackoverflow.com/a/61033353/1319878
const YOUTUBE_LINK =
/(?:https?:\/\/)?(?:www\.)?youtu(?:\.be\/|be.com\/\S*(?:watch|embed)(?:(?:(?=\/[-a-zA-Z0-9_]{11,}(?!\S))\/)|(?:\S*v=|v\/)))([-a-zA-Z0-9_]{11,})/;

export function getYoutubeVideoId(url: string): string | undefined {
return url.match(YOUTUBE_LINK)?.[1];
}

export function getYoutubeThumbnailSrc(url: string): Thumbnail | undefined {
const videoId = getYoutubeVideoId(url);

if (!videoId) return;

return {
sm: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`,
lg: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
};
}
45 changes: 45 additions & 0 deletions src/features/post/link/thumbnail/thumbnailSlice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { determineThumbnail, Thumbnail } from "./thumbnailinator";
import { RootState } from "../../../../store";

interface ThumbnailState {
thumbnailSrcByUrl: Record<string, "pending" | "failed" | "none" | Thumbnail>;
}

const initialState: ThumbnailState = {
thumbnailSrcByUrl: {},
};

export const thumbnailSlice = createSlice({
name: "thumbnail",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchThumbnail.pending, (state, action) => {
if (state.thumbnailSrcByUrl[action.meta.arg]) return;
state.thumbnailSrcByUrl[action.meta.arg] = "pending";
})
.addCase(fetchThumbnail.rejected, (state, action) => {
if (state.thumbnailSrcByUrl[action.meta.arg] !== "pending") return;
state.thumbnailSrcByUrl[action.meta.arg] = "failed";
})
.addCase(fetchThumbnail.fulfilled, (state, action) => {
if (state.thumbnailSrcByUrl[action.meta.arg] !== "pending") return;
state.thumbnailSrcByUrl[action.meta.arg] = action.payload ?? "none";
});
},
});

export default thumbnailSlice.reducer;

export const fetchThumbnail = createAsyncThunk(
"thumbnail/fetch",
async (url: string) => await determineThumbnail(url),
{
condition: (url, { getState }) => {
// Only allow fetch once
return !(getState() as RootState).thumbnail.thumbnailSrcByUrl[url];
},
},
);
18 changes: 18 additions & 0 deletions src/features/post/link/thumbnail/thumbnailinator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import determineThumbnailForYoutube from "./sites/youtube";

export type Thumbnail =
| string
| {
sm: string;
lg: string;
};

export async function determineThumbnail(
url: string,
): Promise<Thumbnail | undefined> {
const potentialYoutube = determineThumbnailForYoutube(url);

if (potentialYoutube) return potentialYoutube;

// Add more services here
}
2 changes: 2 additions & 0 deletions src/features/settings/general/other/Other.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import NoSubscribedInFeed from "./NoSubscribedInFeed";
import OpenNativeApps from "./OpenNativeApps";
import ClearCache from "./ClearCache";
import BackupSettings from "./backup/BackupSettings";
import Thumbnailinator from "./Thumbnailinator";

export default function Other() {
return (
Expand All @@ -22,6 +23,7 @@ export default function Other() {
<ProfileTabLabel />
<Haptics />
<NoSubscribedInFeed />
<Thumbnailinator />
<ClearCache />
<BackupSettings />
</IonList>
Expand Down
24 changes: 24 additions & 0 deletions src/features/settings/general/other/Thumbnailinator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { IonItem, IonToggle } from "@ionic/react";

import { useAppDispatch, useAppSelector } from "../../../../store";
import { setThumbnailinatorEnabled } from "../../settingsSlice";

export default function Thumbnailinator() {
const dispatch = useAppDispatch();
const thumbnailinatorEnabled = useAppSelector(
(state) => state.settings.general.thumbnailinatorEnabled,
);

return (
<IonItem>
<IonToggle
checked={thumbnailinatorEnabled}
onIonChange={(e) =>
dispatch(setThumbnailinatorEnabled(e.detail.checked))
}
>
Find Thumbnails When Missing
</IonToggle>
</IonItem>
);
}
13 changes: 13 additions & 0 deletions src/features/settings/settingsSlice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ interface SettingsState {
preferNativeApps: boolean;
defaultFeed: DefaultFeedType | undefined;
noSubscribedInFeed: boolean;
thumbnailinatorEnabled: boolean;
};
blocks: {
keywords: string[];
Expand Down Expand Up @@ -220,6 +221,7 @@ export const initialState: SettingsState = {
preferNativeApps: true,
defaultFeed: undefined,
noSubscribedInFeed: false,
thumbnailinatorEnabled: true,
},
blocks: {
keywords: [],
Expand Down Expand Up @@ -374,6 +376,10 @@ export const appearanceSlice = createSlice({
state.general.noSubscribedInFeed = action.payload;
db.setSetting("no_subscribed_in_feed", action.payload);
},
setThumbnailinatorEnabled(state, action: PayloadAction<boolean>) {
state.general.thumbnailinatorEnabled = action.payload;
db.setSetting("thumbnailinator_enabled", action.payload);
},
setEmbedExternalMedia(state, action: PayloadAction<boolean>) {
state.appearance.posts.embedExternalMedia = action.payload;
db.setSetting("embed_external_media", action.payload);
Expand Down Expand Up @@ -704,6 +710,9 @@ export const fetchSettingsFromDatabase = createAsyncThunk<SettingsState>(
const no_subscribed_in_feed = await db.getSetting(
"no_subscribed_in_feed",
);
const thumbnailinator_enabled = await db.getSetting(
"thumbnailinator_enabled",
);
const embed_external_media = await db.getSetting("embed_external_media");
const always_show_author = await db.getSetting("always_show_author");
const always_use_reader_mode = await db.getSetting(
Expand Down Expand Up @@ -852,6 +861,9 @@ export const fetchSettingsFromDatabase = createAsyncThunk<SettingsState>(
defaultFeed: initialState.general.defaultFeed,
noSubscribedInFeed:
no_subscribed_in_feed ?? initialState.general.noSubscribedInFeed,
thumbnailinatorEnabled:
thumbnailinator_enabled ??
initialState.general.thumbnailinatorEnabled,
},
blocks: {
keywords: filtered_keywords ?? initialState.blocks.keywords,
Expand Down Expand Up @@ -926,6 +938,7 @@ export const {
setPureBlack,
setDefaultFeed,
setNoSubscribedInFeed,
setThumbnailinatorEnabled,
setEmbedExternalMedia,
setAlwaysShowAuthor,
setAlwaysUseReaderMode,
Expand Down
1 change: 1 addition & 0 deletions src/services/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export type SettingValueTypes = {
long_swipe_trigger_point: LongSwipeTriggerPointType;
has_presented_block_nsfw_tip: boolean;
no_subscribed_in_feed: boolean;
thumbnailinator_enabled: boolean;
embed_external_media: boolean;
always_show_author: boolean;
always_use_reader_mode: boolean;
Expand Down
2 changes: 2 additions & 0 deletions src/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import deepLinkReadySlice from "./features/community/list/deepLinkReadySlice";
import redgifsSlice from "./features/media/external/redgifs/redgifsSlice";
import uploadImageSlice from "./features/shared/markdown/editing/uploadImageSlice";
import postAppearanceSlice from "./features/post/appearance/appearanceSlice";
import thumbnailSlice from "./features/post/link/thumbnail/thumbnailSlice";

const store = configureStore({
reducer: {
Expand Down Expand Up @@ -75,6 +76,7 @@ const store = configureStore({
redgifs: redgifsSlice,
uploadImage: uploadImageSlice,
postAppearance: postAppearanceSlice,
thumbnail: thumbnailSlice,
},
});
export type RootState = ReturnType<typeof store.getState>;
Expand Down

0 comments on commit 876f80a

Please sign in to comment.