namespace App\Console\Commands;
+use App\Models\Channel;
use App\Models\Episode;
use App\Models\EpisodePlayer;
use App\Models\Event;
+use App\Models\Organization;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
* @return int
*/
public function handle() {
+ $this->org = Organization::where('name', '=', 'sg')->firstOrFail();
+
$events = Event::where('external_schedule', 'LIKE', 'sg:%')
->where(function (Builder $query) {
$query->whereNull('end');
$query->orWhere('end', '<', now());
})
->get();
+
foreach ($events as $event) {
try {
$this->line('syncing '.$event->name);
$this->error('error syncing event '.$event->name.': '.$e->getMessage());
}
}
+
return 0;
}
$episode->confirmed = $sgEntry['approved'];
$episode->comment = $sgEntry['match1']['note'];
$episode->save();
+
+ $this->purgeChannels($episode, $sgEntry);
+ $channelIds = [];
+ foreach ($sgEntry['channels'] as $sgChannel) {
+ if ($sgChannel['initials'] == 'NONE') continue;
+ try {
+ $channel = $this->syncChannel($episode, $sgChannel);
+ $channelIds[] = $channel->id;
+ } catch (Exception $e) {
+ $this->error('error syncing channel '.$sgChannel['id'].': '.$e->getMessage());
+ }
+ }
+ if (!empty($channelIds)) {
+ $episode->channels()->syncWithoutDetaching($channelIds);
+ }
+
$this->purgePlayers($episode, $sgEntry);
foreach ($sgEntry['match1']['players'] as $sgPlayer) {
try {
}
}
+ private function purgeChannels(Episode $episode, $sgEntry) {
+ $ext_ids = [];
+ foreach ($sgEntry['channels'] as $sgChannel) {
+ $ext_ids[] = 'sg:'.$sgChannel['id'];
+ }
+ $episode->channels()
+ ->where('ext_id', 'LIKE', 'sg:%')
+ ->whereNotIn('ext_id', $ext_ids)
+ ->detach();
+ }
+
+ private function syncChannel(Episode $episode, $sgChannel) {
+ $ext_id = 'sg:'.$sgChannel['id'];
+ $channel = $this->org->channels()->firstWhere('ext_id', '=', $ext_id);
+ if (!$channel) {
+ $channel = new Channel();
+ $channel->ext_id = $ext_id;
+ $channel->organization()->associate($this->org);
+ }
+ $channel->short_name = $sgChannel['initials'];
+ $channel->title = $sgChannel['name'];
+ $channel->stream_link = 'https://twitch.tv/'.strtolower($sgChannel['name']);
+ $channel->languages = [$sgChannel['language']];
+ $channel->save();
+ return $channel;
+ }
+
private function purgePlayers(Episode $episode, $sgEntry) {
$ext_ids = [];
foreach ($sgEntry['match1']['players'] as $sgPlayer) {
$player->name_override = $sgPlayer['displayName'];
}
if (!empty($sgPlayer['streamingFrom'])) {
- $player->stream_override = $sgPlayer['streamingFrom'];
+ $player->stream_override = strtolower($sgPlayer['streamingFrom']);
}
$player->save();
}
return null;
}
+ private $org;
+
}
]);
$after = isset($validatedData['after']) ? $validatedData['after'] : Carbon::now()->sub(2, 'hours');
$before = isset($validatedData['before']) ? $validatedData['before'] : Carbon::now()->add(1, 'days');
- $episodes = Episode::with(['event', 'players', 'players.user'])
+ $episodes = Episode::with(['channels', 'event', 'players', 'players.user'])
->select('episodes.*')
->join('events', 'episodes.event_id', '=', 'events.id')
->where('episodes.confirmed', '=', true)
--- /dev/null
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Channel extends Model
+{
+ use HasFactory;
+
+ public function organization() {
+ return $this->belongsTo(Organization::class);
+ }
+
+ protected $casts = [
+ 'languages' => 'array',
+ ];
+
+ protected $hidden = [
+ 'created_at',
+ 'ext_id',
+ 'updated_at',
+ ];
+
+}
use HasFactory;
+ public function channels() {
+ return $this->belongsToMany(Channel::class);
+ }
+
public function event() {
return $this->belongsTo(Event::class);
}
protected $casts = [
'confirmed' => 'boolean',
+ 'start' => 'datetime',
+ ];
+
+ protected $hidden = [
+ 'created_at',
+ 'ext_id',
+ 'updated_at',
];
}
protected $hidden = [
'created_at',
+ 'ext_id',
'updated_at',
];
}
protected $casts = [
+ 'end' => 'datetime',
+ 'start' => 'datetime',
'visible' => 'boolean',
];
+ protected $hidden = [
+ 'created_at',
+ 'updated_at',
+ ];
+
}
--- /dev/null
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Organization extends Model
+{
+ use HasFactory;
+
+ public function channels() {
+ return $this->hasMany(Channel::class);
+ }
+
+}
--- /dev/null
+<?php
+
+use App\Models\Organization;
+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('organizations', function (Blueprint $table) {
+ $table->id();
+ $table->string('name')->unique();
+ $table->text('title');
+ $table->timestamps();
+ });
+
+ $sg = new Organization();
+ $sg->name = 'sg';
+ $sg->title = 'SpeedGaming';
+ $sg->save();
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('organizations');
+ }
+};
--- /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('channels', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('organization_id')->nullable()->default(null)->constrained();
+ $table->string('short_name');
+ $table->text('title');
+ $table->text('stream_link');
+ $table->text('languages')->default('[]');
+ $table->string('ext_id')->nullable()->default(null);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('channels');
+ }
+};
--- /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('channel_episode', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('channel_id')->constrained();
+ $table->foreignId('episode_id')->constrained();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('channel_episode');
+ }
+};
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import Icon from '../common/Icon';
+
+const Channel = ({ channel }) =>
+ <div className="episode-channel">
+ <Button
+ href={channel.stream_link}
+ rel="noreferer"
+ target="_blank"
+ title={channel.title}
+ variant="outline-twitch"
+ >
+ <Icon.STREAM />
+ {' '}
+ {channel.short_name || channel.title}
+ </Button>
+ </div>;
+
+Channel.propTypes = {
+ channel: PropTypes.shape({
+ short_name: PropTypes.string,
+ stream_link: PropTypes.string,
+ title: PropTypes.string,
+ }),
+};
+
+export default Channel;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Channel from './Channel';
+
+const Channels = ({ channels }) =>
+ <div className="episode-channels text-right">
+ {channels.map(channel =>
+ <Channel channel={channel} key={channel.id} />
+ )}
+ </div>;
+
+Channels.propTypes = {
+ channels: PropTypes.arrayOf(PropTypes.shape({
+ })),
+};
+
+export default Channels;
+import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import { useTranslation } from 'react-i18next';
+import Channels from './Channels';
import Players from './Players';
+const isActive = episode => {
+ if (!episode.start) return false;
+ const now = moment();
+ const start = moment(episode.start);
+ const end = moment(start).add(episode.estimate, 'seconds');
+ return start.isBefore(now) && end.isAfter(now);
+};
+
const Item = ({ episode }) => {
const { t } = useTranslation();
- return <div className="episodes-item d-flex align-items-start my-3 p-2 border rounded">
+ const classNames = [
+ 'episodes-item',
+ 'd-flex',
+ 'align-items-start',
+ 'my-3',
+ 'p-2',
+ 'border',
+ 'rounded',
+ ];
+ if (isActive(episode)) {
+ classNames.push('is-active');
+ }
+
+ return <div className={classNames.join(' ')}>
<div className="episode-start me-3 fs-4 text-end">
{t('schedule.startTime', { date: new Date(episode.start) })}
</div>
<div className="flex-fill">
- {episode.title ?
- <div className="episode-title fs-4">
- {episode.title}
+ <div className="d-flex align-items-start justify-content-between">
+ <div>
+ {episode.title ?
+ <div className="episode-title fs-4">
+ {episode.title}
+ </div>
+ : null}
+ {episode.event ?
+ <div className="episode-event">
+ {episode.event.title}
+ </div>
+ : null}
</div>
- : null}
- {episode.event ?
- <div className="episode-event">
- {episode.event.title}
+ <div>
+ {episode.channels ?
+ <Channels channels={episode.channels} />
+ : null}
</div>
+ </div>
+ {episode.players ?
+ <Players players={episode.players} />
: null}
- <Players players={episode.players} />
</div>
</div>;
};
Item.propTypes = {
episode: PropTypes.shape({
+ channels: PropTypes.arrayOf(PropTypes.shape({
+ })),
event: PropTypes.shape({
title: PropTypes.string,
}),
.episodes-item {
+ &.is-active {
+ border-color: $success !important;
+ box-shadow: 0 0 0.25rem 0.25rem $success;
+ }
+
.episode-start {
width: 4rem;
}
display: grid;
grid-template-columns: 1fr 1fr;
}
+
.player-link {
border: none;