diff --git a/client-vue/src/components/forms/ChannelAddForm.vue b/client-vue/src/components/forms/ChannelAddForm.vue index e2fcb4e5..d8ba1852 100644 --- a/client-vue/src/components/forms/ChannelAddForm.vue +++ b/client-vue/src/components/forms/ChannelAddForm.vue @@ -410,7 +410,7 @@ function getYouTubeChannelId() { function fetchKickSlug() { axios - .get>(`/api/v0/kickapi/users/${formData.value.internalName}`) + .get>(`/api/v0/kickapi/channels/${formData.value.internalName}`) .then((response) => { const json = response.data; if (json.status == "OK") { diff --git a/common/KickAPI/Kick.ts b/common/KickAPI/Kick.ts index 9ec1a1c4..90ee6bd3 100644 --- a/common/KickAPI/Kick.ts +++ b/common/KickAPI/Kick.ts @@ -1,196 +1,202 @@ - -export interface KickUser { - id: number; - username: string; - bio: string; - twitter: string; - facebook: string; - instagram: string; - youtube: string; - discord: string; - tiktok: string; - profilepic: string; -} - -export interface KickChannel { - /** Channel ID -> KickChannel */ - id: number; - /** User ID -> KickUser */ - user_id: number; - slug: string; - is_banned: boolean; - playback_url: string; - name_updated_at: null; - vod_enabled: boolean; - subscription_enabled: boolean; - followersCount: number; - subscriber_badges: any[]; - banner_image: null; - recent_categories: RecentCategoryElement[]; - livestream: null; - role: null; - muted: boolean; - follower_badges: any[]; - offline_banner_image: null; - can_host: boolean; - user: User; - chatroom: Chatroom; - ascending_links: any[]; - plan: Plan; - previous_livestreams: PreviousLivestream[]; - verified: Verified; - media: any[]; -} - -export interface Chatroom { - id: number; - chatable_type: string; - channel_id: number; - created_at: string; - updated_at: string; - chat_mode_old: string; - chat_mode: string; - slow_mode: boolean; - chatable_id: number; - followers_mode: boolean; - subscribers_mode: boolean; - emotes_mode: boolean; - message_interval: number; - following_min_duration: number; -} - -export interface Plan { - id: number; - channel_id: number; - stripe_plan_id: string; - amount: string; - created_at: string; - updated_at: string; -} - -export interface PreviousLivestream { - id: number; - slug: string; - channel_id: number; - created_at: string; - session_title: string; - is_live: boolean; - risk_level_id: null; - source: null; - twitch_channel: null; - duration: number; - language: string; - is_mature: boolean; - viewer_count: number; - thumbnail: { src: string; srcset: string; }; - views: number; - tags: any[]; - categories: RecentCategoryElement[]; - video: Video; -} - -export interface RecentCategoryElement { - id: number; - category_id: number; - name: string; - slug: string; - tags: string[]; - description: null; - deleted_at: null; - viewers: number; - banner: Banner; - category: RecentCategoryCategory; -} - -export interface Banner { - responsive: string; - url: string; -} - -export interface RecentCategoryCategory { - id: number; - name: string; - slug: string; - icon: string; -} - -export interface Video { - id: number; - live_stream_id: number; - slug: null; - thumb: null; - s3: null; - trading_platform_id: null; - created_at: string; - updated_at: string; - uuid: string; - views: number; - deleted_at: null; -} - -export interface User { - id: number; - username: string; - agreed_to_terms: boolean; - email_verified_at: string; - bio: string; - country: string; - state: string; - city: string; - instagram: string; - twitter: string; - youtube: string; - discord: string; - tiktok: string; - facebook: string; - profile_pic: string; -} - -export interface Verified { - id: number; - channel_id: number; - created_at: string; - updated_at: string; -} - - -export interface KickChannelVideo { - session_title: string; - thumbnail: { src: string; srcset: string }; - video: { - id: number; - uuid: string; - live_stream_id: number; - } -} - -// Generated by https://quicktype.io - -export interface KickChannelLivestreamResponse { - data: KickChannelLivestream; -} - -export interface KickChannelLivestream { - id: number; - slug: string; - session_title: string; - created_at: string; - language: string; - is_mature: boolean; - viewers: number; - category: Category; - playback_url: string; -} - -export interface Category { - id: number; - name: string; - slug: string; - tags: string[]; - parent_category: ParentCategory; -} - -export interface ParentCategory { - id: number; - slug: string; -} +export interface KickMessageResponse { + message: string; +} + +export interface KickDataResponse { + data: T; +} + +export interface KickUser { + id: number; + username: string; + bio: string; + twitter: string; + facebook: string; + instagram: string; + youtube: string; + discord: string; + tiktok: string; + profilepic: string; +} + +export interface KickChannel { + /** Channel ID -> KickChannel */ + id: number; + /** User ID -> KickUser */ + user_id: number; + slug: string; + is_banned: boolean; + playback_url: string; + name_updated_at: null; + vod_enabled: boolean; + subscription_enabled: boolean; + followersCount: number; + subscriber_badges: any[]; + banner_image: null; + recent_categories: RecentCategoryElement[]; + livestream: null; + role: null; + muted: boolean; + follower_badges: any[]; + offline_banner_image: null; + can_host: boolean; + user: User; + chatroom: Chatroom; + ascending_links: any[]; + plan: Plan; + previous_livestreams: PreviousLivestream[]; + verified: Verified; + media: any[]; +} + +export interface Chatroom { + id: number; + chatable_type: string; + channel_id: number; + created_at: string; + updated_at: string; + chat_mode_old: string; + chat_mode: string; + slow_mode: boolean; + chatable_id: number; + followers_mode: boolean; + subscribers_mode: boolean; + emotes_mode: boolean; + message_interval: number; + following_min_duration: number; +} + +export interface Plan { + id: number; + channel_id: number; + stripe_plan_id: string; + amount: string; + created_at: string; + updated_at: string; +} + +export interface PreviousLivestream { + id: number; + slug: string; + channel_id: number; + created_at: string; + session_title: string; + is_live: boolean; + risk_level_id: null; + source: null; + twitch_channel: null; + duration: number; + language: string; + is_mature: boolean; + viewer_count: number; + thumbnail: { src: string; srcset: string }; + views: number; + tags: any[]; + categories: RecentCategoryElement[]; + video: Video; +} + +export interface RecentCategoryElement { + id: number; + category_id: number; + name: string; + slug: string; + tags: string[]; + description: null; + deleted_at: null; + viewers: number; + banner: Banner; + category: RecentCategoryCategory; +} + +export interface Banner { + responsive: string; + url: string; +} + +export interface RecentCategoryCategory { + id: number; + name: string; + slug: string; + icon: string; +} + +export interface Video { + id: number; + live_stream_id: number; + slug: null; + thumb: null; + s3: null; + trading_platform_id: null; + created_at: string; + updated_at: string; + uuid: string; + views: number; + deleted_at: null; +} + +export interface User { + id: number; + username: string; + agreed_to_terms: boolean; + email_verified_at: string; + bio: string; + country: string; + state: string; + city: string; + instagram: string; + twitter: string; + youtube: string; + discord: string; + tiktok: string; + facebook: string; + profile_pic: string; +} + +export interface Verified { + id: number; + channel_id: number; + created_at: string; + updated_at: string; +} + +export interface KickChannelVideo { + session_title: string; + thumbnail: { src: string; srcset: string }; + video: { + id: number; + uuid: string; + live_stream_id: number; + }; +} + +// Generated by https://quicktype.io + +export interface KickChannelLivestreamResponse { + data: KickChannelLivestream; +} + +export interface KickChannelLivestream { + id: number; + slug: string; + session_title: string; + created_at: string; + language: string; + is_mature: boolean; + viewers: number; + category: Category; + playback_url: string; +} + +export interface Category { + id: number; + name: string; + slug: string; + tags: string[]; + parent_category: ParentCategory; +} + +export interface ParentCategory { + id: number; + slug: string; +} diff --git a/server/.eslintrc.js b/server/.eslintrc.js index 13ff4b3e..c79b8c08 100644 --- a/server/.eslintrc.js +++ b/server/.eslintrc.js @@ -64,6 +64,7 @@ module.exports = { "@typescript-eslint/member-ordering": ["warn"], */ "@typescript-eslint/explicit-member-accessibility": ["warn"], "@typescript-eslint/no-base-to-string": "error", + "@typescript-eslint/no-unused-vars": "warn", }, ignorePatterns: [".eslintrc.js"], }; diff --git a/server/package.json b/server/package.json index 398584e0..2720b597 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "livestreamdvr-server", - "version": "1.7.5", + "version": "1.7.5.1", "description": "", "main": "index.ts", "scripts": { diff --git a/server/src/Controllers/Channels.ts b/server/src/Controllers/Channels.ts index 9d5a97f1..4c453a7c 100644 --- a/server/src/Controllers/Channels.ts +++ b/server/src/Controllers/Channels.ts @@ -4,6 +4,7 @@ import { KeyValue } from "@/Core/KeyValue"; import { LiveStreamDVR } from "@/Core/LiveStreamDVR"; import { LOGLEVEL, log } from "@/Core/Log"; import { BaseChannel } from "@/Core/Providers/Base/BaseChannel"; +import { KickAutomator } from "@/Core/Providers/Kick/KickAutomator"; import { KickChannel } from "@/Core/Providers/Kick/KickChannel"; import type { AutomatorMetadata } from "@/Core/Providers/Twitch/TwitchAutomator"; import { TwitchAutomator } from "@/Core/Providers/Twitch/TwitchAutomator"; @@ -21,7 +22,12 @@ import { validateRelativePath, } from "@/Helpers/Filesystem"; import { generateStreamerList } from "@/Helpers/StreamerList"; -import { isError, isTwitchChannel, isYouTubeChannel } from "@/Helpers/Types"; +import { + isError, + isKickChannel, + isTwitchChannel, + isYouTubeChannel, +} from "@/Helpers/Types"; import { VideoQuality } from "@/Zod/Base"; import type { ApiChannelResponse, @@ -694,7 +700,7 @@ export async function AddChannel( let api_channel_data; try { - api_channel_data = await KickChannel.getUserDataBySlug( + api_channel_data = await KickChannel.getChannelDataBySlug( channel_config.internalName ); } catch (error) { @@ -1599,6 +1605,49 @@ export async function ForceRecord( } as ApiErrorResponse); } + return; + } else if (isKickChannel(channel)) { + const streams = await channel.getStreams(); + + if (streams) { + log( + LOGLEVEL.INFO, + "route.channels.force_record", + `Forcing record for ${channel.internalName}` + ); + + const KA = new KickAutomator(); + KA.broadcaster_user_id = channel.internalId; + KA.broadcaster_user_name = channel.displayName; + KA.broadcaster_user_login = channel.internalName; + KA.channel = channel; + // KA.handle(mock_data, req); + + KeyValue.getInstance().set( + `kick.${KA.getUserID()}.vod.started_at`, + streams.created_at || new Date().toISOString() + ); + KeyValue.getInstance().set( + `kick.${KA.getUserID()}.vod.id`, + streams.id.toString() || "fake" + ); + + KA.download(); + + res.api(200, { + status: "OK", + message: req.t( + "route.channels.forced-recording-of-channel-channel-internalname", + [channel.internalName] + ), + }); + } else { + res.api(400, { + status: "ERROR", + message: req.t("route.channels.no-streams-found"), + } as ApiErrorResponse); + } + return; } } diff --git a/server/src/Controllers/KickAPI.ts b/server/src/Controllers/KickAPI.ts index 3ed9f686..071eb70b 100644 --- a/server/src/Controllers/KickAPI.ts +++ b/server/src/Controllers/KickAPI.ts @@ -1,8 +1,8 @@ import type { ApiErrorResponse } from "@common/Api/Api"; import type express from "express"; -import { GetChannel, GetUser } from "../Providers/Kick"; +import { GetChannel } from "../Providers/Kick"; -export async function KickAPIUser( +/* export async function KickAPIUser( req: express.Request, res: express.Response ): Promise { @@ -39,7 +39,7 @@ export async function KickAPIUser( data: user, status: "OK", }); -} +} */ export async function KickAPIChannel( req: express.Request, diff --git a/server/src/Core/Providers/Kick/KickAutomator.ts b/server/src/Core/Providers/Kick/KickAutomator.ts new file mode 100644 index 00000000..c9232c55 --- /dev/null +++ b/server/src/Core/Providers/Kick/KickAutomator.ts @@ -0,0 +1,10 @@ +import { BaseAutomator } from "../Base/BaseAutomator"; +import type { KickChannel } from "./KickChannel"; +import type { KickVOD } from "./KickVOD"; + +export class KickAutomator extends BaseAutomator { + public vod: KickVOD | undefined; + public channel: KickChannel | undefined; + public realm = "kick"; + // payload_eventsub: EventSubResponse | undefined; +} diff --git a/server/src/Core/Providers/Kick/KickChannel.ts b/server/src/Core/Providers/Kick/KickChannel.ts index 814d6249..65b3a1f9 100644 --- a/server/src/Core/Providers/Kick/KickChannel.ts +++ b/server/src/Core/Providers/Kick/KickChannel.ts @@ -2,6 +2,7 @@ import type { ApiKickChannel } from "@common/Api/Client"; import type { KickChannelConfig } from "@common/Config"; import type { Providers } from "@common/Defs"; import type { + KickChannelLivestream, KickChannel as KickChannelT, KickUser, } from "@common/KickAPI/Kick"; @@ -10,7 +11,7 @@ import { KeyValue } from "../../../Core/KeyValue"; import { LiveStreamDVR } from "../../../Core/LiveStreamDVR"; import { LOGLEVEL, log } from "../../../Core/Log"; import { isKickChannel } from "../../../Helpers/Types"; -import { GetChannel, GetStream, GetUser } from "../../../Providers/Kick"; +import { GetChannel, GetStream } from "../../../Providers/Kick"; import { BaseChannel } from "../Base/BaseChannel"; export class KickChannel extends BaseChannel { @@ -33,7 +34,7 @@ export class KickChannel extends BaseChannel { return this.user_data?.username ?? ""; } - public static async getUserDataBySlug( + /* public static async getUserDataBySlug( slug: string ): Promise { let data; @@ -45,7 +46,7 @@ export class KickChannel extends BaseChannel { } return data; - } + } */ /** * API does not seem to support looking up by id @@ -112,7 +113,9 @@ export class KickChannel extends BaseChannel { `Channel ${config.internalName} already exists in channels` ); - const data = await KickChannel.getUserDataBySlug(config.internalName); + const data = await KickChannel.getChannelDataBySlug( + config.internalName + ); if (!data) throw new Error( `Could not get channel data for channel slug: ${config.internalName}` @@ -219,7 +222,7 @@ export class KickChannel extends BaseChannel { public static async channelIdFromSlug( slug: string ): Promise { - const userData = await this.getUserDataBySlug(slug); + const userData = await this.getChannelDataBySlug(slug); return userData ? userData.id.toString() : false; } @@ -329,4 +332,8 @@ export class KickChannel extends BaseChannel { provider: "kick", }; } + + public async getStreams(): Promise { + return (await GetStream(this.internalName)) || false; + } } diff --git a/server/src/Core/Providers/Twitch/TwitchVOD.ts b/server/src/Core/Providers/Twitch/TwitchVOD.ts index 327f9499..ab3565d8 100644 --- a/server/src/Core/Providers/Twitch/TwitchVOD.ts +++ b/server/src/Core/Providers/Twitch/TwitchVOD.ts @@ -415,7 +415,7 @@ export class TwitchVOD extends BaseVOD { cmd.push("-o", captureFilename); // output file - cmd.push("--hls-segment-threads", "10"); + cmd.push("--stream-segment-threads", "10"); cmd.push("--url", videoUrl); // stream url @@ -708,7 +708,7 @@ export class TwitchVOD extends BaseVOD { cmd.push("-o", captureFilename); // output file - cmd.push("--hls-segment-threads", "10"); + cmd.push("--stream-segment-threads", "10"); cmd.push("--url", videoUrl); // stream url diff --git a/server/src/Providers/Kick.ts b/server/src/Providers/Kick.ts index 50397583..1618ba5c 100644 --- a/server/src/Providers/Kick.ts +++ b/server/src/Providers/Kick.ts @@ -4,21 +4,33 @@ import type { KickChannelLivestream, KickChannelLivestreamResponse, KickChannelVideo, - KickUser, } from "@common/KickAPI/Kick"; import axios, { isAxiosError } from "axios"; +import https from "https"; +// import { wrapper } from "axios-cookiejar-support"; +// import { CookieJar } from "tough-cookie"; -const baseURL = "https://kick.com/api/v2/"; +// const jar = new CookieJar(); +// const kickAxios = wrapper(axios.create({ jar })); -const cookies: Record = {}; +const kickAxios = axios.create({ + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + timeout: 10000, + }), +}); -function baseFetchOptions(): RequestInit { +// const baseURL = "https://kick.com/api/v2/"; + +// const kickCookies: Record = {}; + +/* function baseFetchOptions(): RequestInit { return { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:114.0) Gecko/20100101 Firefox/114.0", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*;q=0.8", - Cookie: Object.entries(cookies) + Cookie: Object.entries(kickCookies) .map(([key, value]) => `${key}=${value}`) .join("; "), }, @@ -28,7 +40,7 @@ function baseFetchOptions(): RequestInit { redirect: "follow", cache: "no-cache", }; -} +} */ let xsrfToken: string | undefined; @@ -40,24 +52,100 @@ interface FetchResponse { } // get xsrf token from cookie -export async function fetchXSFRToken(): Promise { - const request = await fetch("https://kick.com", { +export async function fetchXSFRToken(currentTry = 0): Promise { + /* const request = await fetch("https://kick.com", { ...baseFetchOptions(), method: "GET", - }); + }); */ + + let request; + + try { + request = await kickAxios.get("https://kick.com", { + method: "GET", + headers: { + // "Content-Type": "application/json", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Sec-Fetch-User": "?1", + "Accept-Language": "en-US", + Referer: "https://kick.com/", + }, + }); + } catch (error) { + if (isAxiosError(error)) { + log( + LOGLEVEL.ERROR, + "KickAPI.fetchXSFRToken", + `Error getting XSFR token: (${error.response?.config.url}): ${error.response?.data}`, + error + ); + + for (const header in error.response?.headers) { + log( + LOGLEVEL.DEBUG, + "KickAPI.fetchXSFRToken", + `${header}: ${error.response?.headers[header]}` + ); + } + + console.log(error.response?.data); + } else { + log( + LOGLEVEL.ERROR, + "KickAPI.fetchXSFRToken", + `Error getting XSFR token: ${error}` + ); + } + // throw error; + + /* if (currentTry < 3) { + await Sleep(5000); + log(LOGLEVEL.DEBUG, "KickAPI.fetchXSFRToken", "Retrying"); + return fetchXSFRToken(currentTry + 1); + } + */ + + return false; + } + + if (request == undefined) { + log(LOGLEVEL.ERROR, "KickAPI.fetchXSFRToken", "Request is undefined"); + return false; + } + + // const cookies = request.headers.get("set-cookie"); + + const cookies = request.headers["set-cookie"]; - const cookies = request.headers.get("set-cookie"); if (!cookies) { throw new Error("No cookies"); } - const xsrfCookie = cookies - .split(";") - .find((cookie) => cookie.includes("XSRF-TOKEN")); + + // remove all cookies to make sure we don't have any old ones + /* Object.keys(kickCookies).forEach((key) => { + delete kickCookies[key]; + }); + + cookies.forEach((cookie: string) => { + const [key, value] = cookie.split(";")[0].split("="); + kickCookies[key] = value; + }); */ + + const xsrfCookie = cookies.find( + (cookie) => + cookie.includes("XSRF-TOKEN") || cookie.includes("xsrf-token") + ); + if (!xsrfCookie) { console.log(cookies); throw new Error("No XSRF-TOKEN cookie"); } + xsrfToken = xsrfCookie.split("=")[1]; + + console.log(`Got XSRF-TOKEN: ${xsrfToken}`); + return true; } @@ -72,15 +160,26 @@ export async function getRequest(url: string): Promise> { log(LOGLEVEL.DEBUG, "KickAPI.getRequest", `Getting ${url}`); + /* if (!xsrfToken) { + if (!(await fetchXSFRToken())) { + throw new Error("Error getting XSRF token"); + } + } */ + let request; try { - request = await axios.get(url, { + request = await kickAxios.get(url, { method: "GET", headers: { "Content-Type": "application/json", "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; rv:114.0) Gecko/20100101 Firefox/114.0", + "Mozilla/5.0 (Windows NT 10.0; rv:114.0) Gecko/20100101 Firefox/133.0", + Accept: "application/json", + "Accept-Language": "en-US", + Referer: "https://kick.com/", + Authorization: `Bearer ${xsrfToken}`, + "Sec-Fetch-User": "?1", }, }); } catch (error) { @@ -88,7 +187,7 @@ export async function getRequest(url: string): Promise> { log( LOGLEVEL.ERROR, "KickAPI.getRequest", - `Error getting data (${error.response?.config.url}): ${error.response?.data}`, + `Error getting data from '${url}' : (${error.response?.config.url}): ${error.response?.data}`, error ); if ( @@ -102,6 +201,13 @@ export async function getRequest(url: string): Promise> { ); throw new Error("Cloudflare challenge"); } + } else { + log( + LOGLEVEL.ERROR, + "KickAPI.getRequest", + `Error getting data from '${url}' : ${error}`, + error + ); } throw error; @@ -117,14 +223,18 @@ export async function getRequest(url: string): Promise> { }; } -export async function GetUser(username: string): Promise { +/* export async function GetUser(username: string): Promise { log(LOGLEVEL.DEBUG, "KickAPI.GetUser", `Getting user ${username}`); let response; try { // response = await axiosInstance.get(`users/${username}`); response = await getRequest(`users/${username}`); } catch (error) { - log(LOGLEVEL.ERROR, "KickAPI.GetUser", `Error getting data: ${error}`); + log( + LOGLEVEL.ERROR, + "KickAPI.GetUser", + `Error getting user data: ${error}` + ); throw error; } @@ -139,7 +249,7 @@ export async function GetUser(username: string): Promise { `Got user ${response.data.username}` ); return response.data; -} +} */ export async function GetChannel( username: string @@ -206,7 +316,7 @@ export async function GetStream( // const response = await request; // return response.data ? response.data.data : undefined; const response = await getRequest( - `channels/${username}/livestream` + `https://kick.com/api/v2/channels/${username}/livestream` ); return response.data ? response.data.data : undefined; } diff --git a/server/src/Routes/Api.ts b/server/src/Routes/Api.ts index fe383c9e..2ad5ec42 100644 --- a/server/src/Routes/Api.ts +++ b/server/src/Routes/Api.ts @@ -164,7 +164,7 @@ router.get( ); router.post("/youtubeapi/channelid", AuthAdmin, YouTubeAPI.YouTubeAPIChannelID); -router.get("/kickapi/users/:slug", AuthAdmin, KickAPI.KickAPIUser); +// router.get("/kickapi/users/:slug", AuthAdmin, KickAPI.KickAPIUser); router.get("/kickapi/channels/:slug", AuthAdmin, KickAPI.KickAPIChannel); router.get("/keyvalue", AuthAdmin, KeyValue.GetAllKeyValues);