#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "twitch/app.hpp" #include "twitch/auth.hpp" #include "twitch/browse.hpp" #include "twitch/chat.hpp" #include "twitch/curl/curl_http.hpp" #include "twitch/curl/curl_websocket.hpp" #include "twitch/error.hpp" #include "twitch/gql.hpp" #include "twitch/helix.hpp" #include "twitch/hls.hpp" #include "twitch/login.hpp" #include "twitch/playback.hpp" #include "twitch/streams.hpp" #include "twitch/videos.hpp" namespace { std::string lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return s; } constexpr const char* kEllipsis = "\xE2\x80\xA6"; constexpr int kTopCandidates = 5; int usage() { std::fprintf(stderr, "usage:\n" " twitch-cli top [count]\n" " twitch-cli play [quality|best|worst|source] [playerType]\n" " twitch-cli chat \n" " twitch-cli resolve [channel]\n" " twitch-cli vod \n" " twitch-cli clip \n" " twitch-cli categories [count]\n" " twitch-cli category [count]\n" " twitch-cli videos [archive|highlight|upload]\n" " twitch-cli clips [day|week|month|all]\n" " twitch-cli search \n" " twitch-cli channel \n" " twitch-cli login\n" " twitch-cli me\n" " twitch-cli following\n" " twitch-cli say \n" " twitch-cli logout\n"); return 2; } // ---- auth.json: ~/.config/ps5-twitch/auth.json (XDG_CONFIG_HOME respected) ---- // // Saved as the serializeLogin() JSON, directory mode 0700, file mode 0600, // written atomically (temp file in the same directory, then rename). Never // printed: callers only ever show a login/display name, not a token. std::string xdgConfigHome() { if (const char* xdg = std::getenv("XDG_CONFIG_HOME"); xdg != nullptr && *xdg != '\0') return xdg; const char* home = std::getenv("HOME"); return std::string(home != nullptr ? home : ".") + "/.config"; } std::string authDir() { return xdgConfigHome() + "/ps5-twitch"; } std::string authPath() { return authDir() + "/auth.json"; } // Creates authDir() (and, best effort, its parent) with mode 0700. Fine if // either already exists; chmod runs regardless so an existing directory // with looser permissions still ends up at 0700. void ensureAuthDir() { ::mkdir(xdgConfigHome().c_str(), 0755); // parent; ignore the result, may pre-exist const std::string dir = authDir(); if (::mkdir(dir.c_str(), 0700) != 0 && errno != EEXIST) throw std::runtime_error("could not create " + dir + ": " + std::strerror(errno)); ::chmod(dir.c_str(), 0700); } std::optional readFile(const std::string& path) { std::FILE* f = std::fopen(path.c_str(), "rb"); if (f == nullptr) return std::nullopt; std::string data; char buf[4096]; size_t n; while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) data.append(buf, n); std::fclose(f); return data; } // Writes `data` to `path` atomically: a temp file in the same directory // (so the rename can't cross filesystems), chmod'd to `mode` to defeat the // umask, then renamed over `path`. void writeFileAtomic(const std::string& path, const std::string& data, mode_t mode) { const std::string tmp = path + ".tmp." + std::to_string(static_cast(::getpid())); const int fd = ::open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC, mode); if (fd < 0) throw std::runtime_error("could not open " + tmp + ": " + std::strerror(errno)); size_t written = 0; while (written < data.size()) { const ssize_t n = ::write(fd, data.data() + written, data.size() - written); if (n < 0) { ::close(fd); ::unlink(tmp.c_str()); throw std::runtime_error(std::string("write failed: ") + std::strerror(errno)); } written += static_cast(n); } ::close(fd); ::chmod(tmp.c_str(), mode); if (::rename(tmp.c_str(), path.c_str()) != 0) { ::unlink(tmp.c_str()); throw std::runtime_error(std::string("rename failed: ") + std::strerror(errno)); } } std::optional readLoginFile() { std::optional data = readFile(authPath()); if (!data) return std::nullopt; return twitch::parseLogin(*data); } void writeLoginFile(const twitch::SavedLogin& login) { ensureAuthDir(); writeFileAtomic(authPath(), twitch::serializeLogin(login), 0600); } void removeLoginFile() { ::unlink(authPath().c_str()); } long long nowMs() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) .count(); } std::string joinSpace(const std::vector& items) { std::string out; for (size_t i = 0; i < items.size(); ++i) { if (i != 0) out += ' '; out += items[i]; } return out; } constexpr long long kRefreshWindowMs = 5 * 60 * 1000; // refresh within 5 minutes of expiry // Loads the saved login, refreshing it first (and saving the rotated tokens // right away - a refresh token works only once) when the access token // expires within kRefreshWindowMs. Used by me/following/say so none of them // has to duplicate the refresh dance. Throws UnauthorizedError, with the // same message, whether there's no saved login at all or the saved refresh // token is dead (in which case the stale file is removed too) - callers // don't need to tell those apart, both mean "run twitch-cli login". twitch::SavedLogin loadAndRefresh(twitch::HttpClient& http) { std::optional saved = readLoginFile(); if (!saved) throw twitch::UnauthorizedError("not logged in - run 'twitch-cli login'"); const long long now = nowMs(); if (saved->tokens.expiresAtMs - now < kRefreshWindowMs) { twitch::AuthClient auth(http, twitch::kAppClientId); try { saved->tokens = auth.refresh(saved->tokens.refresh, now); writeLoginFile(*saved); } catch (const twitch::UnauthorizedError&) { removeLoginFile(); throw twitch::UnauthorizedError("not logged in - run 'twitch-cli login'"); } } return *saved; } int cmdTop(int count) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); for (const auto& s : twitch::topStreams(gql, count)) { std::printf("%8d %-20s %-24s %s\n", s.viewers, s.login.c_str(), s.gameName.empty() ? "-" : s.gameName.c_str(), s.title.c_str()); } return 0; } int cmdCategories(int count) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); for (const auto& c : twitch::topCategories(gql, count)) { std::printf("%8d %-30s %s\n", c.viewers, c.name.c_str(), c.boxArtUrl.c_str()); } return 0; } int cmdCategory(const std::string& name, int count) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); for (const auto& s : twitch::categoryStreams(gql, name, count)) { std::printf("%8d %-20s %s\n", s.viewers, s.login.c_str(), s.title.c_str()); } return 0; } int cmdSearch(const std::string& query) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); std::printf("channels:\n"); for (const auto& h : twitch::searchChannels(gql, query, 10)) { std::printf(" %8d %-20s %s\n", h.liveViewers, h.login.c_str(), h.displayName.c_str()); } std::printf("categories:\n"); for (const auto& c : twitch::searchCategories(gql, query, 10)) { std::printf(" %8d %-30s %s\n", c.viewers, c.name.c_str(), c.boxArtUrl.c_str()); } return 0; } int cmdChannel(const std::string& login) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); twitch::ChannelInfo c = twitch::fetchChannel(gql, login); std::printf("channel: %s (%s)\n", c.displayName.c_str(), c.login.c_str()); std::printf("followers: %d\n", c.followers); std::printf("description: %s\n", c.description.empty() ? "-" : c.description.c_str()); std::printf("avatar: %s\n", c.profileImageUrl.c_str()); const long long now = static_cast(std::time(nullptr)); if (c.live) { std::printf("live: yes, %d viewers, playing %s\n", c.viewers, c.gameName.empty() ? "-" : c.gameName.c_str()); std::printf("title: %s\n", c.streamTitle.c_str()); std::printf("started: %s (%s)\n", c.streamStartedAt.empty() ? "-" : c.streamStartedAt.c_str(), twitch::uptimeText(c.streamStartedAt, now).c_str()); const std::string lang = twitch::languageName(c.language); std::printf("language: %s%s%s\n", c.language.empty() ? "-" : c.language.c_str(), lang.empty() ? "" : " = ", lang.c_str()); std::string tags; for (const std::string& t : c.tags) tags += (tags.empty() ? "" : ", ") + t; std::printf("tags: %s\n", tags.empty() ? "-" : tags.c_str()); } else { std::printf("live: no\n"); } const std::vector next = twitch::fetchSchedule(gql, login, now, 3); if (next.empty()) std::printf("schedule: -\n"); for (const twitch::ScheduleSegment& s : next) std::printf("schedule: %s %-14s %s%s%s\n", s.startAt.c_str(), twitch::startsInText(s.startAt, now).c_str(), s.title.empty() ? "-" : s.title.c_str(), s.category.empty() ? "" : " / ", s.category.c_str()); return 0; } int cmdPlay(const std::string& channel, const std::string& quality, const std::string& playerType) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); auto token = twitch::fetchStreamToken(gql, channel, playerType); std::random_device rd; std::string master = twitch::usherStreamUrl(channel, token, rd() % 1000000); auto variants = twitch::fetchVariants(http, master); std::printf("qualities:"); for (const auto& v : variants) { std::printf(" %s", v.label.c_str()); if (v.name != v.label) std::printf(" [%s]", v.name.c_str()); } std::printf("\n"); twitch::Variant chosen = twitch::pickVariant(variants, quality); std::printf("playing %s (%dx%d, %.0f fps, %d bps)\n", chosen.label.c_str(), chosen.width, chosen.height, chosen.fps, chosen.bandwidth); twitch::HttpRequest req; req.url = chosen.uri; auto res = http.send(req); if (res.status != 200) throw twitch::ApiError("media playlist HTTP " + std::to_string(res.status)); auto media = twitch::parseMedia(res.body); std::printf("target duration %.1f s, %zu segments, ads: %s\n", media.targetDuration, media.segments.size(), media.containsAds() ? "YES" : "no"); for (const auto& s : media.segments) { std::printf(" #%lld %.3fs %s%s\n", s.sequence, s.duration, s.ad ? "[AD] " : "", s.title.c_str()); } return 0; } // Host-side mirror of the PS5 launcher's resolve worker (resolve() in // app/projects/evoplayer/twitch/src/evo_twitch_launcher.cpp): same sequence, // normalizeLogin -> fetchStreamToken(gql, login, "embed") -> // usherStreamUrl -> fetchVariants -> pickVariant(variants, "best"). With no // channel it walks topStreams(gql, 5) and tries candidates in order, // skipping the same three errors the launcher skips and stopping on // anything else (including NetworkError). The one addition over the // launcher is the ad check: it fetches the chosen variant's media playlist // once, with the same http client, purely to report ads. Never prints the // master or media playlist URL: both carry signed tokens. int cmdResolve(const std::string& channelArg) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); std::random_device rd; auto resolveAndPrint = [&](const std::string& login, const std::string& displayName, const std::string& title) { const twitch::AccessToken token = twitch::fetchStreamToken(gql, login, "embed"); const std::string master = twitch::usherStreamUrl(login, token, rd() % 1000000); const std::vector variants = twitch::fetchVariants(http, master); const twitch::Variant chosen = twitch::pickVariant(variants, "best"); twitch::HttpRequest req; req.url = chosen.uri; const twitch::HttpResponse res = http.send(req); if (res.status != 200) throw twitch::HttpStatusError("media playlist HTTP " + std::to_string(res.status), res.status); const bool ads = twitch::parseMedia(res.body).containsAds(); const std::string& name = displayName.empty() ? login : displayName; std::printf("channel: %s (%s)\n", name.c_str(), login.c_str()); std::printf("title: %s\n", title.empty() ? "-" : title.c_str()); std::printf("quality: %s", chosen.label.c_str()); if (chosen.name != chosen.label) std::printf(" [%s]", chosen.name.c_str()); std::printf(" %dx%d %.0f fps\n", chosen.width, chosen.height, chosen.fps); std::printf("ads: %s\n", ads ? "YES" : "no"); }; // A channel on the command line: that one or nothing, same as the // launcher's channel-file path. if (!channelArg.empty()) { const std::string login = twitch::normalizeLogin(channelArg); resolveAndPrint(login, "", ""); return 0; } // Otherwise the most-watched stream that actually resolves. const std::vector top = twitch::topStreams(gql, kTopCandidates); if (top.empty()) throw twitch::NotFoundError("no live streams"); std::string lastError; for (const twitch::StreamInfo& s : top) { std::fprintf(stderr, "trying %s %s", s.login.c_str(), kEllipsis); std::fflush(stderr); try { const std::string login = twitch::normalizeLogin(s.login); resolveAndPrint(login, s.displayName, s.title); std::fprintf(stderr, "\n"); return 0; } catch (const twitch::NotFoundError& e) { lastError = e.what(); } catch (const twitch::OfflineError& e) { lastError = e.what(); } catch (const twitch::HttpStatusError& e) { lastError = e.what(); } std::fprintf(stderr, " skipped: %s\n", lastError.c_str()); } throw twitch::NotFoundError("none of the top streams would open (" + lastError + ")"); } twitch::VideoType parseVideoType(const std::string& s) { if (s.empty() || s == "archive") return twitch::VideoType::Archive; if (s == "highlight") return twitch::VideoType::Highlight; if (s == "upload") return twitch::VideoType::Upload; throw twitch::ApiError("unknown video type: " + s); } twitch::ClipPeriod parseClipPeriod(const std::string& s) { if (s.empty() || s == "day") return twitch::ClipPeriod::Day; if (s == "week") return twitch::ClipPeriod::Week; if (s == "month") return twitch::ClipPeriod::Month; if (s == "all") return twitch::ClipPeriod::All; throw twitch::ApiError("unknown clip period: " + s); } int cmdVideos(const std::string& login, const std::string& typeArg) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); const twitch::VideoType type = parseVideoType(typeArg); const twitch::VideoPage page = twitch::fetchChannelVideos(gql, login, type, 20); const long long now = nowMs() / 1000; for (const auto& v : page.cards) { std::printf("%-12s %6d views %5ds %-14s %-20s %s\n", v.id.c_str(), v.viewCount, v.lengthSeconds, twitch::ageText(v.publishedAt, now).c_str(), v.gameName.empty() ? "-" : v.gameName.c_str(), v.title.c_str()); } std::printf("next: %s\n", page.nextCursor.empty() ? "-" : page.nextCursor.c_str()); return 0; } int cmdClips(const std::string& login, const std::string& periodArg) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); const twitch::ClipPeriod period = parseClipPeriod(periodArg); const twitch::ClipPage page = twitch::fetchChannelClips(gql, login, period, 20); const long long now = nowMs() / 1000; for (const auto& c : page.cards) { std::printf("%-26s %6d views %4ds %-14s %-16s %s\n", c.slug.c_str(), c.viewCount, c.durationSeconds, twitch::ageText(c.createdAt, now).c_str(), c.curatorName.c_str(), c.title.c_str()); } std::printf("next: %s\n", page.nextCursor.empty() ? "-" : page.nextCursor.c_str()); return 0; } int cmdVod(const std::string& videoId) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); const std::vector variants = twitch::resolveVod(gql, http, videoId); std::printf("qualities:"); for (const auto& v : variants) { std::printf(" %s", v.label.c_str()); if (v.name != v.label) std::printf(" [%s]", v.name.c_str()); } std::printf("\n"); const twitch::Variant chosen = twitch::pickQuality(variants, "best"); std::printf("best: %s (%dx%d, %.0f fps)\n", chosen.label.c_str(), chosen.width, chosen.height, chosen.fps); return 0; } int cmdClip(const std::string& slug) { twitch::CurlHttpClient http; twitch::GqlClient gql(http); const std::vector variants = twitch::clipVariants(gql, slug); for (const auto& v : variants) { std::printf("%-6s %3dfps %s\n", v.quality.c_str(), v.fps, v.url.c_str()); } return 0; } int cmdChat(const std::string& channel) { twitch::CurlWebSocket ws; twitch::ChatClient chat(ws); std::random_device rd; chat.connectAnonymous(10000 + rd() % 90000); chat.join(channel); std::fprintf(stderr, "joined #%s, Ctrl+C to quit\n", channel.c_str()); while (true) { for (const auto& m : chat.poll(1000)) { if (m.action) { std::printf("* %s %s\n", m.displayName.c_str(), m.text.c_str()); } else { std::printf("%s: %s\n", m.displayName.c_str(), m.text.c_str()); } std::fflush(stdout); } } } // Device-code login: prints the code and URL, then polls until the owner // approves (or denies, or lets it expire) on their phone. Live run only - // the coordinator runs this one with the owner at the keyboard. int cmdLogin() { twitch::CurlHttpClient http; twitch::AuthClient auth(http, twitch::kAppClientId); const twitch::DeviceCode code = auth.startDeviceLogin(twitch::kAppScopes); // Plain, no hyphen: typing "ABCD-EFGH" by hand on twitch.tv/activate may // not be accepted. std::printf("Open %s and approve (code %s)\n", code.verificationUri.c_str(), code.userCode.c_str()); std::fflush(stdout); int intervalSec = code.intervalSec > 0 ? code.intervalSec : 5; const long long deadlineMs = nowMs() + static_cast(code.expiresInSec) * 1000; while (true) { std::this_thread::sleep_for(std::chrono::seconds(intervalSec)); const long long now = nowMs(); if (now >= deadlineMs) throw twitch::ApiError("login code expired"); twitch::Tokens tokens; const twitch::PollResult result = auth.pollDeviceLogin(code, twitch::kAppScopes, now, &tokens); if (result == twitch::PollResult::Pending) continue; if (result == twitch::PollResult::SlowDown) { intervalSec += 5; continue; } if (result == twitch::PollResult::Expired) throw twitch::ApiError("login code expired"); if (result == twitch::PollResult::Denied) throw twitch::ApiError("login denied"); // Done: validate (Twitch requires this right after getting a token) and // ask Helix for the display name validate doesn't carry. const twitch::Identity identity = auth.validate(tokens.access); twitch::HelixClient helix(http, twitch::kAppClientId, tokens.access); const twitch::HelixUser me = helix.me(); twitch::SavedLogin saved; saved.tokens = tokens; saved.userId = identity.userId.empty() ? me.id : identity.userId; saved.login = identity.login.empty() ? me.login : identity.login; saved.displayName = me.displayName; writeLoginFile(saved); const std::string& shown = saved.displayName.empty() ? saved.login : saved.displayName; std::printf("Logged in as %s (%s)\n", shown.c_str(), saved.login.c_str()); return 0; } } int cmdMe() { twitch::CurlHttpClient http; const twitch::SavedLogin saved = loadAndRefresh(http); twitch::AuthClient auth(http, twitch::kAppClientId); const twitch::Identity identity = auth.validate(saved.tokens.access); std::printf("login: %s\n", identity.login.c_str()); std::printf("user id: %s\n", identity.userId.c_str()); std::printf("scopes: %s\n", joinSpace(identity.scopes).c_str()); std::printf("expires: %d min\n", identity.expiresInSec / 60); return 0; } int cmdFollowing() { twitch::CurlHttpClient http; const twitch::SavedLogin saved = loadAndRefresh(http); twitch::HelixClient helix(http, twitch::kAppClientId, saved.tokens.access); const long long nowSec = nowMs() / 1000; const std::vector live = helix.followedStreams(saved.userId, nowSec); const std::vector all = helix.followedChannels(saved.userId); std::unordered_set liveIds; for (const auto& s : live) liveIds.insert(s.userId); std::printf("live now (%zu):\n", live.size()); for (const auto& s : live) { const std::string& name = s.displayName.empty() ? s.login : s.displayName; std::printf(" %-20s %6d viewers %-20s %s\n", name.c_str(), s.viewers, s.game.empty() ? "-" : s.game.c_str(), s.title.c_str()); } std::vector offlineNames; for (const auto& c : all) { if (liveIds.count(c.userId)) continue; offlineNames.push_back(c.displayName.empty() ? c.login : c.displayName); } std::printf("offline (%zu):\n", offlineNames.size()); for (const auto& name : offlineNames) std::printf(" %s\n", name.c_str()); return 0; } int cmdSay(const std::string& channel, const std::string& text) { twitch::CurlHttpClient http; const twitch::SavedLogin saved = loadAndRefresh(http); twitch::HelixClient helix(http, twitch::kAppClientId, saved.tokens.access); const std::string broadcasterId = helix.userIdForLogin(channel); const twitch::SendResult result = helix.sendChat(broadcasterId, saved.userId, text); if (result.sent) { std::printf("sent\n"); } else { std::printf("not sent: %s\n", result.dropReason.empty() ? "unknown reason" : result.dropReason.c_str()); } return 0; } int cmdLogout() { const std::optional saved = readLoginFile(); if (!saved) { std::printf("not logged in\n"); return 0; } twitch::CurlHttpClient http; twitch::AuthClient auth(http, twitch::kAppClientId); auth.revoke(saved->tokens.access); // best effort, never throws removeLoginFile(); std::printf("logged out\n"); return 0; } } // namespace int main(int argc, char** argv) { if (argc < 2) return usage(); twitch::curlGlobalInit(); std::string cmd = argv[1]; try { if (cmd == "top") return cmdTop(argc > 2 ? std::atoi(argv[2]) : 10); if (cmd == "play" && argc >= 3) return cmdPlay(lower(argv[2]), argc > 3 ? argv[3] : "best", argc > 4 ? argv[4] : "site"); if (cmd == "chat" && argc >= 3) return cmdChat(lower(argv[2])); if (cmd == "resolve") return cmdResolve(argc > 2 ? argv[2] : ""); if (cmd == "vod" && argc >= 3) return cmdVod(argv[2]); if (cmd == "clip" && argc >= 3) return cmdClip(argv[2]); if (cmd == "categories") return cmdCategories(argc > 2 ? std::atoi(argv[2]) : 10); if (cmd == "category" && argc >= 3) return cmdCategory(argv[2], argc > 3 ? std::atoi(argv[3]) : 10); if (cmd == "videos" && argc >= 3) return cmdVideos(lower(argv[2]), argc > 3 ? argv[3] : ""); if (cmd == "clips" && argc >= 3) return cmdClips(lower(argv[2]), argc > 3 ? argv[3] : ""); if (cmd == "search" && argc >= 3) { std::string query = argv[2]; for (int i = 3; i < argc; ++i) { query += ' '; query += argv[i]; } return cmdSearch(query); } if (cmd == "channel" && argc >= 3) return cmdChannel(argv[2]); if (cmd == "login") return cmdLogin(); if (cmd == "me") return cmdMe(); if (cmd == "following") return cmdFollowing(); if (cmd == "say" && argc >= 4) { std::string text = argv[3]; for (int i = 4; i < argc; ++i) { text += ' '; text += argv[i]; } return cmdSay(argv[2], text); } if (cmd == "logout") return cmdLogout(); return usage(); } catch (const twitch::TwitchError& e) { std::fprintf(stderr, "error: %s\n", e.what()); return 1; } catch (const std::exception& e) { std::fprintf(stderr, "unexpected error: %s\n", e.what()); return 1; } }