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
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:
143
doc/research/reference-sources/fpdl_Subscription.ts
Normal file
143
doc/research/reference-sources/fpdl_Subscription.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user