Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
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>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
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;
}
}