@dxos/plugin-youtube 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/lib/browser/ChannelArticle-CDQR4BBY.mjs +90 -0
- package/dist/lib/browser/ChannelArticle-CDQR4BBY.mjs.map +7 -0
- package/dist/lib/browser/ChannelSettings-ZYUNW3VS.mjs +28 -0
- package/dist/lib/browser/ChannelSettings-ZYUNW3VS.mjs.map +7 -0
- package/dist/lib/browser/VideoArticle-FC4A6E7B.mjs +76 -0
- package/dist/lib/browser/VideoArticle-FC4A6E7B.mjs.map +7 -0
- package/dist/lib/browser/VideoCard-CCPXDCB7.mjs +64 -0
- package/dist/lib/browser/VideoCard-CCPXDCB7.mjs.map +7 -0
- package/dist/lib/browser/app-graph-builder-MJY6A6SN.mjs +195 -0
- package/dist/lib/browser/app-graph-builder-MJY6A6SN.mjs.map +7 -0
- package/dist/lib/browser/blueprint-definition-FRYUYJ22.mjs +22 -0
- package/dist/lib/browser/blueprint-definition-FRYUYJ22.mjs.map +7 -0
- package/dist/lib/browser/blueprints/index.mjs +13 -0
- package/dist/lib/browser/blueprints/index.mjs.map +7 -0
- package/dist/lib/browser/chunk-C26XKDK2.mjs +355 -0
- package/dist/lib/browser/chunk-C26XKDK2.mjs.map +7 -0
- package/dist/lib/browser/chunk-DFRSBBSO.mjs +21 -0
- package/dist/lib/browser/chunk-DFRSBBSO.mjs.map +7 -0
- package/dist/lib/browser/chunk-GFRR4TTX.mjs +72 -0
- package/dist/lib/browser/chunk-GFRR4TTX.mjs.map +7 -0
- package/dist/lib/browser/chunk-J5LGTIGS.mjs +10 -0
- package/dist/lib/browser/chunk-J5LGTIGS.mjs.map +7 -0
- package/dist/lib/browser/chunk-MUE22YUM.mjs +57 -0
- package/dist/lib/browser/chunk-MUE22YUM.mjs.map +7 -0
- package/dist/lib/browser/chunk-P67QEKBQ.mjs +72 -0
- package/dist/lib/browser/chunk-P67QEKBQ.mjs.map +7 -0
- package/dist/lib/browser/chunk-YMDT37TA.mjs +62 -0
- package/dist/lib/browser/chunk-YMDT37TA.mjs.map +7 -0
- package/dist/lib/browser/chunk-Z3DGTMKC.mjs +8 -0
- package/dist/lib/browser/chunk-Z3DGTMKC.mjs.map +7 -0
- package/dist/lib/browser/clear-synced-videos-EVMJIZPD.mjs +66 -0
- package/dist/lib/browser/clear-synced-videos-EVMJIZPD.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +153 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/browser/react-surface-EDA5VYDC.mjs +77 -0
- package/dist/lib/browser/react-surface-EDA5VYDC.mjs.map +7 -0
- package/dist/lib/browser/sync-423Q4BDD.mjs +360 -0
- package/dist/lib/browser/sync-423Q4BDD.mjs.map +7 -0
- package/dist/lib/browser/types/index.mjs +14 -0
- package/dist/lib/browser/types/index.mjs.map +7 -0
- package/dist/lib/node-esm/ChannelArticle-GQ64BO7V.mjs +91 -0
- package/dist/lib/node-esm/ChannelArticle-GQ64BO7V.mjs.map +7 -0
- package/dist/lib/node-esm/ChannelSettings-DM2HWNKO.mjs +29 -0
- package/dist/lib/node-esm/ChannelSettings-DM2HWNKO.mjs.map +7 -0
- package/dist/lib/node-esm/VideoArticle-WLTWZO3K.mjs +77 -0
- package/dist/lib/node-esm/VideoArticle-WLTWZO3K.mjs.map +7 -0
- package/dist/lib/node-esm/VideoCard-FOWQZK75.mjs +65 -0
- package/dist/lib/node-esm/VideoCard-FOWQZK75.mjs.map +7 -0
- package/dist/lib/node-esm/app-graph-builder-IU5TBAXN.mjs +196 -0
- package/dist/lib/node-esm/app-graph-builder-IU5TBAXN.mjs.map +7 -0
- package/dist/lib/node-esm/blueprint-definition-W264MZ3D.mjs +23 -0
- package/dist/lib/node-esm/blueprint-definition-W264MZ3D.mjs.map +7 -0
- package/dist/lib/node-esm/blueprints/index.mjs +14 -0
- package/dist/lib/node-esm/blueprints/index.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-5KNC2JMP.mjs +58 -0
- package/dist/lib/node-esm/chunk-5KNC2JMP.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-6BUJ2DQX.mjs +73 -0
- package/dist/lib/node-esm/chunk-6BUJ2DQX.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-CX6MV3QM.mjs +23 -0
- package/dist/lib/node-esm/chunk-CX6MV3QM.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-CZSLL3XQ.mjs +63 -0
- package/dist/lib/node-esm/chunk-CZSLL3XQ.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +11 -0
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-JM5SBBP5.mjs +356 -0
- package/dist/lib/node-esm/chunk-JM5SBBP5.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-JSGRZMG3.mjs +73 -0
- package/dist/lib/node-esm/chunk-JSGRZMG3.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-M4S6BE47.mjs +10 -0
- package/dist/lib/node-esm/chunk-M4S6BE47.mjs.map +7 -0
- package/dist/lib/node-esm/clear-synced-videos-5UCH6XHL.mjs +67 -0
- package/dist/lib/node-esm/clear-synced-videos-5UCH6XHL.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +154 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/lib/node-esm/react-surface-5DJAQPHJ.mjs +78 -0
- package/dist/lib/node-esm/react-surface-5DJAQPHJ.mjs.map +7 -0
- package/dist/lib/node-esm/sync-CEF5DX2J.mjs +361 -0
- package/dist/lib/node-esm/sync-CEF5DX2J.mjs.map +7 -0
- package/dist/lib/node-esm/types/index.mjs +15 -0
- package/dist/lib/node-esm/types/index.mjs.map +7 -0
- package/dist/types/src/YouTubePlugin.d.ts +3 -0
- package/dist/types/src/YouTubePlugin.d.ts.map +1 -0
- package/dist/types/src/blueprints/index.d.ts +3 -0
- package/dist/types/src/blueprints/index.d.ts.map +1 -0
- package/dist/types/src/blueprints/youtube.d.ts +4 -0
- package/dist/types/src/blueprints/youtube.d.ts.map +1 -0
- package/dist/types/src/capabilities/app-graph-builder/app-graph-builder.d.ts +6 -0
- package/dist/types/src/capabilities/app-graph-builder/app-graph-builder.d.ts.map +1 -0
- package/dist/types/src/capabilities/app-graph-builder/index.d.ts +3 -0
- package/dist/types/src/capabilities/app-graph-builder/index.d.ts.map +1 -0
- package/dist/types/src/capabilities/blueprint-definition/blueprint-definition.d.ts +6 -0
- package/dist/types/src/capabilities/blueprint-definition/blueprint-definition.d.ts.map +1 -0
- package/dist/types/src/capabilities/blueprint-definition/index.d.ts +3 -0
- package/dist/types/src/capabilities/blueprint-definition/index.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +4 -0
- package/dist/types/src/capabilities/index.d.ts.map +1 -0
- package/dist/types/src/capabilities/react-surface/index.d.ts +3 -0
- package/dist/types/src/capabilities/react-surface/index.d.ts.map +1 -0
- package/dist/types/src/capabilities/react-surface/react-surface.d.ts +5 -0
- package/dist/types/src/capabilities/react-surface/react-surface.d.ts.map +1 -0
- package/dist/types/src/containers/ChannelArticle/ChannelArticle.d.ts +8 -0
- package/dist/types/src/containers/ChannelArticle/ChannelArticle.d.ts.map +1 -0
- package/dist/types/src/containers/ChannelArticle/index.d.ts +3 -0
- package/dist/types/src/containers/ChannelArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/ChannelSettings/ChannelSettings.d.ts +7 -0
- package/dist/types/src/containers/ChannelSettings/ChannelSettings.d.ts.map +1 -0
- package/dist/types/src/containers/ChannelSettings/index.d.ts +3 -0
- package/dist/types/src/containers/ChannelSettings/index.d.ts.map +1 -0
- package/dist/types/src/containers/VideoArticle/VideoArticle.d.ts +11 -0
- package/dist/types/src/containers/VideoArticle/VideoArticle.d.ts.map +1 -0
- package/dist/types/src/containers/VideoArticle/index.d.ts +3 -0
- package/dist/types/src/containers/VideoArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/VideoCard/VideoCard.d.ts +9 -0
- package/dist/types/src/containers/VideoCard/VideoCard.d.ts.map +1 -0
- package/dist/types/src/containers/VideoCard/index.d.ts +3 -0
- package/dist/types/src/containers/VideoCard/index.d.ts.map +1 -0
- package/dist/types/src/containers/index.d.ts +11 -0
- package/dist/types/src/containers/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/meta.d.ts +3 -0
- package/dist/types/src/meta.d.ts.map +1 -0
- package/dist/types/src/operations/apis/index.d.ts +2 -0
- package/dist/types/src/operations/apis/index.d.ts.map +1 -0
- package/dist/types/src/operations/apis/youtube/api.d.ts +251 -0
- package/dist/types/src/operations/apis/youtube/api.d.ts.map +1 -0
- package/dist/types/src/operations/apis/youtube/index.d.ts +3 -0
- package/dist/types/src/operations/apis/youtube/index.d.ts.map +1 -0
- package/dist/types/src/operations/apis/youtube/types.d.ts +493 -0
- package/dist/types/src/operations/apis/youtube/types.d.ts.map +1 -0
- package/dist/types/src/operations/clear-synced-videos.d.ts +5 -0
- package/dist/types/src/operations/clear-synced-videos.d.ts.map +1 -0
- package/dist/types/src/operations/definitions.d.ts +45 -0
- package/dist/types/src/operations/definitions.d.ts.map +1 -0
- package/dist/types/src/operations/index.d.ts +5 -0
- package/dist/types/src/operations/index.d.ts.map +1 -0
- package/dist/types/src/operations/services/google-credentials.d.ts +35 -0
- package/dist/types/src/operations/services/google-credentials.d.ts.map +1 -0
- package/dist/types/src/operations/services/index.d.ts +2 -0
- package/dist/types/src/operations/services/index.d.ts.map +1 -0
- package/dist/types/src/operations/sync.d.ts +5 -0
- package/dist/types/src/operations/sync.d.ts.map +1 -0
- package/dist/types/src/operations/sync.test.d.ts +2 -0
- package/dist/types/src/operations/sync.test.d.ts.map +1 -0
- package/dist/types/src/operations/transcript.d.ts +12 -0
- package/dist/types/src/operations/transcript.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +49 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/types/Channel.d.ts +54 -0
- package/dist/types/src/types/Channel.d.ts.map +1 -0
- package/dist/types/src/types/Video.d.ts +41 -0
- package/dist/types/src/types/Video.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +4 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/src/types/types.d.ts +5 -0
- package/dist/types/src/types/types.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +113 -0
- package/src/YouTubePlugin.tsx +66 -0
- package/src/blueprints/index.ts +7 -0
- package/src/blueprints/youtube.ts +52 -0
- package/src/capabilities/app-graph-builder/app-graph-builder.ts +148 -0
- package/src/capabilities/app-graph-builder/index.ts +7 -0
- package/src/capabilities/blueprint-definition/blueprint-definition.ts +17 -0
- package/src/capabilities/blueprint-definition/index.ts +7 -0
- package/src/capabilities/index.ts +7 -0
- package/src/capabilities/react-surface/index.ts +7 -0
- package/src/capabilities/react-surface/react-surface.tsx +54 -0
- package/src/containers/ChannelArticle/ChannelArticle.tsx +115 -0
- package/src/containers/ChannelArticle/index.ts +7 -0
- package/src/containers/ChannelSettings/ChannelSettings.tsx +34 -0
- package/src/containers/ChannelSettings/index.ts +7 -0
- package/src/containers/VideoArticle/VideoArticle.tsx +109 -0
- package/src/containers/VideoArticle/index.ts +7 -0
- package/src/containers/VideoCard/VideoCard.tsx +86 -0
- package/src/containers/VideoCard/index.ts +7 -0
- package/src/containers/index.ts +24 -0
- package/src/index.ts +8 -0
- package/src/meta.ts +19 -0
- package/src/operations/apis/index.ts +5 -0
- package/src/operations/apis/youtube/api.ts +149 -0
- package/src/operations/apis/youtube/index.ts +6 -0
- package/src/operations/apis/youtube/types.ts +254 -0
- package/src/operations/clear-synced-videos.ts +50 -0
- package/src/operations/definitions.ts +62 -0
- package/src/operations/index.ts +13 -0
- package/src/operations/services/google-credentials.ts +81 -0
- package/src/operations/services/index.ts +5 -0
- package/src/operations/sync.test.ts +114 -0
- package/src/operations/sync.ts +309 -0
- package/src/operations/transcript.ts +47 -0
- package/src/translations.ts +59 -0
- package/src/types/Channel.ts +80 -0
- package/src/types/Video.ts +67 -0
- package/src/types/index.ts +8 -0
- package/src/types/types.ts +10 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
|
|
7
|
+
import { Database, Feed, Filter, Obj, Ref } from '@dxos/echo';
|
|
8
|
+
import { log } from '@dxos/log';
|
|
9
|
+
import { Operation } from '@dxos/operation';
|
|
10
|
+
|
|
11
|
+
import { Channel, Video } from '../types';
|
|
12
|
+
|
|
13
|
+
import { ClearSyncedVideos } from './definitions';
|
|
14
|
+
|
|
15
|
+
const handler: Operation.WithHandler<typeof ClearSyncedVideos> = ClearSyncedVideos.pipe(
|
|
16
|
+
Operation.withHandler(({ channel: channelRef }) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
log('clearing youtube channel synced videos', { channel: channelRef.dxn.toString() });
|
|
19
|
+
const channel = (yield* Database.load(channelRef)) as Channel.YouTubeChannel;
|
|
20
|
+
const oldFeed = yield* Database.load(channel.feed as Ref.Ref<Feed.Feed>);
|
|
21
|
+
|
|
22
|
+
const videos = yield* Feed.runQuery(oldFeed, Filter.type(Video.YouTubeVideo));
|
|
23
|
+
log('removing synced videos', { count: videos.length });
|
|
24
|
+
|
|
25
|
+
const newFeed = Feed.make();
|
|
26
|
+
yield* Database.add(newFeed);
|
|
27
|
+
Obj.setParent(newFeed, channel);
|
|
28
|
+
|
|
29
|
+
Obj.change(channel, (mutable) => {
|
|
30
|
+
mutable.feed = Ref.make(newFeed);
|
|
31
|
+
delete mutable.lastSyncedAt;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (videos.length > 0) {
|
|
35
|
+
yield* Feed.remove(oldFeed, videos);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const video of videos) {
|
|
39
|
+
yield* Database.remove(video);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
yield* Database.remove(oldFeed);
|
|
43
|
+
|
|
44
|
+
log('replaced youtube channel feed', { removedVideos: videos.length });
|
|
45
|
+
return { removedVideos: videos.length };
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export default handler;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Operation } from '@dxos/operation';
|
|
6
|
+
import * as Schema from 'effect/Schema';
|
|
7
|
+
|
|
8
|
+
import { Database, Feed, Ref } from '@dxos/echo';
|
|
9
|
+
import { CredentialsService } from '@dxos/functions';
|
|
10
|
+
|
|
11
|
+
import { Channel, Video } from '../types';
|
|
12
|
+
|
|
13
|
+
export const Sync = Operation.make({
|
|
14
|
+
meta: {
|
|
15
|
+
key: 'dxos.org/function/youtube/sync',
|
|
16
|
+
name: 'Sync YouTube Channel',
|
|
17
|
+
description: 'Sync videos from a YouTube channel to the feed.',
|
|
18
|
+
},
|
|
19
|
+
input: Schema.Struct({
|
|
20
|
+
channel: Ref.Ref(Channel.YouTubeChannel).annotations({
|
|
21
|
+
description: 'Reference to the YouTube channel to sync videos from.',
|
|
22
|
+
}),
|
|
23
|
+
restrictedMode: Schema.Boolean.pipe(
|
|
24
|
+
Schema.annotations({
|
|
25
|
+
description:
|
|
26
|
+
'Use restricted mode to limit to max 20 videos. Reduces API calls. Useful for testing or quick syncs.',
|
|
27
|
+
}),
|
|
28
|
+
Schema.optional,
|
|
29
|
+
),
|
|
30
|
+
includeTranscripts: Schema.Boolean.pipe(
|
|
31
|
+
Schema.annotations({
|
|
32
|
+
description:
|
|
33
|
+
'Whether to fetch transcripts: Data API when your account can manage the video, otherwise watch-page captions (browser uses a public CORS proxy for YouTube fetches).',
|
|
34
|
+
}),
|
|
35
|
+
Schema.optional,
|
|
36
|
+
),
|
|
37
|
+
}),
|
|
38
|
+
output: Schema.Struct({
|
|
39
|
+
newVideos: Schema.Number,
|
|
40
|
+
channelTitle: Schema.String.pipe(Schema.optional),
|
|
41
|
+
}),
|
|
42
|
+
types: [Channel.YouTubeChannel, Video.YouTubeVideo],
|
|
43
|
+
services: [Database.Service, Feed.Service, CredentialsService],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const ClearSyncedVideos = Operation.make({
|
|
47
|
+
meta: {
|
|
48
|
+
key: 'dxos.org/function/youtube/clear-synced-videos',
|
|
49
|
+
name: 'Clear Synced YouTube Videos',
|
|
50
|
+
description: 'Remove all synced videos from the channel by replacing its feed with a new empty feed.',
|
|
51
|
+
},
|
|
52
|
+
input: Schema.Struct({
|
|
53
|
+
channel: Ref.Ref(Channel.YouTubeChannel).annotations({
|
|
54
|
+
description: 'Reference to the YouTube channel whose synced videos should be cleared.',
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
output: Schema.Struct({
|
|
58
|
+
removedVideos: Schema.Number,
|
|
59
|
+
}),
|
|
60
|
+
types: [Channel.YouTubeChannel, Video.YouTubeVideo],
|
|
61
|
+
services: [Database.Service, Feed.Service],
|
|
62
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { OperationHandlerSet } from '@dxos/operation';
|
|
6
|
+
|
|
7
|
+
export { YouTube } from './apis';
|
|
8
|
+
export * from './definitions';
|
|
9
|
+
|
|
10
|
+
export const YouTubeHandlers = OperationHandlerSet.lazy(
|
|
11
|
+
() => import('./clear-synced-videos'),
|
|
12
|
+
() => import('./sync'),
|
|
13
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Context from 'effect/Context';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Layer from 'effect/Layer';
|
|
8
|
+
|
|
9
|
+
import { Database, type Ref } from '@dxos/echo';
|
|
10
|
+
import { CredentialsService } from '@dxos/functions';
|
|
11
|
+
import { log } from '@dxos/log';
|
|
12
|
+
import { type AccessToken } from '@dxos/types';
|
|
13
|
+
|
|
14
|
+
import type * as Channel from '../../types/Channel';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates the service interface from a cached token.
|
|
18
|
+
* Falls back to database credentials if no cached token is provided.
|
|
19
|
+
*/
|
|
20
|
+
const makeService = (cachedToken: string | undefined): Context.Tag.Service<GoogleCredentials> => ({
|
|
21
|
+
get: () =>
|
|
22
|
+
cachedToken
|
|
23
|
+
? Effect.succeed(cachedToken)
|
|
24
|
+
: Effect.map(CredentialsService.getCredential({ service: 'google.com' }), (credential) => credential.apiKey!),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Loads access token from a ref if available.
|
|
29
|
+
*/
|
|
30
|
+
const loadAccessToken = (accessTokenRef: Ref.Ref<AccessToken.AccessToken> | undefined, label: string) =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
if (accessTokenRef) {
|
|
33
|
+
const accessToken = yield* Database.load(accessTokenRef);
|
|
34
|
+
if (accessToken?.token) {
|
|
35
|
+
log(`using ${label}-specific access token`, { note: accessToken.note });
|
|
36
|
+
return accessToken.token;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Service for accessing Google API credentials.
|
|
44
|
+
* Provides the Google API token either from an object's access token or falls back to database credentials.
|
|
45
|
+
*/
|
|
46
|
+
// TODO(dmaretskyi): Remove this service.
|
|
47
|
+
export class GoogleCredentials extends Context.Tag('GoogleCredentials')<
|
|
48
|
+
GoogleCredentials,
|
|
49
|
+
{
|
|
50
|
+
/** Returns the Google API token. */
|
|
51
|
+
get: () => Effect.Effect<string, never, CredentialsService>;
|
|
52
|
+
}
|
|
53
|
+
>() {
|
|
54
|
+
/**
|
|
55
|
+
* Creates a credentials layer from a channel.
|
|
56
|
+
* Pre-loads the access token during layer construction.
|
|
57
|
+
*/
|
|
58
|
+
static fromChannel = (channel: Channel.YouTubeChannel) =>
|
|
59
|
+
Layer.effect(GoogleCredentials, Effect.map(loadAccessToken(channel.accessToken, 'channel'), makeService));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Creates a credentials layer from a channel ref.
|
|
63
|
+
* Loads the channel and pre-loads the access token during layer construction.
|
|
64
|
+
*/
|
|
65
|
+
static fromChannelRef = (channelRef: Ref.Ref<Channel.YouTubeChannel>) =>
|
|
66
|
+
Layer.effect(
|
|
67
|
+
GoogleCredentials,
|
|
68
|
+
Effect.flatMap(Database.load(channelRef), (channel) =>
|
|
69
|
+
Effect.map(loadAccessToken(channel.accessToken, 'channel'), makeService),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Default layer that uses database credentials.
|
|
75
|
+
* Use this for operations that don't have an associated channel.
|
|
76
|
+
*/
|
|
77
|
+
static default = Layer.succeed(GoogleCredentials, makeService(undefined));
|
|
78
|
+
|
|
79
|
+
/** Convenience accessor - returns the Google API token. */
|
|
80
|
+
static get = () => Effect.flatMap(GoogleCredentials, (service) => service.get());
|
|
81
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
|
|
6
|
+
import { describe, it } from '@effect/vitest';
|
|
7
|
+
import * as Config from 'effect/Config';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Layer from 'effect/Layer';
|
|
10
|
+
|
|
11
|
+
import { CredentialsService } from '@dxos/functions';
|
|
12
|
+
|
|
13
|
+
import { YouTube } from './apis';
|
|
14
|
+
import { GoogleCredentials } from './services/google-credentials';
|
|
15
|
+
import { fetchTranscript } from './transcript';
|
|
16
|
+
|
|
17
|
+
const TestLayer = Layer.mergeAll(
|
|
18
|
+
CredentialsService.layerConfig([
|
|
19
|
+
{
|
|
20
|
+
service: 'google.com',
|
|
21
|
+
apiKey: Config.redacted('GOOGLE_ACCESS_TOKEN'),
|
|
22
|
+
},
|
|
23
|
+
]),
|
|
24
|
+
FetchHttpClient.layer,
|
|
25
|
+
GoogleCredentials.default,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* To get a temporary access token:
|
|
30
|
+
* 1. Go to Google OAuth Playground: https://developers.google.com/oauthplayground/
|
|
31
|
+
* 2. Select YouTube Data API v3 (all scopes or readonly)
|
|
32
|
+
* 3. Authorize and get an access token
|
|
33
|
+
*
|
|
34
|
+
* export GOOGLE_ACCESS_TOKEN="xxx"
|
|
35
|
+
* pnpm vitest sync.test.ts
|
|
36
|
+
*/
|
|
37
|
+
describe.runIf(process.env.GOOGLE_ACCESS_TOKEN)('YouTube API', { timeout: 30_000 }, () => {
|
|
38
|
+
it.effect(
|
|
39
|
+
'get channel by handle',
|
|
40
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
41
|
+
const response = yield* YouTube.getChannelByHandle('Google');
|
|
42
|
+
console.log(JSON.stringify(response, null, 2));
|
|
43
|
+
expect(response.items.length).toBeGreaterThan(0);
|
|
44
|
+
expect(response.items[0].snippet?.title).toBeDefined();
|
|
45
|
+
}, Effect.provide(TestLayer)),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
it.effect(
|
|
49
|
+
'get channel uploads',
|
|
50
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
51
|
+
const channelResponse = yield* YouTube.getChannelByHandle('Google');
|
|
52
|
+
const channel = channelResponse.items[0];
|
|
53
|
+
expect(channel).toBeDefined();
|
|
54
|
+
|
|
55
|
+
const uploadsPlaylistId = channel.contentDetails?.relatedPlaylists?.uploads;
|
|
56
|
+
expect(uploadsPlaylistId).toBeDefined();
|
|
57
|
+
|
|
58
|
+
const playlistResponse = yield* YouTube.listPlaylistItems(uploadsPlaylistId!, 5);
|
|
59
|
+
console.log('Playlist items:', JSON.stringify(playlistResponse.items.slice(0, 2), null, 2));
|
|
60
|
+
expect(playlistResponse.items.length).toBeGreaterThan(0);
|
|
61
|
+
}, Effect.provide(TestLayer)),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it.effect(
|
|
65
|
+
'get video details',
|
|
66
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
67
|
+
const channelResponse = yield* YouTube.getChannelByHandle('TED');
|
|
68
|
+
const channel = channelResponse.items[0];
|
|
69
|
+
expect(channel).toBeDefined();
|
|
70
|
+
|
|
71
|
+
const uploadsPlaylistId = channel.contentDetails?.relatedPlaylists?.uploads;
|
|
72
|
+
expect(uploadsPlaylistId).toBeDefined();
|
|
73
|
+
|
|
74
|
+
const playlistResponse = yield* YouTube.listPlaylistItems(uploadsPlaylistId!, 3);
|
|
75
|
+
const videoIds = playlistResponse.items
|
|
76
|
+
.map((item) => item.snippet?.resourceId?.videoId)
|
|
77
|
+
.filter((id): id is string => Boolean(id));
|
|
78
|
+
|
|
79
|
+
const videosResponse = yield* YouTube.getVideoDetails(videoIds);
|
|
80
|
+
console.log('Video details:', JSON.stringify(videosResponse.items[0], null, 2));
|
|
81
|
+
expect(videosResponse.items.length).toBeGreaterThan(0);
|
|
82
|
+
expect(videosResponse.items[0].contentDetails?.duration).toBeDefined();
|
|
83
|
+
}, Effect.provide(TestLayer)),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Transcripts: uses youtube-caption-extractor to scrape captions from the watch page.
|
|
89
|
+
*/
|
|
90
|
+
describe('YouTube Transcript', { timeout: 30_000 }, () => {
|
|
91
|
+
it.effect(
|
|
92
|
+
'fetch transcript for video',
|
|
93
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
94
|
+
const result = yield* fetchTranscript('dQw4w9WgXcQ');
|
|
95
|
+
|
|
96
|
+
if (result) {
|
|
97
|
+
console.log('Transcript segments:', result.segments.length);
|
|
98
|
+
console.log('Full text preview:', result.fullText.slice(0, 200));
|
|
99
|
+
expect(result.segments.length).toBeGreaterThan(0);
|
|
100
|
+
expect(result.fullText.length).toBeGreaterThan(0);
|
|
101
|
+
} else {
|
|
102
|
+
console.log('No transcript available for this video');
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
it.effect(
|
|
108
|
+
'handles video without transcript gracefully',
|
|
109
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
110
|
+
const result = yield* fetchTranscript('nonexistent_video_id_12345');
|
|
111
|
+
expect(result).toBeUndefined();
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
|
|
6
|
+
import * as Chunk from 'effect/Chunk';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Function from 'effect/Function';
|
|
9
|
+
import * as Layer from 'effect/Layer';
|
|
10
|
+
import * as Option from 'effect/Option';
|
|
11
|
+
import * as Predicate from 'effect/Predicate';
|
|
12
|
+
import * as Stream from 'effect/Stream';
|
|
13
|
+
|
|
14
|
+
import { Database, Feed, Filter, Obj, Ref } from '@dxos/echo';
|
|
15
|
+
import { log } from '@dxos/log';
|
|
16
|
+
import { Operation } from '@dxos/operation';
|
|
17
|
+
|
|
18
|
+
import { Channel, Video } from '../types';
|
|
19
|
+
|
|
20
|
+
import { YouTube } from './apis';
|
|
21
|
+
import { Sync } from './definitions';
|
|
22
|
+
import { GoogleCredentials } from './services/google-credentials';
|
|
23
|
+
import { fetchTranscript } from './transcript';
|
|
24
|
+
|
|
25
|
+
const handler: Operation.WithHandler<typeof Sync> = Sync.pipe(
|
|
26
|
+
Operation.withHandler(({ channel: channelRef, restrictedMode = false, includeTranscripts = true }) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
log('syncing youtube channel', { channel: channelRef.dxn.toString(), restrictedMode, includeTranscripts });
|
|
29
|
+
const channel = yield* Database.load(channelRef);
|
|
30
|
+
|
|
31
|
+
const channelUrl =
|
|
32
|
+
(channel as Channel.YouTubeChannel).channelUrl ?? (channel as Channel.YouTubeChannel).channelId;
|
|
33
|
+
if (!channelUrl) {
|
|
34
|
+
return yield* Effect.fail(new Error('No channel URL or ID configured'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const channelInfo = extractChannelInfo(channelUrl);
|
|
38
|
+
log('extracted channel info', channelInfo);
|
|
39
|
+
|
|
40
|
+
const { channelId, channelTitle, uploadsPlaylistId } = yield* getUploadsPlaylistId(channelInfo);
|
|
41
|
+
log('found channel', { channelId, channelTitle, uploadsPlaylistId });
|
|
42
|
+
|
|
43
|
+
Obj.change(channel as Channel.YouTubeChannel, (channelObj) => {
|
|
44
|
+
channelObj.channelId = channelId;
|
|
45
|
+
if (!channelObj.name) {
|
|
46
|
+
channelObj.name = channelTitle;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Get the feed and query for existing videos.
|
|
51
|
+
const feed = yield* Database.load((channel as Channel.YouTubeChannel).feed as Ref.Ref<Feed.Feed>);
|
|
52
|
+
const existingVideos = yield* Feed.runQuery(feed, Filter.type(Video.YouTubeVideo));
|
|
53
|
+
const existingVideoIds = new Set(existingVideos.map((video: Video.YouTubeVideo) => video.videoId));
|
|
54
|
+
log('existing videos', { count: existingVideoIds.size });
|
|
55
|
+
|
|
56
|
+
const newVideosCount = yield* streamVideosToFeed(
|
|
57
|
+
uploadsPlaylistId,
|
|
58
|
+
feed,
|
|
59
|
+
existingVideoIds,
|
|
60
|
+
restrictedMode,
|
|
61
|
+
includeTranscripts,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
Obj.change(channel as Channel.YouTubeChannel, (channelObj) => {
|
|
65
|
+
channelObj.lastSyncedAt = new Date().toISOString();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
log('sync complete', { newVideos: newVideosCount, channelTitle });
|
|
69
|
+
return {
|
|
70
|
+
newVideos: newVideosCount,
|
|
71
|
+
channelTitle,
|
|
72
|
+
};
|
|
73
|
+
}).pipe(Effect.provide(Layer.mergeAll(FetchHttpClient.layer, GoogleCredentials.fromChannelRef(channelRef)))),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const STREAMING_CONFIG = {
|
|
78
|
+
/** Videos per page from YouTube API. */
|
|
79
|
+
maxResults: 50,
|
|
80
|
+
/** Parallel transcript fetches. */
|
|
81
|
+
transcriptFetchConcurrency: 3,
|
|
82
|
+
/** In-flight video buffer. */
|
|
83
|
+
bufferSize: 10,
|
|
84
|
+
/** Videos per feed append. */
|
|
85
|
+
feedBatchSize: 10,
|
|
86
|
+
/** Max videos in restricted mode. */
|
|
87
|
+
restrictedMax: 20,
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extracts channel ID from various YouTube URL formats.
|
|
92
|
+
*/
|
|
93
|
+
const extractChannelInfo = (
|
|
94
|
+
urlOrHandle: string,
|
|
95
|
+
): { type: 'id'; value: string } | { type: 'handle'; value: string } | { type: 'url'; value: string } => {
|
|
96
|
+
const trimmed = urlOrHandle.trim();
|
|
97
|
+
|
|
98
|
+
if (trimmed.startsWith('@')) {
|
|
99
|
+
return { type: 'handle', value: trimmed.slice(1) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (trimmed.startsWith('UC') && trimmed.length === 24) {
|
|
103
|
+
return { type: 'id', value: trimmed };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(trimmed);
|
|
108
|
+
const pathname = url.pathname;
|
|
109
|
+
|
|
110
|
+
if (pathname.startsWith('/channel/')) {
|
|
111
|
+
const channelId = pathname.split('/')[2];
|
|
112
|
+
if (channelId) {
|
|
113
|
+
return { type: 'id', value: channelId };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (pathname.startsWith('/@')) {
|
|
118
|
+
return { type: 'handle', value: pathname.slice(2) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (pathname.startsWith('/c/') || pathname.startsWith('/user/')) {
|
|
122
|
+
const name = pathname.split('/')[2];
|
|
123
|
+
if (name) {
|
|
124
|
+
return { type: 'handle', value: name };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
129
|
+
return { type: 'handle', value: trimmed };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { type: 'url', value: trimmed };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Gets the uploads playlist ID for a channel.
|
|
138
|
+
*/
|
|
139
|
+
const getUploadsPlaylistId = Effect.fn(function* (channelInfo: { type: string; value: string }) {
|
|
140
|
+
let channelResponse;
|
|
141
|
+
|
|
142
|
+
if (channelInfo.type === 'id') {
|
|
143
|
+
channelResponse = yield* YouTube.getChannel(channelInfo.value);
|
|
144
|
+
} else {
|
|
145
|
+
channelResponse = yield* YouTube.getChannelByHandle(channelInfo.value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const channel = channelResponse.items[0];
|
|
149
|
+
if (!channel) {
|
|
150
|
+
return yield* Effect.fail(new Error(`Channel not found: ${channelInfo.value}`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const uploadsPlaylistId = channel.contentDetails?.relatedPlaylists?.uploads;
|
|
154
|
+
if (!uploadsPlaylistId) {
|
|
155
|
+
return yield* Effect.fail(new Error(`No uploads playlist found for channel: ${channelInfo.value}`));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
channelId: channel.id,
|
|
160
|
+
channelTitle: channel.snippet?.title ?? '',
|
|
161
|
+
uploadsPlaylistId,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fetches videos from an uploads playlist, returning them from newest to oldest.
|
|
167
|
+
*/
|
|
168
|
+
const fetchPlaylistVideos = (uploadsPlaylistId: string, maxVideos?: number) => {
|
|
169
|
+
let videoCount = 0;
|
|
170
|
+
|
|
171
|
+
return Stream.unfoldChunkEffect({ pageToken: Option.none<string>(), done: false }, (state) =>
|
|
172
|
+
Effect.gen(function* () {
|
|
173
|
+
if (state.done || (maxVideos && videoCount >= maxVideos)) {
|
|
174
|
+
return Option.none();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const response = yield* YouTube.listPlaylistItems(
|
|
178
|
+
uploadsPlaylistId,
|
|
179
|
+
STREAMING_CONFIG.maxResults,
|
|
180
|
+
Option.getOrUndefined(state.pageToken),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const videoIds = response.items
|
|
184
|
+
.map((item) => item.snippet?.resourceId?.videoId)
|
|
185
|
+
.filter((id): id is string => Boolean(id));
|
|
186
|
+
|
|
187
|
+
log('fetched playlist items', {
|
|
188
|
+
count: videoIds.length,
|
|
189
|
+
pageToken: Option.getOrUndefined(state.pageToken),
|
|
190
|
+
hasMore: Boolean(response.nextPageToken),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
videoCount += videoIds.length;
|
|
194
|
+
|
|
195
|
+
const nextState = {
|
|
196
|
+
pageToken: Option.fromNullable(response.nextPageToken),
|
|
197
|
+
done: !response.nextPageToken || (maxVideos !== undefined && videoCount >= maxVideos),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return Option.some([Chunk.fromIterable(videoIds), nextState]);
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
type TranscriptData = { segments: Video.TranscriptSegment[]; fullText: string };
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Maps YouTube API video item to YouTubeVideo data.
|
|
209
|
+
*/
|
|
210
|
+
const mapVideoData = (
|
|
211
|
+
item: YouTube.VideoItem,
|
|
212
|
+
transcript: TranscriptData | undefined,
|
|
213
|
+
includeTranscripts: boolean,
|
|
214
|
+
): Omit<Video.YouTubeVideo, 'id' | '~@dxos/echo/Kind'> => {
|
|
215
|
+
const hasTranscript = Boolean(transcript?.fullText?.trim());
|
|
216
|
+
return {
|
|
217
|
+
title: item.snippet?.title ?? 'Untitled',
|
|
218
|
+
videoId: item.id,
|
|
219
|
+
description: item.snippet?.description,
|
|
220
|
+
url: `https://www.youtube.com/watch?v=${item.id}`,
|
|
221
|
+
thumbnailUrl:
|
|
222
|
+
item.snippet?.thumbnails?.high?.url ??
|
|
223
|
+
item.snippet?.thumbnails?.medium?.url ??
|
|
224
|
+
item.snippet?.thumbnails?.default?.url,
|
|
225
|
+
channelTitle: item.snippet?.channelTitle,
|
|
226
|
+
publishedAt: item.snippet?.publishedAt ?? new Date().toISOString(),
|
|
227
|
+
duration: item.contentDetails?.duration,
|
|
228
|
+
viewCount: item.statistics?.viewCount ? parseInt(item.statistics.viewCount, 10) : undefined,
|
|
229
|
+
likeCount: item.statistics?.likeCount ? parseInt(item.statistics.likeCount, 10) : undefined,
|
|
230
|
+
transcript: transcript && hasTranscript ? transcript.fullText : undefined,
|
|
231
|
+
transcriptSegments: transcript && hasTranscript ? transcript.segments : undefined,
|
|
232
|
+
transcriptFetched: includeTranscripts ? hasTranscript : false,
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Stream videos with transcripts into a DXOS feed.
|
|
238
|
+
*/
|
|
239
|
+
const streamVideosToFeed = Effect.fn(function* (
|
|
240
|
+
uploadsPlaylistId: string,
|
|
241
|
+
feed: Feed.Feed,
|
|
242
|
+
existingVideoIds: Set<string>,
|
|
243
|
+
restricted: boolean,
|
|
244
|
+
includeTranscripts: boolean,
|
|
245
|
+
) {
|
|
246
|
+
const count = yield* Function.pipe(
|
|
247
|
+
fetchPlaylistVideos(uploadsPlaylistId, restricted ? STREAMING_CONFIG.restrictedMax : undefined),
|
|
248
|
+
Stream.filter((videoId) => {
|
|
249
|
+
const isDuplicate = existingVideoIds.has(videoId);
|
|
250
|
+
if (isDuplicate) {
|
|
251
|
+
log('skipping duplicate video', { videoId });
|
|
252
|
+
}
|
|
253
|
+
return !isDuplicate;
|
|
254
|
+
}),
|
|
255
|
+
restricted ? Stream.take(STREAMING_CONFIG.restrictedMax) : Function.identity,
|
|
256
|
+
Stream.grouped(10),
|
|
257
|
+
Stream.flatMap(
|
|
258
|
+
(videoIdChunk) =>
|
|
259
|
+
Effect.gen(function* () {
|
|
260
|
+
const videoIds = Chunk.toArray(videoIdChunk);
|
|
261
|
+
log('fetching video details', { count: videoIds.length });
|
|
262
|
+
|
|
263
|
+
const response = yield* YouTube.getVideoDetails(videoIds);
|
|
264
|
+
return response.items;
|
|
265
|
+
}),
|
|
266
|
+
{ concurrency: 1 },
|
|
267
|
+
),
|
|
268
|
+
Stream.flatMap((items) => Stream.fromIterable(items)),
|
|
269
|
+
Stream.flatMap(
|
|
270
|
+
(item) =>
|
|
271
|
+
Effect.gen(function* () {
|
|
272
|
+
let transcript: TranscriptData | undefined;
|
|
273
|
+
|
|
274
|
+
if (includeTranscripts) {
|
|
275
|
+
log('fetching transcript', { videoId: item.id });
|
|
276
|
+
const result = yield* fetchTranscript(item.id);
|
|
277
|
+
if (result) {
|
|
278
|
+
transcript = result;
|
|
279
|
+
log('transcript fetched', { videoId: item.id, length: transcript.fullText.length });
|
|
280
|
+
} else {
|
|
281
|
+
log('no transcript available', { videoId: item.id });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return mapVideoData(item, transcript, includeTranscripts);
|
|
286
|
+
}),
|
|
287
|
+
{
|
|
288
|
+
concurrency: STREAMING_CONFIG.transcriptFetchConcurrency,
|
|
289
|
+
bufferSize: STREAMING_CONFIG.bufferSize,
|
|
290
|
+
},
|
|
291
|
+
),
|
|
292
|
+
Stream.filter(Predicate.isNotNullable),
|
|
293
|
+
Stream.grouped(STREAMING_CONFIG.feedBatchSize),
|
|
294
|
+
Stream.mapEffect((batch) =>
|
|
295
|
+
Effect.gen(function* () {
|
|
296
|
+
const videos = Chunk.toArray(batch);
|
|
297
|
+
log('appending batch to feed', { count: videos.length });
|
|
298
|
+
const videoObjects = videos.map((video) => Obj.make(Video.YouTubeVideo, video));
|
|
299
|
+
yield* Feed.append(feed, videoObjects);
|
|
300
|
+
return videos.length;
|
|
301
|
+
}),
|
|
302
|
+
),
|
|
303
|
+
Stream.runFold(0, (acc, batchCount) => acc + batchCount),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
return count;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
export default handler;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
|
|
9
|
+
import type { TranscriptSegment } from '../types/Video';
|
|
10
|
+
|
|
11
|
+
export type TranscriptResult = {
|
|
12
|
+
segments: TranscriptSegment[];
|
|
13
|
+
fullText: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetches captions for a YouTube video using youtube-caption-extractor.
|
|
18
|
+
* Works in both browser and Node/edge environments.
|
|
19
|
+
*/
|
|
20
|
+
export const fetchTranscript = (videoId: string, lang?: string): Effect.Effect<TranscriptResult | undefined> =>
|
|
21
|
+
Effect.tryPromise({
|
|
22
|
+
try: async () => {
|
|
23
|
+
const { getSubtitles } = await import('youtube-caption-extractor');
|
|
24
|
+
const subtitles = await getSubtitles({ videoID: videoId, lang: lang ?? 'en' });
|
|
25
|
+
|
|
26
|
+
if (!subtitles || subtitles.length === 0) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const segments: TranscriptSegment[] = subtitles.map((sub) => ({
|
|
31
|
+
text: sub.text,
|
|
32
|
+
offset: parseFloat(sub.start),
|
|
33
|
+
duration: parseFloat(sub.dur),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const fullText = segments.map((segment) => segment.text).join(' ');
|
|
37
|
+
return { segments, fullText };
|
|
38
|
+
},
|
|
39
|
+
catch: (error) => {
|
|
40
|
+
log('failed to fetch transcript', { videoId, error });
|
|
41
|
+
return undefined;
|
|
42
|
+
},
|
|
43
|
+
}).pipe(
|
|
44
|
+
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
45
|
+
Effect.timeout('30 seconds'),
|
|
46
|
+
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
47
|
+
);
|