Some checks failed
release / build-release (push) Has been cancelled
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
import { fApi } from "./helpers/index";
|
||
|
||
import chalk from "chalk-template";
|
||
|
||
import type { ContentPost, VideoContent } from "floatplane/content";
|
||
import type { ChannelOptions, SubscriptionSettings } from "./types";
|
||
|
||
import { Attachment } from "./Attachment";
|
||
import { settings } from "./helpers/index";
|
||
import { Video } from "./Video";
|
||
|
||
const removeRepeatedSentences = (postTitle: string, attachmentTitle: string) => {
|
||
const separators = /(?:\s+|^)((?:[^.,;:!?-]+[\s]*[.,;:!?-]+)+)(?:\s+|$)/g;
|
||
const postTitleSentences = postTitle.match(separators)?.map((sentence) => sentence.trim());
|
||
const attachmentTitleSentences = attachmentTitle.match(separators)?.map((sentence) => sentence.trim());
|
||
|
||
const uniqueAttachmentTitleSentences = attachmentTitleSentences?.filter((sentence) => !postTitleSentences?.includes(sentence));
|
||
|
||
if (uniqueAttachmentTitleSentences === undefined) return `${postTitle.trim()} - ${attachmentTitle.trim()}`.trim();
|
||
|
||
// Remove trailing separator
|
||
return `${postTitle.trim()} - ${uniqueAttachmentTitleSentences.join("").trim()}`.trim().replace(/[\s]*[.,;:!?-]+[\s]*$/, "");
|
||
};
|
||
|
||
type isChannel = (post: ContentPost, video?: VideoContent) => boolean;
|
||
export default class Subscription {
|
||
public readonly creatorId: string;
|
||
public readonly channels: SubscriptionSettings["channels"];
|
||
public readonly plan: string;
|
||
|
||
constructor(subscription: SubscriptionSettings) {
|
||
this.creatorId = subscription.creatorId;
|
||
this.channels = subscription.channels;
|
||
this.plan = subscription.plan;
|
||
}
|
||
|
||
public deleteOldVideos = async () => {
|
||
for (const channel of this.channels) {
|
||
if (channel.daysToKeepVideos !== undefined) {
|
||
const ignoreBeforeTimestamp = Subscription.getIgnoreBeforeTimestamp(channel);
|
||
let deletedVideos = 0;
|
||
|
||
for (const video of Video.find((video) => {
|
||
return video.releaseDate < ignoreBeforeTimestamp && video.channelTitle === channel.title;
|
||
})) {
|
||
deletedVideos++;
|
||
await new Attachment({
|
||
...video,
|
||
releaseDate: new Date(video.releaseDate),
|
||
}).delete();
|
||
}
|
||
console.log(chalk`[{yellow ${channel.title}}] deleted {redBright ${deletedVideos}} videos older than {cyanBright ${channel.daysToKeepVideos}} days`);
|
||
}
|
||
}
|
||
};
|
||
|
||
private static getIgnoreBeforeTimestamp = (channel: ChannelOptions) => Date.now() - (channel.daysToKeepVideos ?? 0) * 24 * 60 * 60 * 1000;
|
||
private static isChannelCache: Record<string, isChannel> = {};
|
||
private static isChannelHelper = `const isChannel = (post, channelId) => (typeof post.channel !== 'string' ? post.channel.id : post.channel) === channelId`;
|
||
|
||
private async *matchChannel(blogPost: ContentPost): AsyncGenerator<Video> {
|
||
if (blogPost.videoAttachments === undefined) return;
|
||
let dateOffset = 0;
|
||
for (const videoAttachment of blogPost.videoAttachments.sort((a, b) => blogPost.attachmentOrder.indexOf(a.id) - blogPost.attachmentOrder.indexOf(b.id))) {
|
||
// Make sure we have a unique object for each attachment
|
||
const post = { ...blogPost };
|
||
const video: VideoContent = await fApi.content.video(videoAttachment.id);
|
||
// Skip videos with no levels
|
||
if (video.levels.length === 0) continue;
|
||
|
||
if (blogPost.videoAttachments.length > 1) {
|
||
dateOffset++;
|
||
post.title = removeRepeatedSentences(post.title, video.title);
|
||
}
|
||
|
||
for (const channel of this.channels) {
|
||
if (channel.isChannel === undefined) continue;
|
||
const isChannel =
|
||
Subscription.isChannelCache[channel.isChannel] ??
|
||
(Subscription.isChannelCache[channel.isChannel] = new Function(`${Subscription.isChannelHelper};return ${channel.isChannel};`)() as isChannel);
|
||
|
||
if (!isChannel(post, video)) continue;
|
||
if (channel.skip) break;
|
||
if (channel.daysToKeepVideos !== undefined && new Date(post.releaseDate).getTime() < Subscription.getIgnoreBeforeTimestamp(channel)) return;
|
||
|
||
// Remove the identifier from the video title if to give a nicer title
|
||
if (settings.extras.stripSubchannelPrefix === true) {
|
||
const replacers = [
|
||
new RegExp(channel.title.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"), "i"),
|
||
/MA: /i,
|
||
/FP Exclusive: /i,
|
||
/talklinked/i,
|
||
/TL: /i,
|
||
/TL Short: /i,
|
||
/TQ: /i,
|
||
/TJM: /i,
|
||
/SC: /i,
|
||
/CSF: /i,
|
||
/Livestream VOD – /i,
|
||
/ : /i,
|
||
];
|
||
for (const regIdCheck of replacers) {
|
||
post.title = post.title.replace(regIdCheck, "");
|
||
}
|
||
|
||
post.title = post.title.replaceAll(" ", " ");
|
||
if (post.title.startsWith(": ")) post.title = post.title.replace(": ", "");
|
||
|
||
post.title = post.title.trim();
|
||
}
|
||
yield Video.getOrCreate({
|
||
attachmentId: videoAttachment.id,
|
||
description: post.text,
|
||
artworkUrl: post.thumbnail?.path,
|
||
channelTitle: channel.title,
|
||
videoTitle: post.title,
|
||
releaseDate: new Date(new Date(post.releaseDate).getTime() + dateOffset * 1000),
|
||
textTracks: video.textTracks,
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
public async *fetchNewVideos(): AsyncGenerator<Video> {
|
||
if (settings.floatplane.videosToSearch === 0) return;
|
||
let videosSearched = 0;
|
||
console.log(chalk`Searching for new videos in {yellow ${this.plan}}`);
|
||
for await (const blogPost of fApi.creator.blogPostsIterable(this.creatorId)) {
|
||
if (!blogPost.metadata.hasVideo) continue;
|
||
for await (const video of this.matchChannel(await fApi.content.post(blogPost.id))) {
|
||
yield video;
|
||
}
|
||
|
||
// Stop searching if we have looked through videosToSearch
|
||
if (++videosSearched >= settings.floatplane.videosToSearch) break;
|
||
}
|
||
}
|
||
|
||
public async *seekAndDestroy(contentPost: ContentPost): AsyncGenerator<Video> {
|
||
for await (const video of this.matchChannel(contentPost)) yield video;
|
||
}
|
||
}
|