Files
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- 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>
2026-10-25 09:42:27 +01:00

144 lines
5.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}