Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions server/src/Core/Providers/Twitch/TwitchAutomator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { BaseConfigCacheFolder, BaseConfigDataFolder } from "../../BaseConfig";
import { ClientBroker } from "../../ClientBroker";
import { Config } from "../../Config";
import { KeyValue } from "../../KeyValue";
import { LOGLEVEL, censoredLogWords, log } from "../../Log";
import { LOGLEVEL, log } from "../../Log";
import { Webhook } from "../../Webhook";
import { BaseAutomator } from "../Base/BaseAutomator";
import { TwitchChannel } from "./TwitchChannel";
Expand Down Expand Up @@ -649,22 +649,21 @@ export class TwitchAutomator extends BaseAutomator {
}
}

private sessionToken?: string;

public async captureVideo(): Promise<boolean> {
// validate before streamlink runs, otherwise a bad token just shows up as "no playable streams"
this.sessionToken = await TwitchHelper.getSessionToken();
return super.captureVideo();
}

public providerArgs(): string[] {
const cmd = [];

if (
fs.existsSync(
path.join(BaseConfigDataFolder.config, "twitch_oauth.txt")
)
) {
const token = fs
.readFileSync(
path.join(BaseConfigDataFolder.config, "twitch_oauth.txt"),
"utf8"
)
.trim();
censoredLogWords.add(token.toString());
cmd.push(`--twitch-api-header=Authorization=OAuth ${token}`);
if (this.sessionToken) {
cmd.push(
`--twitch-api-header=Authorization=OAuth ${this.sessionToken}`
);
}

// enable low latency mode, probably not a good idea without testing
Expand Down
25 changes: 9 additions & 16 deletions server/src/Core/Providers/Twitch/TwitchVOD.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import { FFmpegMetadata } from "../../FFmpegMetadata";
import { Helper } from "../../Helper";
import { Job } from "../../Job";
import { LiveStreamDVR } from "../../LiveStreamDVR";
import { LOGLEVEL, censoredLogWords, log } from "../../Log";
import { LOGLEVEL, log } from "../../Log";
import { Webhook } from "../../Webhook";
import { BaseVOD } from "../Base/BaseVOD";
import { TwitchChannel } from "./TwitchChannel";
Expand Down Expand Up @@ -426,31 +426,24 @@ export class TwitchVOD extends BaseVOD {
*/
if (Config.getInstance().cfg("twitch.voddownload.auth_enabled")) {
if (
fs.existsSync(
!fs.existsSync(
path.join(
BaseConfigDataFolder.config,
"twitch_oauth.txt"
)
)
) {
const token = fs
.readFileSync(
path.join(
BaseConfigDataFolder.config,
"twitch_oauth.txt"
),
"utf8"
)
.trim();
censoredLogWords.add(token.toString());
cmd.push(
`--twitch-api-header=Authorization=OAuth ${token}`
);
} else {
throw new Error(
"Twitch OAuth token not found but auth_enabled is true!"
);
}
// undefined = expired/invalid, already logged + notified; download unauthenticated
const token = await TwitchHelper.getSessionToken();
if (token) {
cmd.push(
`--twitch-api-header=Authorization=OAuth ${token}`
);
}
}

// logging level
Expand Down
5 changes: 5 additions & 0 deletions server/src/Core/Scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export class Scheduler {
void TwitchHelper.validateOAuth();
});

// warn about an expired twitch_oauth.txt before the next stream starts (notifies on failure)
this.schedule("validate_session_token", "30 */6 * * *", () => {
void TwitchHelper.getSessionToken();
});

// refresh oauth token every 29 days
// this.schedule("refresh_oauth", "0 0 */29 * *", () => {
// // if (Config.getInstance().cfg("twitchapi.auth_type") == "app") return;
Expand Down
54 changes: 54 additions & 0 deletions server/src/Providers/Twitch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseConfigCacheFolder, BaseConfigDataFolder } from "@/Core/BaseConfig";
import { ClientBroker } from "@/Core/ClientBroker";
import { Config } from "@/Core/Config";
import { Helper } from "@/Core/Helper";
import { KeyValue } from "@/Core/KeyValue";
Expand Down Expand Up @@ -37,6 +38,7 @@ import type { Axios, AxiosRequestConfig, AxiosResponse } from "axios";
import axios from "axios";
import chalk from "chalk";
import { format, parseJSON } from "date-fns";
import { t } from "i18next";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
Expand Down Expand Up @@ -1505,6 +1507,58 @@ export class TwitchHelper {
}
}

/**
* Reads the streamlink session token from twitch_oauth.txt and validates it against Twitch.
* Separate from the app token (accessToken), don't mix them up.
* @returns the token if valid, undefined if missing or rejected by Twitch
*/
public static async getSessionToken(): Promise<string | undefined> {
const file = path.join(BaseConfigDataFolder.config, "twitch_oauth.txt");
if (!fs.existsSync(file)) return undefined;

const token = fs.readFileSync(file, "utf8").trim();
if (!token) return undefined;
censoredLogWords.add(token);

try {
await axios.get<TwitchAuthTokenValidationResponse>(
"https://id.twitch.tv/oauth2/validate",
{ headers: { Authorization: `OAuth ${token}` } }
);
return token;
} catch (error) {
// ponytail: only a 401 means the token is bad; network hiccups keep using it
if (
!axios.isAxiosError(error) ||
error.response?.status !== 401
) {
log(
LOGLEVEL.WARNING,
"tw.helper.getSessionToken",
`Could not validate twitch_oauth.txt, using it anyway: ${
(error as Error).message
}`
);
return token;
}
}

log(
LOGLEVEL.ERROR,
"tw.helper.getSessionToken",
"Twitch session token in twitch_oauth.txt is expired or invalid, capturing without it (no ad-skip / sub-only access). Replace it with a fresh auth-token cookie."
);
ClientBroker.notify(
"Twitch token invalid",
t(
"notify.twitch-oauth-token-expired-or-invalid-check-config-twitch-oauth-txt"
).toString(),
"",
"system"
);
return undefined;
}

public static async checkTTVLolPlugin() {
const bin = Helper.path_streamlink();

Expand Down
1 change: 1 addition & 0 deletions server/src/Translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"this-broadcaster_user_login-has-gone-offline": "{{0}} has gone offline!",
"was-streaming-for-formatdistancetonow-this-channel-latest_vod-started_at": "Was streaming for {{0}}.",
"capturing-of-basename-failed-no-streams-available-nis-there-a-configuration-error": "Capturing of {{0}} failed, no streams available!\\nIs there a configuration error?",
"twitch-oauth-token-expired-or-invalid-check-config-twitch-oauth-txt": "Twitch session token in config/twitch_oauth.txt is expired or invalid.\\nCapturing without it until you replace it.",
"live-non-capturing-channel-this-getlogin-changed-status": "Live non-capturing channel {{0}} changed status",
"offline-channel-this-getlogin-changed-status": "Offline channel {{0}} changed status"
},
Expand Down
Loading