]> git.localhorst.tv Git - alttp.git/commitdiff
sync episode channels
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 20 Feb 2023 10:34:14 +0000 (11:34 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 20 Feb 2023 10:34:14 +0000 (11:34 +0100)
14 files changed:
app/Console/Commands/SyncSpeedGaming.php
app/Http/Controllers/EpisodeController.php
app/Models/Channel.php [new file with mode: 0644]
app/Models/Episode.php
app/Models/EpisodePlayer.php
app/Models/Event.php
app/Models/Organization.php [new file with mode: 0644]
database/migrations/2023_02_20_084120_create_organizations_table.php [new file with mode: 0644]
database/migrations/2023_02_20_084344_create_channels_table.php [new file with mode: 0644]
database/migrations/2023_02_20_093249_create_channel_episode_table.php [new file with mode: 0644]
resources/js/components/episodes/Channel.js [new file with mode: 0644]
resources/js/components/episodes/Channels.js [new file with mode: 0644]
resources/js/components/episodes/Item.js
resources/sass/episodes.scss

index 8c74c68124428eb190237dae117f957680ceb0fe..4d2cb9cb27cb9a4a9195f21c93f3851238a6f9c2 100644 (file)
@@ -2,9 +2,11 @@
 
 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;
@@ -33,12 +35,15 @@ class SyncSpeedGaming extends 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);
@@ -47,6 +52,7 @@ class SyncSpeedGaming extends Command {
                                $this->error('error syncing event '.$event->name.': '.$e->getMessage());
                        }
                }
+
                return 0;
        }
 
@@ -101,6 +107,22 @@ class SyncSpeedGaming extends Command {
                $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 {
@@ -111,6 +133,33 @@ class SyncSpeedGaming extends Command {
                }
        }
 
+       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) {
@@ -137,7 +186,7 @@ class SyncSpeedGaming extends Command {
                        $player->name_override = $sgPlayer['displayName'];
                }
                if (!empty($sgPlayer['streamingFrom'])) {
-                       $player->stream_override = $sgPlayer['streamingFrom'];
+                       $player->stream_override = strtolower($sgPlayer['streamingFrom']);
                }
                $player->save();
        }
@@ -158,4 +207,6 @@ class SyncSpeedGaming extends Command {
                return null;
        }
 
+       private $org;
+
 }
index 6672cdc128b21a036a6c54c9ad2d45ed10fd5987..2fc5f28867dbdb177e8e249ec1c7d0ec800af9d6 100644 (file)
@@ -16,7 +16,7 @@ class EpisodeController extends Controller
                ]);
                $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)
diff --git a/app/Models/Channel.php b/app/Models/Channel.php
new file mode 100644 (file)
index 0000000..d68ba58
--- /dev/null
@@ -0,0 +1,26 @@
+<?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',
+       ];
+
+}
index b4a4ec824ea3f3ccf13fc2b74d7e6e12755fece4..ddd92a6dd111e18715ca938101c79271720e59f6 100644 (file)
@@ -10,6 +10,10 @@ class Episode extends Model
 
        use HasFactory;
 
+       public function channels() {
+               return $this->belongsToMany(Channel::class);
+       }
+
        public function event() {
                return $this->belongsTo(Event::class);
        }
@@ -20,6 +24,13 @@ class Episode extends Model
 
        protected $casts = [
                'confirmed' => 'boolean',
+               'start' => 'datetime',
+       ];
+
+       protected $hidden = [
+               'created_at',
+               'ext_id',
+               'updated_at',
        ];
 
 }
index 46a064b5aa47ea6836879abdbdbfba0140451ffd..909919097bc9508fae14026e2891c1727ce63ce5 100644 (file)
@@ -19,6 +19,7 @@ class EpisodePlayer extends Model
 
        protected $hidden = [
                'created_at',
+               'ext_id',
                'updated_at',
        ];
 
index 44bc69be067f3722f0bb6e9b8b37a94147becb3d..b566dbb5553925e36ba26bba2ce7ae44ad3d1964 100644 (file)
@@ -15,7 +15,14 @@ class Event extends Model
        }
 
        protected $casts = [
+               'end' => 'datetime',
+               'start' => 'datetime',
                'visible' => 'boolean',
        ];
 
+       protected $hidden = [
+               'created_at',
+               'updated_at',
+       ];
+
 }
diff --git a/app/Models/Organization.php b/app/Models/Organization.php
new file mode 100644 (file)
index 0000000..61a9182
--- /dev/null
@@ -0,0 +1,16 @@
+<?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);
+       }
+
+}
diff --git a/database/migrations/2023_02_20_084120_create_organizations_table.php b/database/migrations/2023_02_20_084120_create_organizations_table.php
new file mode 100644 (file)
index 0000000..ce00e8e
--- /dev/null
@@ -0,0 +1,39 @@
+<?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');
+    }
+};
diff --git a/database/migrations/2023_02_20_084344_create_channels_table.php b/database/migrations/2023_02_20_084344_create_channels_table.php
new file mode 100644 (file)
index 0000000..904d15c
--- /dev/null
@@ -0,0 +1,37 @@
+<?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');
+       }
+};
diff --git a/database/migrations/2023_02_20_093249_create_channel_episode_table.php b/database/migrations/2023_02_20_093249_create_channel_episode_table.php
new file mode 100644 (file)
index 0000000..7f33734
--- /dev/null
@@ -0,0 +1,32 @@
+<?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');
+    }
+};
diff --git a/resources/js/components/episodes/Channel.js b/resources/js/components/episodes/Channel.js
new file mode 100644 (file)
index 0000000..1943753
--- /dev/null
@@ -0,0 +1,30 @@
+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;
diff --git a/resources/js/components/episodes/Channels.js b/resources/js/components/episodes/Channels.js
new file mode 100644 (file)
index 0000000..fb10147
--- /dev/null
@@ -0,0 +1,18 @@
+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;
index 8f1bafcb240508500e0336768c3a4af99d70b4ef..3a3afa44f8c548b38ba02ebe97d5045a22c65ce7 100644 (file)
@@ -1,34 +1,70 @@
+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,
                }),
index 242699adc30042c65a7ba66296429e2fd367477c..fb34fa5876b201a669d33c31de56432f99067a99 100644 (file)
@@ -1,4 +1,9 @@
 .episodes-item {
+       &.is-active {
+               border-color: $success !important;
+               box-shadow: 0 0 0.25rem 0.25rem $success;
+       }
+
        .episode-start {
                width: 4rem;
        }
@@ -6,6 +11,7 @@
                display: grid;
                grid-template-columns: 1fr 1fr;
        }
+
        .player-link {
                border: none;