use App\Models\Channel;
use App\Models\DiscordBotCommand;
use App\Models\Episode;
+use App\Models\EpisodeCrew;
use App\Models\EpisodePlayer;
use App\Models\Event;
use App\Models\Organization;
$episode->channels()->syncWithoutDetaching($channelIds);
}
+ $this->purgeCrew($episode, $sgEntry['broadcasters'], 'brd');
+ foreach ($sgEntry['broadcasters'] as $sgCrew) {
+ try {
+ $this->syncCrew($episode, $sgCrew, 'brd', 'setup');
+ } catch (Exception $e) {
+ $this->error('error syncing broadcaster '.$sgCrew['id'].': '.$e->getMessage());
+ }
+ }
+
+ $this->purgeCrew($episode, $sgEntry['commentators'], 'comm');
+ foreach ($sgEntry['commentators'] as $sgCrew) {
+ try {
+ $this->syncCrew($episode, $sgCrew, 'comm', 'commentary');
+ } catch (Exception $e) {
+ $this->error('error syncing commentator '.$sgCrew['id'].': '.$e->getMessage());
+ }
+ }
+
+ $this->purgeCrew($episode, $sgEntry['helpers'], 'help');
+ foreach ($sgEntry['helpers'] as $sgCrew) {
+ try {
+ $this->syncCrew($episode, $sgCrew, 'help', 'setup');
+ } catch (Exception $e) {
+ $this->error('error syncing helper '.$sgCrew['id'].': '.$e->getMessage());
+ }
+ }
+
+ $this->purgeCrew($episode, $sgEntry['trackers'], 'track');
+ foreach ($sgEntry['trackers'] as $sgCrew) {
+ try {
+ $this->syncCrew($episode, $sgCrew, 'track', 'tracking');
+ } catch (Exception $e) {
+ $this->error('error syncing tracker '.$sgCrew['id'].': '.$e->getMessage());
+ }
+ }
+
$this->purgePlayers($episode, $sgEntry);
foreach ($sgEntry['match1']['players'] as $sgPlayer) {
try {
return $channel;
}
+ private function purgeCrew(Episode $episode, $sgCrews, $prefix) {
+ $ext_ids = [];
+ foreach ($sgCrews as $sgCrew) {
+ $ext_ids[] = 'sg:'.$prefix.':'.$sgCrew['id'];
+ }
+ $episode->crew()->where('ext_id', 'LIKE', 'sg:'.$prefix.':%')->whereNotIn('ext_id', $ext_ids)->delete();
+ }
+
+ private function syncCrew(Episode $episode, $sgCrew, $prefix, $role) {
+ $ext_id = 'sg:'.$prefix.':'.$sgCrew['id'];
+ $crew = $episode->crew()->firstWhere('ext_id', '=', $ext_id);
+ if (!$crew) {
+ $crew = new EpisodeCrew();
+ $crew->ext_id = $ext_id;
+ $crew->episode()->associate($episode);
+ }
+ $user = $this->getUserBySGPlayer($sgCrew);
+ if ($user) {
+ $crew->user()->associate($user);
+ } else {
+ $crew->user()->disassociate();
+ }
+ if ($role == 'commentary') {
+ $channel = $this->getChannelByCrew($episode, $sgCrew);
+ if ($channel) {
+ $crew->channel()->associate($channel);
+ } else {
+ $crew->channel()->disassociate();
+ }
+ }
+ $crew->role = $role;
+ $crew->confirmed = $sgCrew['approved'] ?: false;
+ if (!empty($sgCrew['displayName'])) {
+ $crew->name_override = $sgCrew['displayName'];
+ }
+ if (!empty($sgCrew['publicStream'])) {
+ $crew->stream_override = 'https://twitch.tv/'.strtolower($sgCrew['publicStream']);
+ }
+ $crew->save();
+ }
+
private function purgePlayers(Episode $episode, $sgEntry) {
$ext_ids = [];
foreach ($sgEntry['match1']['players'] as $sgPlayer) {
$user = $this->getUserBySGPlayer($sgPlayer);
if ($user) {
$player->user()->associate($user);
- if (empty($user->stream_link)) {
- if (!empty($sgPlayer['publicStream'])) {
- $user->stream_link = 'https://twitch.tv/'.strtolower($sgPlayer['publicStream']);
- $user->save();
- } else if (!empty($sgPlayer['streamingFrom'])) {
- $user->stream_link = 'https://twitch.tv/'.strtolower($sgPlayer['streamingFrom']);
- $user->save();
- }
- }
} else {
$player->user()->disassociate();
}
$player->name_override = $sgPlayer['displayName'];
}
if (!empty($sgPlayer['streamingFrom'])) {
- $player->stream_override = strtolower($sgPlayer['streamingFrom']);
+ $player->stream_override = 'https://twitch.tv/'.strtolower($sgPlayer['streamingFrom']);
}
$player->save();
}
+ private function getChannelByCrew(Episode $episode, $sgCrew) {
+ $channel = $episode->channels()
+ ->where('ext_id', 'LIKE', 'sg:%')
+ ->whereJsonContains('languages', $sgCrew['language'])
+ ->first();
+ if ($channel) {
+ return $channel;
+ }
+ }
+
private function getUserBySGPlayer($player) {
if (!empty($player['discordId'])) {
$user = User::find($player['discordId']);
- if ($user) return $user;
+ if ($user) {
+ if (empty($user->stream_link)) {
+ if (!empty($sgPlayer['publicStream'])) {
+ $user->stream_link = 'https://twitch.tv/'.strtolower($sgPlayer['publicStream']);
+ $user->save();
+ } else if (!empty($sgPlayer['streamingFrom'])) {
+ $user->stream_link = 'https://twitch.tv/'.strtolower($sgPlayer['streamingFrom']);
+ $user->save();
+ }
+ }
+ return $user;
+ }
DiscordBotCommand::syncUser($player['discordId']);
}
if (!empty($player['discordTag'])) {
->where('events.visible', '=', true)
->orderBy('episodes.start')
->limit(1000);
+ if ($request->user() && $request->user()->isAdmin()) {
+ $episodes = $episodes->with('crew');
+ } else {
+ $episodes = $episodes->with([
+ 'crew' => function ($query) {
+ $query->where('confirmed', true);
+ }
+ ]);
+ }
return $episodes->get()->toJson();
}
return $this->belongsToMany(Channel::class);
}
+ public function crew() {
+ return $this->hasMany(EpisodeCrew::class);
+ }
+
public function event() {
return $this->belongsTo(Event::class);
}
--- /dev/null
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class EpisodeCrew extends Model
+{
+ use HasFactory;
+
+ public function channel() {
+ return $this->belongsTo(Channel::class);
+ }
+
+ public function episode() {
+ return $this->belongsTo(Episode::class);
+ }
+
+ public function user() {
+ return $this->belongsTo(User::class);
+ }
+
+ protected $casts = [
+ 'confirmed' => 'boolean',
+ ];
+
+ protected $hidden = [
+ 'created_at',
+ 'ext_id',
+ 'updated_at',
+ ];
+
+}
return new class extends Migration
{
- /**
- * Run the migrations.
- *
- * @return void
- */
- public function up()
- {
- Schema::create('episode_players', function (Blueprint $table) {
- $table->id();
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('episode_players', function (Blueprint $table) {
+ $table->id();
$table->foreignId('episode_id')->constrained();
$table->foreignId('user_id')->nullable()->default(null)->constrained();
$table->string('name_override')->default('');
$table->string('stream_override')->default('');
$table->string('ext_id')->nullable()->default(null);
- $table->timestamps();
- });
- }
+ $table->timestamps();
+ });
+ }
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down()
- {
- Schema::dropIfExists('episode_players');
- }
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('episode_players');
+ }
};
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('episode_crews', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('episode_id')->constrained();
+ $table->foreignId('user_id')->nullable()->default(null)->constrained();
+ $table->foreignId('channel_id')->nullable()->default(null)->constrained();
+ $table->string('role')->default('commentary');
+ $table->boolean('confirmed')->default(false);
+ $table->string('name_override')->default('');
+ $table->string('stream_override')->default('');
+ $table->string('ext_id')->nullable()->default(null);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('episode_crews');
+ }
+};
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import CrewMember from './CrewMember';
+import { compareCrew } from '../../helpers/Crew';
+
+const Crew = ({ crew }) => {
+ const { t } = useTranslation();
+
+ const commentators = React.useMemo(() =>
+ crew.filter(c => c.role === 'commentary').sort(compareCrew)
+ , [crew]);
+ const trackers = React.useMemo(() =>
+ crew.filter(c => c.role === 'tracking').sort(compareCrew)
+ , [crew]);
+ const techies = React.useMemo(() =>
+ crew.filter(c => c.role === 'setup').sort(compareCrew)
+ , [crew]);
+
+ return <Row className="episode-crew">
+ {commentators.length ?
+ <Col>
+ <div className="fs-5">
+ {t('episodes.commentary')}
+ </div>
+ {commentators.map(c =>
+ <CrewMember crew={c} key={c.id} />
+ )}
+ </Col>
+ : null}
+ {trackers.length ?
+ <Col>
+ <div className="fs-5">
+ {t('episodes.tracking')}
+ </div>
+ {trackers.map(c =>
+ <CrewMember crew={c} key={c.id} />
+ )}
+ </Col>
+ : null}
+ {techies.length ?
+ <Col>
+ <div className="fs-5">
+ {t('episodes.setup')}
+ </div>
+ {techies.map(c =>
+ <CrewMember crew={c} key={c.id} />
+ )}
+ </Col>
+ : null}
+ </Row>;
+};
+
+Crew.propTypes = {
+ crew: PropTypes.arrayOf(PropTypes.shape({
+ })),
+};
+
+export default Crew;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import { getName, getStreamLink } from '../../helpers/Crew';
+import { getAvatarUrl } from '../../helpers/User';
+
+const CrewMember = ({ crew }) => {
+ const classNames = [
+ 'crew-member',
+ 'text-light',
+ ];
+ if (!crew.confirmed) {
+ classNames.push('unconfirmed');
+ }
+ return <Button
+ className={classNames.join(' ')}
+ href={getStreamLink(crew) || null}
+ key={crew.id}
+ rel="noreferer"
+ variant="outline-twitch"
+ >
+ <img alt="" src={getAvatarUrl(crew.user)} />
+ <span>{getName(crew)}</span>
+ </Button>;
+};
+
+CrewMember.propTypes = {
+ crew: PropTypes.shape({
+ confirmed: PropTypes.bool,
+ id: PropTypes.number,
+ user: PropTypes.shape({
+ }),
+ }),
+};
+
+export default CrewMember;
import { useTranslation } from 'react-i18next';
import Channels from './Channels';
+import Crew from './Crew';
import Players from './Players';
const isActive = episode => {
: null}
</div>
</div>
- {episode.players ?
+ {episode.players && episode.players.length ?
<Players players={episode.players} />
: null}
+ {episode.crew && episode.crew.length ?
+ <Crew crew={episode.crew} />
+ : null}
</div>
</div>;
};
episode: PropTypes.shape({
channels: PropTypes.arrayOf(PropTypes.shape({
})),
+ crew: PropTypes.arrayOf(PropTypes.shape({
+ })),
event: PropTypes.shape({
title: PropTypes.string,
}),
import React from 'react';
import { Button } from 'react-bootstrap';
+import { getName, getStreamLink } from '../../helpers/Crew';
import { getAvatarUrl } from '../../helpers/User';
-const getName = player => {
- if (player.name_override) {
- return player.name_override;
- }
- if (player.user) {
- return player.user.nickname || player.user.username;
- }
- return '';
-};
-
-const getStreamLink = player => {
- if (player.stream_override) {
- return `https://twitch.tv/${player.stream_override}`;
- }
- if (player.user && player.user.stream_link) {
- return player.user.stream_link;
- }
- return '';
-};
-
const Player = ({ player }) => {
return <div className="episode-player my-3">
<Button
--- /dev/null
+export const compareCrew = (a, b) => {
+ const a_confirmed = !!(a && a.confirmed);
+ const b_confirmed = !!(b && b.confirmed);
+ if (a_confirmed === b_confirmed) {
+ return getName(a).localeCompare(getName(b));
+ }
+ return a_confirmed ? -1 : 1;
+};
+
+export const getName = crew => {
+ if (!crew) return '';
+ if (crew.name_override) {
+ return crew.name_override;
+ }
+ if (crew.user) {
+ return crew.user.nickname || crew.user.username;
+ }
+ return '';
+};
+
+export const getStreamLink = crew => {
+ if (crew.stream_override) {
+ return crew.stream_override;
+ }
+ if (crew.user && crew.user.stream_link) {
+ return crew.user.stream_link;
+ }
+ return '';
+};
+
search: 'Suche',
settings: 'Einstellungen',
},
+ episodes: {
+ commentary: 'Kommentar',
+ setup: 'Setup',
+ tracking: 'Tracking',
+ },
error: {
403: {
description: 'So aber nicht',
search: 'Search',
settings: 'Settings',
},
+ episodes: {
+ commentary: 'Commentary',
+ setup: 'Setup',
+ tracking: 'Tracking',
+ },
error: {
403: {
description: 'Um no',
vertical-align: middle;
}
}
+
+ .crew-member {
+ border: none;
+
+ img {
+ max-height: 2rem;
+ width: auto;
+ border-radius: 50%;
+ margin: 0 0.25rem 0 0;
+ }
+ span {
+ vertical-align: middle;
+ }
+
+ &.unconfirmed {
+ opacity: 0.25;
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
}