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
17 changes: 17 additions & 0 deletions src/components/ProviderCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ const PLATFORM_COLORS: Record<ProviderId, string> = {
CONNECT
</a>
<template v-else>
<a
v-if="provider.dashboardUrl"
class="action dashboard"
:href="provider.dashboardUrl"
target="_blank"
rel="noopener"
>
DASHBOARD ↗
</a>
<a
v-if="provider.needsReauth"
class="action connect"
Expand Down Expand Up @@ -185,6 +194,14 @@ const PLATFORM_COLORS: Record<ProviderId, string> = {
color: var(--accent-hover);
}

.action.dashboard {
color: var(--text-dim);
}

.action.dashboard:hover {
color: var(--accent);
}

.action.disconnect {
color: var(--text-dim);
}
Expand Down
5 changes: 5 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export interface ProviderState {
isLive: boolean
/** Category / game, where the platform exposes one. */
category: Category | null
/**
* Deep link to the platform's own broadcast console. Null when disconnected,
* or when the platform keys the URL by a channel the account does not have.
*/
dashboardUrl: string | null
/**
* Connected, but the stored token predates the write scopes — saves would
* fail until the user reconnects.
Expand Down
20 changes: 20 additions & 0 deletions src/views/__tests__/DashboardView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function providerState(overrides: Partial<ProviderState> = {}): ProviderState {
streamTitle: null,
isLive: false,
category: null,
dashboardUrl: null,
needsReauth: false,
error: null,
...overrides,
Expand Down Expand Up @@ -91,6 +92,25 @@ describe('DashboardView', () => {
expect(wrapper.find('a[href="/auth/twitch/start"]').exists()).toBe(false)
})

it('links a connected card to that platform’s own console', async () => {
stubApi([
providerState({
connected: true,
displayName: 'Streamer',
dashboardUrl: 'https://dashboard.twitch.tv/u/streamer_tv/stream-manager',
}),
providerState({ id: 'kick', label: 'Kick', connected: true }),
])
const wrapper = await mountView()

const link = wrapper.get('a.dashboard')
expect(link.attributes('href')).toBe('https://dashboard.twitch.tv/u/streamer_tv/stream-manager')
expect(link.attributes('target')).toBe('_blank')
expect(link.text()).toContain('DASHBOARD')
// A platform that reported no url — like Kick here — offers no link.
expect(wrapper.findAll('a.dashboard')).toHaveLength(1)
})

it('pre-fills the title input with the current shared title', async () => {
stubApi([providerState({ connected: true, streamTitle: 'Current title' })])
const wrapper = await mountView()
Expand Down
24 changes: 21 additions & 3 deletions src/worker/__tests__/providers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ function routeFetch(routes: Record<string, unknown>) {
describe('twitch fetchStatus', () => {
it('reads the title from the channel and liveness from /streams', async () => {
routeFetch({
'https://api.twitch.tv/helix/users': { data: [{ id: '42', display_name: 'Streamer' }] },
'https://api.twitch.tv/helix/users': {
// The login is deliberately not the display name lowercased: the
// dashboard url must follow the login.
data: [{ id: '42', display_name: 'Streamer', login: 'streamer_tv' }],
},
'https://api.twitch.tv/helix/channels': {
data: [
{
Expand All @@ -66,12 +70,17 @@ describe('twitch fetchStatus', () => {
streamTitle: 'Playing something',
isLive: true,
category: { id: '743', name: 'Chess', imageUrl: 'https://img/68x90.jpg' },
dashboardUrl: 'https://dashboard.twitch.tv/u/streamer_tv/stream-manager',
})
})

it('reports offline when /streams returns nothing', async () => {
routeFetch({
'https://api.twitch.tv/helix/users': { data: [{ id: '42', display_name: 'Streamer' }] },
'https://api.twitch.tv/helix/users': {
// The login is deliberately not the display name lowercased: the
// dashboard url must follow the login.
data: [{ id: '42', display_name: 'Streamer', login: 'streamer_tv' }],
},
'https://api.twitch.tv/helix/channels': {
data: [{ title: 'Offline title', game_id: '', game_name: '' }],
},
Expand Down Expand Up @@ -117,7 +126,11 @@ describe('twitch categories and updates', () => {

it('PATCHes the channel keyed by the token holder’s own id', async () => {
const calls = routeFetch({
'https://api.twitch.tv/helix/users': { data: [{ id: '42', display_name: 'Streamer' }] },
'https://api.twitch.tv/helix/users': {
// The login is deliberately not the display name lowercased: the
// dashboard url must follow the login.
data: [{ id: '42', display_name: 'Streamer', login: 'streamer_tv' }],
},
'https://api.twitch.tv/helix/channels': {},
})

Expand Down Expand Up @@ -155,6 +168,7 @@ describe('kick fetchStatus', () => {
streamTitle: 'Kick stream',
isLive: true,
category: { id: '5', name: 'Just Chatting', imageUrl: 'https://thumb.jpg' },
dashboardUrl: 'https://dashboard.kick.com/stream',
})
})

Expand Down Expand Up @@ -243,6 +257,8 @@ describe('vkvideo fetchStatus', () => {
streamTitle: 'VK stream',
isLive: true,
category: { id: 'g1', name: 'Games', imageUrl: 'https://cover.jpg', kind: 'game' },
// Keyed by the channel url, not the nick.
dashboardUrl: 'https://live.vkvideo.ru/my-channel/studio',
})
})

Expand Down Expand Up @@ -273,6 +289,8 @@ describe('vkvideo fetchStatus', () => {
streamTitle: null,
isLive: false,
category: null,
// No channel means no studio to link to.
dashboardUrl: null,
})
})

Expand Down
1 change: 1 addition & 0 deletions src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ app.get('/api/me', async (c) => {
streamTitle: null,
isLive: false,
category: null,
dashboardUrl: null,
needsReauth: false,
error: null,
})),
Expand Down
3 changes: 3 additions & 0 deletions src/worker/providers/kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export const kick: Provider = {
imageUrl: channel.category.thumbnail || null,
}
: null,
// Kick's console resolves the channel from the session, so the URL is
// the same for every account.
dashboardUrl: 'https://dashboard.kick.com/stream',
}
},

Expand Down
3 changes: 3 additions & 0 deletions src/worker/providers/twitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { Provider, ProviderStatus, StreamPatch } from './types'
interface TwitchUser {
id: string
display_name: string
/** The URL-safe channel name. Not always `display_name` lowercased. */
login: string
}

interface TwitchChannel {
Expand Down Expand Up @@ -93,6 +95,7 @@ export const twitch: Provider = {
// /streams returns an entry only while actually broadcasting.
isLive: stream !== null,
category,
dashboardUrl: `https://dashboard.twitch.tv/u/${encodeURIComponent(user.login)}/stream-manager`,
}
},

Expand Down
6 changes: 6 additions & 0 deletions src/worker/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export interface ProviderStatus {
streamTitle: string | null
isLive: boolean
category: Category | null
/**
* The platform's own broadcast console. Built here rather than in the browser
* because the URL is keyed by a slug (Twitch's `login`, VK's channel url),
* which is not the same string as `displayName`.
*/
dashboardUrl: string | null
}

/**
Expand Down
10 changes: 9 additions & 1 deletion src/worker/providers/vkvideo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ export const vkvideo: Provider = {
if (!channelUrl) {
// A VK account that has never streamed has no channel. That is a valid
// state, not an error — report the identity and leave the title empty.
return { displayName: nick, streamTitle: null, isLive: false, category: null }
return {
displayName: nick,
streamTitle: null,
isLive: false,
category: null,
dashboardUrl: null,
}
}

const channel = await vkGet<VkChannel>(
Expand All @@ -88,6 +94,8 @@ export const vkvideo: Provider = {
streamTitle: stream?.title || null,
isLive: stream?.status === 'started',
category: toCategory(stream?.category),
// The studio path takes the channel url, not the nick shown on the card.
dashboardUrl: `https://live.vkvideo.ru/${encodeURIComponent(channelUrl)}/studio`,
}
},

Expand Down
3 changes: 3 additions & 0 deletions src/worker/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export async function resolveProvider(
streamTitle: null,
isLive: false,
category: null,
dashboardUrl: null,
}),
needsReauth,
error: call.error,
Expand Down Expand Up @@ -181,6 +182,7 @@ function disconnected(provider: Provider, error: string | null = null): Resolved
streamTitle: null,
isLive: false,
category: null,
dashboardUrl: null,
needsReauth: false,
error,
},
Expand All @@ -198,6 +200,7 @@ function connected(provider: Provider, status: ProviderStatus): ProviderState {
streamTitle: status.streamTitle,
isLive: status.isLive,
category: status.category,
dashboardUrl: status.dashboardUrl,
needsReauth: false,
error: null,
}
Expand Down
Loading