├── data └── .gitkeep ├── output └── .gitkeep ├── .ruby-version ├── .rspec ├── .rerun ├── lib ├── models │ ├── vod.rb │ ├── persistence.rb │ ├── player.rb │ ├── team.rb │ ├── list.rb │ ├── model.rb │ ├── league.rb │ ├── source.rb │ └── match.rb ├── error_reporting.rb ├── synced_stdout.rb ├── riot │ ├── vod.rb │ ├── data_response.rb │ ├── team.rb │ ├── data_request.rb │ ├── game.rb │ ├── stream.rb │ ├── data.rb │ ├── stream_event.rb │ ├── tournament.rb │ ├── event.rb │ ├── league.rb │ └── seeder.rb ├── seeders │ ├── leagues.rb │ ├── seeder.rb │ ├── stream_events.rb │ └── events.rb ├── build │ ├── vod_url.rb │ ├── json.rb │ ├── haml_context.rb │ ├── stream_url.rb │ ├── logos_downloader.rb │ ├── sprites_builder.rb │ ├── html.rb │ └── json_renderer.rb ├── parallel.rb ├── build.rb ├── client.rb └── lolschedule.rb ├── .gitignore ├── spec ├── support │ └── vcr.rb ├── riot │ └── league_spec.rb └── spec_helper.rb ├── lolschedule ├── README.md ├── Gemfile ├── Guardfile ├── LICENSE ├── Rakefile ├── Gemfile.lock └── DEPLOYMENT.md /data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /output/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.6 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rerun: -------------------------------------------------------------------------------- 1 | -x 2 | --ignore 'output/*' 3 | time bundle exec rake build:html -------------------------------------------------------------------------------- /lib/models/vod.rb: -------------------------------------------------------------------------------- 1 | class Models::Vod < Models::Model 2 | set_fields :url 3 | end 4 | -------------------------------------------------------------------------------- /lib/error_reporting.rb: -------------------------------------------------------------------------------- 1 | if defined?(Rollbar) 2 | Rollbar.configure do |config| 3 | config.access_token = ENV['ROLLBAR_TOKEN'] 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/synced_stdout.rb: -------------------------------------------------------------------------------- 1 | class SyncedStdout 2 | MUTEX = Mutex.new 3 | 4 | def self.puts(*args) 5 | MUTEX.synchronize { $stdout.puts(*args) } 6 | end 7 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /data/* 2 | !/data/archived.json 3 | /build/logos/ 4 | /build/used_logos/ 5 | /build/css/logos.css 6 | /build/logos.png 7 | /output/ 8 | .DS_Store 9 | /spec/fixtures/vcr_cassettes 10 | /.env 11 | -------------------------------------------------------------------------------- /lib/riot/vod.rb: -------------------------------------------------------------------------------- 1 | class Riot::Vod 2 | def initialize(data) 3 | @data = data 4 | end 5 | 6 | def id 7 | @data["id"] 8 | end 9 | 10 | def parameter 11 | @data["parameter"] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/vcr.rb: -------------------------------------------------------------------------------- 1 | VCR.configure do |c| 2 | c.cassette_library_dir = 'spec/fixtures/vcr_cassettes' 3 | c.hook_into :excon 4 | 5 | c.configure_rspec_metadata! 6 | c.default_cassette_options = { record: :new_episodes } 7 | end -------------------------------------------------------------------------------- /lib/models/persistence.rb: -------------------------------------------------------------------------------- 1 | class Models::Persistence 2 | def self.load(path) 3 | Models::Source.from_h(JSON.parse(path.read)) 4 | end 5 | 6 | def self.save(source, path) 7 | path.write(source.to_h.to_json) 8 | end 9 | end -------------------------------------------------------------------------------- /lib/models/player.rb: -------------------------------------------------------------------------------- 1 | class Models::Player < Models::Model 2 | set_fields :riot_league_id, :name 3 | 4 | finder name: :league, relation: :leagues, key: :riot_league_id 5 | 6 | def slug 7 | "#{league.slug}-#{name}" 8 | end 9 | end -------------------------------------------------------------------------------- /lib/models/team.rb: -------------------------------------------------------------------------------- 1 | class Models::Team < Models::Model 2 | set_fields :riot_league_id, :acronym, :logo 3 | 4 | finder name: :league, relation: :leagues, key: :riot_league_id 5 | 6 | def slug 7 | "#{league.slug}-#{acronym}" 8 | end 9 | end -------------------------------------------------------------------------------- /lolschedule: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | exec >>/home/ubuntu/lolschedule.log 2>&1 4 | set -e 5 | set -x 6 | 7 | export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 8 | export HOME="/home/ubuntu" 9 | 10 | . ~/environment 11 | cd ~/lolschedule 12 | bundle exec rake -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | First, install the dependencies: 2 | 3 | `bundle install` 4 | 5 | Then, to generate LoL schedule: 6 | 7 | 1. run `rake data` 8 | 2. run `rake build` 9 | 10 | This will generate `index.html` and `logos.png`. 11 | 12 | For deployment instructions, see [DEPLOYMENT](DEPLOYMENT.md). -------------------------------------------------------------------------------- /lib/riot/data_response.rb: -------------------------------------------------------------------------------- 1 | class Riot::DataResponse 2 | attr_reader :data_request, :body 3 | 4 | def initialize(data_request:, body:) 5 | @data_request = data_request 6 | @body = body 7 | end 8 | 9 | def parent_id 10 | @data_request.parent_id 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/seeders/leagues.rb: -------------------------------------------------------------------------------- 1 | class Seeders::Leagues 2 | def initialize(source) 3 | @source = source 4 | end 5 | 6 | def seed 7 | Riot::Data["leagues"].each do |league| 8 | @source.leagues << Models::League.new(riot_id: league.id, name: league.name) 9 | end 10 | end 11 | end -------------------------------------------------------------------------------- /lib/seeders/seeder.rb: -------------------------------------------------------------------------------- 1 | class Seeders::Seeder 2 | def initialize(source) 3 | @source = source 4 | end 5 | 6 | def seed 7 | Riot::Seeder.new.seed 8 | 9 | Seeders::Leagues.new(@source).seed 10 | 11 | Seeders::Events.new(@source).seed 12 | 13 | Seeders::StreamEvents.new(@source).seed 14 | end 15 | end -------------------------------------------------------------------------------- /lib/riot/team.rb: -------------------------------------------------------------------------------- 1 | class Riot::Team 2 | def initialize(data) 3 | @data = data 4 | end 5 | 6 | def league_id 7 | @data["league_id"] 8 | end 9 | 10 | def id 11 | "#{league_id}-#{code}" 12 | end 13 | 14 | def code 15 | @data["code"] 16 | end 17 | 18 | def image 19 | @data["image"] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/riot/data_request.rb: -------------------------------------------------------------------------------- 1 | class Riot::DataRequest 2 | API_KEY = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z" 3 | 4 | attr_reader :url, :parent_id 5 | 6 | def initialize(url:, parent_id:) 7 | @url = url 8 | @parent_id = parent_id 9 | end 10 | 11 | def get 12 | body = JSON.parse(Client.get(@url, API_KEY)) 13 | Riot::DataResponse.new(data_request: self, body: body) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/riot/game.rb: -------------------------------------------------------------------------------- 1 | class Riot::Game 2 | def initialize(data) 3 | @data = data 4 | end 5 | 6 | def id 7 | @data["id"] 8 | end 9 | 10 | def state 11 | @data["state"] 12 | end 13 | 14 | def completed? 15 | state == "completed" 16 | end 17 | 18 | def vods 19 | @data["vods"].take(1).map.with_index do |vod, i| 20 | Riot::Vod.new(vod.merge("id" => "#{id}-#{i}")) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/riot/stream.rb: -------------------------------------------------------------------------------- 1 | class Riot::Stream 2 | def initialize(data) 3 | @data = data 4 | end 5 | 6 | def id 7 | @data["id"] 8 | end 9 | 10 | def provider 11 | @data["provider"] 12 | end 13 | 14 | def parameter 15 | @data["parameter"] 16 | end 17 | 18 | def offset 19 | @data["offset"] 20 | end 21 | 22 | def locale 23 | @data["locale"] 24 | end 25 | 26 | def english? 27 | locale =~ /^en/ 28 | end 29 | 30 | def youtube? 31 | provider == "youtube" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | ruby "~> 3.3.0" 4 | gem 'base64' 5 | gem 'rake' 6 | gem 'excon' 7 | gem 'hamlit' 8 | gem 'addressable' 9 | gem 'observer' 10 | gem 'rmagick' 11 | gem 'sprite-factory', git: 'https://github.com/RandieM/sprite-factory', require: 'sprite_factory' 12 | gem 'dotenv' 13 | 14 | group :guard do 15 | gem 'guard' 16 | gem 'guard-yield' 17 | gem 'guard-livereload' 18 | end 19 | 20 | group :test do 21 | gem 'rspec' 22 | gem 'vcr' 23 | end 24 | 25 | group :production do 26 | gem 'rollbar' 27 | end -------------------------------------------------------------------------------- /lib/build/vod_url.rb: -------------------------------------------------------------------------------- 1 | class Build::VodUrl 2 | def initialize(attributes) 3 | @id = attributes['id'] 4 | @start = attributes['start'] 5 | end 6 | 7 | def to_h 8 | { 9 | "id": @id, 10 | "start": @start, 11 | "youtube": youtube, 12 | "surprise": surprise 13 | } 14 | end 15 | 16 | def youtube 17 | url = "https://youtu.be/#{@id}" 18 | url += "?t=#{@start}" if @start != "0" 19 | url 20 | end 21 | 22 | def surprise 23 | "http://surprise.ly/v/?#{@id}:#{@start}:0:0:100" 24 | end 25 | end -------------------------------------------------------------------------------- /lib/riot/data.rb: -------------------------------------------------------------------------------- 1 | class Riot::Data 2 | class << self 3 | attr_accessor :leagues, :tournaments, :events, :stream_events 4 | 5 | def clear 6 | @leagues = [] 7 | @tournaments = [] 8 | @events = [] 9 | @stream_events = [] 10 | end 11 | 12 | def [](key) 13 | case key 14 | when "leagues" 15 | @leagues 16 | when "tournaments" 17 | @tournaments 18 | when "events" 19 | @events 20 | when "stream_events" 21 | @stream_events 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/riot/stream_event.rb: -------------------------------------------------------------------------------- 1 | class Riot::StreamEvent 2 | def self.parse(data_response) 3 | events = data_response.body["data"]["schedule"]["events"].map { |event| new(event) } 4 | end 5 | 6 | def initialize(data) 7 | @data = data 8 | end 9 | 10 | def league_id 11 | @data["league"]["id"] 12 | end 13 | 14 | def id 15 | @data["id"] 16 | end 17 | 18 | def start_time 19 | @data["startTime"] 20 | end 21 | 22 | def streams 23 | return [] unless @data.key?("streams") 24 | @data["streams"].map { |stream| Riot::Stream.new(stream) } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/build/json.rb: -------------------------------------------------------------------------------- 1 | class Build::Json 2 | def initialize 3 | @now = Time.now 4 | end 5 | 6 | def build 7 | output = Build::JsonRenderer.new(@now).render(**context) 8 | 9 | Build.write_with_gz(path: Build.output_path + "data.json", data: output) 10 | end 11 | 12 | def context 13 | source = Models::Persistence.load(Build.source_path) 14 | 15 | matches = source.matches.to_a 16 | leagues = source.leagues.to_a 17 | 18 | { 19 | leagues: leagues, 20 | matches: matches, 21 | generated: @now.iso8601, 22 | data_generated: Build.source_path.mtime.iso8601 23 | } 24 | end 25 | end -------------------------------------------------------------------------------- /lib/parallel.rb: -------------------------------------------------------------------------------- 1 | class Parallel 2 | MAX_PARALLELISM = 4 3 | MUTEX = Mutex.new 4 | 5 | def initialize(items) 6 | @items = items 7 | end 8 | 9 | def perform(&block) 10 | @items.each_slice(MAX_PARALLELISM) do |batch| 11 | threads = batch.map do |item| 12 | Thread.new { block.call(item) } 13 | end 14 | 15 | threads.each { |thread| thread.join } 16 | end 17 | end 18 | 19 | def perform_collate(&block) 20 | results = [] 21 | 22 | perform do |item| 23 | result = block.call(item) 24 | MUTEX.synchronize { results << result } 25 | end 26 | 27 | results 28 | end 29 | end -------------------------------------------------------------------------------- /lib/build/haml_context.rb: -------------------------------------------------------------------------------- 1 | class Build::HamlContext 2 | def initialize(build_path) 3 | @build_path = build_path 4 | @haml_engine_cache = {} 5 | end 6 | 7 | def render(template, locals = {}) 8 | haml_engine(template).render(self, locals) 9 | end 10 | 11 | def partial(name, locals = {}) 12 | render("_#{name}.haml", locals) 13 | end 14 | 15 | def include(path) 16 | (@build_path + path).read 17 | end 18 | 19 | def haml_engine(path) 20 | unless @haml_engine_cache.key?(path) 21 | @haml_engine_cache[path] = Hamlit::Template.new(filename: path) { include(path) } 22 | end 23 | @haml_engine_cache[path] 24 | end 25 | end -------------------------------------------------------------------------------- /lib/models/list.rb: -------------------------------------------------------------------------------- 1 | class Models::List 2 | attr_accessor :source 3 | extend Forwardable 4 | def_delegators :to_a, :each, :map, :select 5 | 6 | def initialize(source) 7 | @source = source 8 | @index = {} 9 | end 10 | 11 | def <<(record) 12 | record.source = @source 13 | @index[record.riot_id] = record 14 | self 15 | end 16 | 17 | def to_a 18 | @index.values 19 | end 20 | alias :all :to_a 21 | 22 | def find(riot_id) 23 | @index[riot_id] 24 | end 25 | 26 | def find_all(riot_ids) 27 | @index.values_at(*riot_ids).compact 28 | end 29 | 30 | def delete(riot_id) 31 | @index.delete(riot_id) 32 | end 33 | end -------------------------------------------------------------------------------- /lib/build/stream_url.rb: -------------------------------------------------------------------------------- 1 | class Build::StreamUrl 2 | def initialize(attributes) 3 | @provider = attributes['provider'] 4 | @id = attributes['id'] 5 | @start = attributes['start'] 6 | end 7 | 8 | def to_h 9 | { 10 | "id": @id, 11 | "start": @start, 12 | "url": url 13 | } 14 | end 15 | 16 | def youtube 17 | url = "https://youtu.be/#{@id}" 18 | url += "?t=#{@start}" if @start != "0" 19 | url 20 | end 21 | 22 | def twitch 23 | "https://twitch.tv/#{@id}" 24 | end 25 | 26 | def url 27 | case @provider 28 | when "youtube" 29 | youtube 30 | when "twitch" 31 | twitch 32 | else 33 | raise 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/riot/tournament.rb: -------------------------------------------------------------------------------- 1 | class Riot::Tournament 2 | HOST = "https://esports-api.lolesports.com" 3 | VODS_ENDPOINT = "#{HOST}/persisted/gw/getVods?hl=en-US&tournamentId=" 4 | 5 | def self.parse(data_response) 6 | league_id = data_response.parent_id 7 | data_response.body["data"]["leagues"][0]["tournaments"].map { |tournament| new(tournament, league_id) } 8 | end 9 | 10 | attr_reader :league_id 11 | 12 | def initialize(data, league_id) 13 | @data = data 14 | @league_id = league_id 15 | end 16 | 17 | def id 18 | @data["id"] 19 | end 20 | 21 | def slug 22 | @data["slug"] 23 | end 24 | 25 | def start_time 26 | Time.strptime(@data["startDate"], "%Y-%m-%d") 27 | end 28 | 29 | def end_time 30 | Time.strptime(@data["endDate"], "%Y-%m-%d") 31 | end 32 | 33 | def events_url 34 | "#{VODS_ENDPOINT}#{id}" 35 | end 36 | 37 | def events_request 38 | Riot::DataRequest.new(url: events_url, parent_id: @league_id) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | require_relative './lib/lolschedule.rb' 2 | 3 | def yield_hash(object, &block) 4 | run_proc = proc do |*args| 5 | begin 6 | block.call(*args) 7 | rescue => e 8 | puts "Error: #{e.message}\n#{e.backtrace.join("\n")}" 9 | end 10 | end 11 | 12 | { 13 | object: object, 14 | run_on_additions: run_proc, 15 | run_on_modifications: run_proc, 16 | run_on_removals: run_proc 17 | } 18 | end 19 | 20 | 21 | icons_options = yield_hash(Build::Icons.new) { |icons| icons.download } 22 | sprites_options = yield_hash(Build::Icons.new) { |icons| icons.build_sprites } 23 | build_options = yield_hash(Build::Html.new) { |builder| builder.build } 24 | 25 | guard :yield, icons_options do 26 | watch(%r{^data/.*$}) 27 | end 28 | 29 | guard :yield, sprites_options do 30 | watch(%r{^build/icons/.*$}) 31 | end 32 | 33 | guard :yield, build_options do 34 | watch(%r{^build/.*$}) 35 | watch(%r{^data/.*$}) 36 | end 37 | 38 | guard 'livereload' do 39 | watch(%r{^output/.*$}) 40 | end -------------------------------------------------------------------------------- /lib/riot/event.rb: -------------------------------------------------------------------------------- 1 | class Riot::Event 2 | def self.parse(data_response) 3 | league_id = data_response.parent_id 4 | events = data_response.body["data"]["schedule"]["events"] 5 | events.reject! { |event| event["type"] == "show" } 6 | events.map { |event| new(event.merge("league_id" => league_id)) } 7 | end 8 | 9 | def initialize(data) 10 | @data = data 11 | end 12 | 13 | def league_id 14 | @data["league_id"] 15 | end 16 | 17 | def match_id 18 | @data["match"]["id"] 19 | end 20 | 21 | def start_time 22 | @data["startTime"] 23 | end 24 | 25 | def year 26 | start_time[0..3].to_i 27 | end 28 | 29 | def block_name 30 | @data["blockName"] 31 | end 32 | 33 | def teams 34 | @data["match"]["teams"].map { |team| Riot::Team.new(team.merge("league_id" => league_id)) } 35 | end 36 | 37 | def games 38 | return [] unless @data.key?("games") 39 | @data["games"].map { |game| Riot::Game.new(game) }.select { |game| game.completed? } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/models/model.rb: -------------------------------------------------------------------------------- 1 | class Models::Model 2 | attr_accessor :source 3 | 4 | class << self 5 | attr_reader :fields 6 | 7 | def set_fields(*fields) 8 | @fields = [:riot_id] + fields 9 | 10 | attr_accessor *@fields 11 | end 12 | end 13 | 14 | def self.finder(name:, relation:, key:) 15 | define_method(name) do 16 | return nil unless source 17 | 18 | self_value = send(key) 19 | result = source.send(relation).find(self_value) 20 | raise "Could not find record in relation #{relation} with riot_id of #{self_value}" unless result 21 | result 22 | end 23 | end 24 | 25 | def initialize(attributes = {}) 26 | attributes.each_pair do |attr, value| 27 | send("#{attr}=", value) 28 | end 29 | end 30 | 31 | def to_h 32 | Hash[self.class.fields.map { |field| [field.to_s, send(field)] }] 33 | end 34 | 35 | def inspect 36 | values = to_h.map { |k, v| "#{k}=#{v.inspect}" }.join(", ") 37 | "#<#{self.class.name}:#{object_id} #{values}>" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/riot/league_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Riot::League, vcr: true do 2 | let(:league_id) { 2 } 3 | let(:league) { described_class.new(league_id) } 4 | 5 | describe '#teams' do 6 | specify { expect(league.teams).to be_a(Array) } 7 | 8 | let(:team) { league.teams.first } 9 | specify { expect(team).to be_a(Hash) } 10 | specify { expect(team["id"]).to eq(11) } 11 | specify { expect(team["acronym"]).to eq("TSM") } 12 | specify { expect(team["logoUrl"]).to eq("http://assets.lolesports.com/team/team-solomid-er9lau58.png") } 13 | end 14 | 15 | describe '#tournaments' do 16 | specify { expect(league.tournaments).to be_a(Array) } 17 | 18 | let(:tournament) { league.tournaments.find { |t| t["title"] =~ /2016/ } } 19 | specify { expect(tournament).to be_a(Hash) } 20 | specify { expect(tournament["id"]).to eq("739fc707-a686-4e49-9209-e16a80fd1655") } 21 | specify { expect(tournament["title"]).to eq("na_2016_spring") } 22 | specify { expect(tournament["league"]).to eq(league_id.to_s) } 23 | end 24 | end -------------------------------------------------------------------------------- /lib/build/logos_downloader.rb: -------------------------------------------------------------------------------- 1 | class Build::LogosDownloader 2 | def initialize(source) 3 | @source = source 4 | end 5 | 6 | def download 7 | Build.logos_path.mkpath 8 | 9 | logos = find_logos.reject { |url, files| files.all?(&:exist?) } 10 | 11 | SyncedStdout.puts("Requesting logos...") unless logos.empty? 12 | 13 | Parallel.new(logos.keys).perform do |url| 14 | img = Magick::Image.from_blob(Client.get(url))[0] 15 | small = img.resize_to_fit(36, 36) 16 | logos[url].each { |file| small.write(file.to_s) } 17 | end 18 | 19 | nil 20 | end 21 | 22 | def find_logos 23 | logos = {} 24 | @source.teams.each do |team| 25 | url = logo_url(team.logo) 26 | next unless url 27 | 28 | logos[url] ||= [] 29 | logos[url] << Build.logos_path + "#{team.slug}.png" 30 | end 31 | logos 32 | end 33 | 34 | def logo_url(logo) 35 | return nil if logo.start_with?("http://na.lolesports.com") 36 | return logo if logo.start_with?("https://lolstatic-a.akamaihd.net") 37 | "https://am-a.akamaihd.net/image/?resize=60:&f=#{logo}" 38 | end 39 | end -------------------------------------------------------------------------------- /lib/seeders/stream_events.rb: -------------------------------------------------------------------------------- 1 | class Seeders::StreamEvents 2 | def initialize(source) 3 | @source = source 4 | end 5 | 6 | def seed 7 | Riot::Data["stream_events"].each { |event| seed_stream_event(event) } 8 | end 9 | 10 | def seed_stream_event(event) 11 | league = @source.leagues.find(event.league_id) 12 | return unless league 13 | league.stream_match_ids ||= [] 14 | league.stream_match_ids << event.id 15 | 16 | stream = best_stream(event.streams) 17 | return unless stream 18 | seed_stream(stream, league) 19 | end 20 | 21 | def best_stream(streams) 22 | stream = streams.find { |stream| stream.english? && stream.youtube? } 23 | return stream if stream 24 | 25 | stream = streams.find(&:english?) 26 | return stream if stream 27 | 28 | stream = streams.find(&:youtube?) 29 | return stream if stream 30 | 31 | streams.first 32 | end 33 | 34 | def seed_stream(stream, league) 35 | league.streams << { 36 | 'id' => nil, 37 | 'url' => { provider: stream.provider, id: stream.parameter, start: stream.offset.to_s }, 38 | priority: 0 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Brenton Fletcher (http://bloople.net i@bloople.net) 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/build/sprites_builder.rb: -------------------------------------------------------------------------------- 1 | class Build::SpritesBuilder 2 | def build 3 | @groups = Build.grouped_logos 4 | 5 | clean 6 | copy_used 7 | create_styles 8 | end 9 | 10 | def clean 11 | Build.used_logos_path.mkpath 12 | Build.used_logos_path.children.each { |file| file.rmtree } 13 | end 14 | 15 | def copy_used 16 | @groups.each do |group| 17 | from = group.first 18 | to = Build.used_logos_path + from.basename 19 | to.write(from.read) 20 | end 21 | end 22 | 23 | def create_styles 24 | SpriteFactory.run!( 25 | Build.used_logos_path.to_s, 26 | output_image: Build.output_path + 'logos.png', 27 | output_style: Build.build_path + 'css' + 'logos.css', 28 | margin: 1, 29 | selector: '.', 30 | nocomments: true, 31 | layout: :packed 32 | ) { |sprites| css_rules(sprites) } 33 | end 34 | 35 | def css_rules(sprites) 36 | @groups.map do |group| 37 | classes = group.map { |file| file.basename(file.extname).to_s } 38 | 39 | sprite = sprites[classes.first.to_sym] 40 | 41 | selector = classes.map { |c| ".#{c}" }.join(", ") 42 | 43 | "#{selector} { #{sprite[:style]} }" 44 | end.join("\n") 45 | end 46 | end -------------------------------------------------------------------------------- /lib/build.rb: -------------------------------------------------------------------------------- 1 | module Build 2 | def self.root_path 3 | Pathname.new(__FILE__).dirname.parent 4 | end 5 | 6 | def self.data_path 7 | if ENV.key?('LOLSCHEDULE_DATA_DIR') 8 | Pathname.new(ENV['LOLSCHEDULE_DATA_DIR']) 9 | else 10 | root_path + 'data' 11 | end 12 | end 13 | 14 | def self.source_path 15 | data_path + 'source.json' 16 | end 17 | 18 | def self.archived_source_path 19 | data_path + 'archived.json' 20 | end 21 | 22 | def self.build_path 23 | root_path + 'build' 24 | end 25 | 26 | def self.logos_path 27 | build_path + 'logos' 28 | end 29 | 30 | def self.used_logos_path 31 | build_path + 'used_logos' 32 | end 33 | 34 | def self.output_path 35 | if ENV.key?('LOLSCHEDULE_OUTPUT_DIR') 36 | Pathname.new(ENV['LOLSCHEDULE_OUTPUT_DIR']) 37 | else 38 | root_path + 'output' 39 | end 40 | end 41 | 42 | def self.grouped_logos 43 | group = {} 44 | 45 | logos_path.children.each do |file| 46 | hash = Magick::Image.read(file.to_s).first.signature 47 | group[hash] ||= [] 48 | group[hash] << file 49 | end 50 | 51 | group.values 52 | end 53 | 54 | def self.write_with_gz(path:, data:) 55 | path.write(data) 56 | Pathname.new("#{path.to_s}.gz").write(Zlib.gzip(data, level: Zlib::BEST_COMPRESSION)) 57 | end 58 | end -------------------------------------------------------------------------------- /lib/client.rb: -------------------------------------------------------------------------------- 1 | class Client 2 | MUTEX = Mutex.new 3 | 4 | HEADERS = { 5 | "Accept-Encoding" => "gzip, deflate, sdch", 6 | "Accept-Language" => ":en-GB,en;q=0.8,en-US;q=0.6", 7 | "Connection" => "keep-alive", 8 | "User-Agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36" 9 | } 10 | 11 | def initialize(site) 12 | @site = site 13 | @connection = Excon.new(site, 14 | persistent: true, 15 | middlewares: Excon.defaults[:middlewares] + [Excon::Middleware::Decompress], 16 | omit_default_port: true 17 | ) 18 | end 19 | 20 | def get(path, api_key = nil) 21 | start = Time.now 22 | 23 | response = if api_key 24 | @connection.get(path: path, headers: HEADERS.merge("x-api-key" => api_key)) 25 | else 26 | @connection.get(path: path, headers: HEADERS) 27 | end 28 | 29 | taken = ((Time.now - start) * 1000).round(1) 30 | size_kb = (response.body.bytes.length / 1024.0).round(1) 31 | SyncedStdout.puts "Requested #{@site}#{path}; took #{taken}ms, response status #{response.status}, body size #{size_kb}KB" 32 | 33 | response.body 34 | end 35 | 36 | def self.for(site) 37 | MUTEX.synchronize do 38 | @connections ||= {} 39 | @connections[site] ||= new(site) 40 | end 41 | end 42 | 43 | def self.get(url, api_key = nil) 44 | parsed = Addressable::URI.parse(url) 45 | self.for(parsed.site).get(parsed.request_uri, api_key) 46 | end 47 | end -------------------------------------------------------------------------------- /lib/models/league.rb: -------------------------------------------------------------------------------- 1 | class Models::League < Models::Model 2 | set_fields :name, :streams, :stream_match_ids 3 | 4 | def initialize(attributes = {}) 5 | @streams = [] 6 | super(attributes) 7 | end 8 | 9 | def teams 10 | source.teams.select { |team| team.riot_league_id == riot_id } 11 | end 12 | 13 | def slug 14 | name.gsub(' ', '-') 15 | end 16 | 17 | def brand_name(year) 18 | return name.dup if year < 2019 19 | case name 20 | when 'EU LCS' 21 | 'LEC' 22 | when 'NA LCS' 23 | 'LCS' 24 | when 'All-Star Event' 25 | 'All-Star' 26 | when 'EMEA Masters' 27 | year <= 2022 ? 'European Masters' : name.dup 28 | when 'LCO' 29 | year <= 2020 ? 'OPL' : name.dup 30 | else 31 | name.dup 32 | end 33 | end 34 | 35 | def brand_name_short(year) 36 | case name 37 | when 'LCS Academy' 38 | 'LCSA' 39 | when 'European Masters' 40 | 'EU Mas' 41 | when 'LCS Challengers' 42 | 'LCS Chl' 43 | when 'LCK Challengers' 44 | 'LCK Chl' 45 | when 'CBLOL Academy' 46 | 'CBLOLA' 47 | when 'EMEA Masters' 48 | year <= 2022 ? 'EU Mas' : 'EMEA' 49 | when 'LJL Academy' 50 | 'LJLA' 51 | else 52 | brand_name(year) 53 | end 54 | end 55 | 56 | def brand_name_full(year) 57 | result = brand_name(year) 58 | result << " (#{brand_name_short(year)})" if result != brand_name_short(year) 59 | result 60 | end 61 | 62 | def international? 63 | ["worlds", "msi", "all-star", "all-star-event"].include?(slug.downcase) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/seeders/events.rb: -------------------------------------------------------------------------------- 1 | class Seeders::Events 2 | def initialize(source) 3 | @source = source 4 | end 5 | 6 | def seed 7 | Riot::Data["events"].each { |event| seed_event(event) } 8 | end 9 | 10 | def seed_event(event) 11 | return unless event.year >= 2022 12 | 13 | teams = seed_teams(event) 14 | 15 | riot_game_ids = seed_games(event) 16 | 17 | @source.matches << Models::Match.new({ 18 | riot_id: event.match_id, 19 | riot_league_id: event.league_id, 20 | time: event.start_time, 21 | riot_game_ids: riot_game_ids, 22 | type: 'team', 23 | riot_team_1_id: teams.first.acronym, 24 | riot_team_2_id: teams.last.acronym, 25 | bracket_name: event.block_name 26 | }) 27 | end 28 | 29 | def seed_teams(event) 30 | event.teams.map { |riot_team| seed_team(riot_team) } 31 | end 32 | 33 | def seed_team(riot_team) 34 | team = @source.teams.find(riot_team.id) 35 | return team if team 36 | 37 | Models::Team.new( 38 | riot_id: riot_team.id, 39 | riot_league_id: riot_team.league_id, 40 | acronym: riot_team.code, 41 | logo: riot_team.image 42 | ).tap { |team| @source.teams << team } 43 | end 44 | 45 | def seed_games(event) 46 | event.games.map { |riot_game| seed_vods(riot_game) }.flatten 47 | end 48 | 49 | def seed_vods(game) 50 | game.vods.map do |riot_vod| 51 | @source.vods << Models::Vod.new({ 52 | riot_id: riot_vod.id, 53 | url: { id: riot_vod.parameter, start: "0" } 54 | }) 55 | 56 | riot_vod.id 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/lolschedule.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'cgi' 3 | require 'forwardable' 4 | require 'json' 5 | require 'pathname' 6 | require 'time' 7 | require 'zlib' 8 | 9 | require 'rubygems' 10 | require 'bundler/setup' 11 | ENV['LOLSCHEDULE_ENV'] ||= 'development' 12 | Bundler.require(:default, ENV['LOLSCHEDULE_ENV']) 13 | 14 | $:.unshift(File.dirname(__FILE__)) 15 | 16 | Dotenv.load 17 | 18 | module Models 19 | end 20 | 21 | module Seeders 22 | end 23 | 24 | module Riot 25 | end 26 | 27 | require 'error_reporting' 28 | require 'synced_stdout' 29 | require 'client' 30 | require 'parallel' 31 | require 'build' 32 | require 'build/haml_context' 33 | require 'build/logos_downloader' 34 | require 'build/sprites_builder' 35 | require 'build/html' 36 | require 'build/json_renderer' 37 | require 'build/json' 38 | require 'build/stream_url' 39 | require 'build/vod_url' 40 | require 'riot/data' 41 | require 'riot/data_request' 42 | require 'riot/data_response' 43 | require 'riot/event' 44 | require 'riot/game' 45 | require 'riot/league' 46 | require 'riot/seeder' 47 | require 'riot/stream' 48 | require 'riot/stream_event' 49 | require 'riot/team' 50 | require 'riot/tournament' 51 | require 'riot/vod' 52 | require 'models/source' 53 | require 'models/persistence' 54 | require 'models/list' 55 | require 'models/model' 56 | require 'models/match' 57 | require 'models/league' 58 | require 'models/team' 59 | require 'models/player' 60 | require 'models/vod' 61 | require 'seeders/leagues' 62 | require 'seeders/stream_events' 63 | require 'seeders/events' 64 | require 'seeders/seeder' 65 | 66 | class << File 67 | alias_method :exists?, :exist? 68 | end 69 | -------------------------------------------------------------------------------- /lib/build/html.rb: -------------------------------------------------------------------------------- 1 | class Build::Html 2 | SEASONS = { 3 | 2015 => '2015.html', 4 | 2016 => '2016.html', 5 | 2017 => '2017.html', 6 | 2018 => '2018.html', 7 | 2019 => '2019.html', 8 | 2020 => '2020.html', 9 | 2021 => '2021.html', 10 | 2022 => '2022.html', 11 | 2023 => '2023.html', 12 | 2024 => 'index.html' 13 | } 14 | 15 | def initialize 16 | @now = Time.now 17 | end 18 | 19 | def build 20 | haml_context = Build::HamlContext.new(Build.build_path) 21 | 22 | source = Models::Persistence.load(Build.source_path) 23 | 24 | SEASONS.each_pair do |year, file| 25 | Build.write_with_gz( 26 | path: Build.output_path + file, 27 | data: haml_context.render('index.haml', context(source: source, year: year)) 28 | ) 29 | end 30 | end 31 | 32 | def context(source:, year:) 33 | matches = matches_for_year(source, year) 34 | leagues = leagues_for_matches(source, matches) 35 | team_acronyms = matches.flat_map { |match| match.teams }.uniq.map { |team| team.acronym } 36 | 37 | { 38 | now: @now, 39 | year: year, 40 | leagues: leagues, 41 | matches: matches, 42 | team_acronyms: team_acronyms, 43 | title: "#{year} League of Legends eSports Schedule", 44 | generated: @now.iso8601, 45 | data_generated: Build.source_path.mtime.iso8601 46 | } 47 | end 48 | 49 | def matches_for_year(source, year) 50 | source.matches.select { |match| match.year == year } 51 | end 52 | 53 | def leagues_for_matches(source, matches) 54 | source.leagues.select { |league| matches.any? { |match| match.league == league } }.uniq { |league| league.slug } 55 | end 56 | end -------------------------------------------------------------------------------- /lib/riot/league.rb: -------------------------------------------------------------------------------- 1 | class Riot::League 2 | HOST = "https://esports-api.lolesports.com" 3 | TOURNAMENTS_ENDPOINT = "#{HOST}/persisted/gw/getTournamentsForLeague?hl=en-US&leagueId=" 4 | SCHEDULES_ENDPOINT = "#{HOST}/persisted/gw/getSchedule?hl=en-US&leagueId=" 5 | VALID_LEAGUE_SLUGS = [ 6 | #International tournaments 7 | "worlds", 8 | "all-star", 9 | "msi", 10 | #Tier 1 leagues 11 | "lck", 12 | "lpl", 13 | "lec", 14 | "lcs", 15 | "pcs", 16 | "vcs", 17 | "cblol-brazil", 18 | "ljl-japan", 19 | "lla", 20 | #Tier 2 leagues 21 | "lco", 22 | "european-masters", 23 | "north_american_challenger_league", 24 | "lck_challengers_league", 25 | "lck_academy", 26 | "ljl_academy", 27 | "cblol_academy", 28 | "turkey-academy-league", 29 | #"honor_division", 30 | #"elements_league", 31 | #"movistar_fiber_golden_league", 32 | #"volcano_discover_league", 33 | #"honor_league", 34 | #"master_flow_league", 35 | #"claro_gaming_stars_league", 36 | #Former tier 1 and 2 leagues 37 | "turkiye-sampiyonluk-ligi", 38 | ] 39 | 40 | def self.parse(response) 41 | leagues = response.body["data"]["leagues"] 42 | leagues.select! { |league| VALID_LEAGUE_SLUGS.include?(league["slug"]) } 43 | leagues.map { |league| new(league) } 44 | end 45 | 46 | def initialize(data) 47 | @data = data 48 | end 49 | 50 | def id 51 | @data["id"] 52 | end 53 | 54 | def name 55 | @data["name"] 56 | end 57 | 58 | def tournament_url 59 | "#{TOURNAMENTS_ENDPOINT}#{id}" 60 | end 61 | 62 | def schedule_url(page_token = nil) 63 | if page_token 64 | "#{SCHEDULES_ENDPOINT}#{id}&pageToken=#{page_token}" 65 | else 66 | "#{SCHEDULES_ENDPOINT}#{id}" 67 | end 68 | end 69 | 70 | def tournament_request 71 | Riot::DataRequest.new(url: tournament_url, parent_id: id) 72 | end 73 | 74 | def schedule_request(page_token = nil) 75 | Riot::DataRequest.new(url: schedule_url(page_token), parent_id: id) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/riot/seeder.rb: -------------------------------------------------------------------------------- 1 | class Riot::Seeder 2 | HOST = "https://esports-api.lolesports.com" 3 | LEAGUES_ENDPOINT = "#{HOST}/persisted/gw/getLeagues?hl=en-US" 4 | LIVESTREAMS_ENDPOINT = "#{HOST}/persisted/gw/getLive?hl=en-US" 5 | 6 | def seed 7 | SyncedStdout.puts "Requesting data..." 8 | 9 | data.clear 10 | leagues 11 | tournaments 12 | events 13 | schedules 14 | stream_events 15 | end 16 | 17 | def leagues 18 | request = Riot::DataRequest.new(url: LEAGUES_ENDPOINT, parent_id: nil) 19 | data.leagues = Riot::League.parse(request.get) 20 | end 21 | 22 | def tournaments 23 | requests = data.leagues.map(&:tournament_request) 24 | responses = Parallel.new(requests).perform_collate(&:get) 25 | data.tournaments = responses.flat_map { |response| Riot::Tournament.parse(response) } 26 | end 27 | 28 | def events 29 | requests = data.tournaments.map(&:events_request) 30 | responses = Parallel.new(requests).perform_collate(&:get) 31 | data.events = responses.flat_map { |response| Riot::Event.parse(response) } 32 | end 33 | 34 | def schedules 35 | data.leagues.each do |league| 36 | schedules_for(league: league, direction: "older", page_token: nil) 37 | schedules_for(league: league, direction: "newer", page_token: nil) 38 | end 39 | 40 | data.events.uniq! { |event| event.match_id } 41 | end 42 | 43 | def schedules_for(league:, direction:, page_token:) 44 | request = Riot::DataRequest.new(url: league.schedule_url(page_token), parent_id: league.id) 45 | response = request.get 46 | 47 | data.events.concat(Riot::Event.parse(response)) 48 | 49 | followup_page_token = response.body.dig("data", "schedule", "pages", direction) 50 | schedules_for(league: league, direction: direction, page_token: followup_page_token) if followup_page_token 51 | end 52 | 53 | def stream_events 54 | request = Riot::DataRequest.new(url: LIVESTREAMS_ENDPOINT, parent_id: nil) 55 | data.stream_events = Riot::StreamEvent.parse(request.get) 56 | end 57 | 58 | def data 59 | Riot::Data 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/models/source.rb: -------------------------------------------------------------------------------- 1 | class Models::Source 2 | attr_accessor :matches, :vods, :leagues, :teams, :players 3 | 4 | def self.from_h(hash) 5 | source = new 6 | source.from_h(hash) 7 | source 8 | end 9 | 10 | def initialize 11 | @matches = Models::List.new(self) 12 | @vods = Models::List.new(self) 13 | @leagues = Models::List.new(self) 14 | @teams = Models::List.new(self) 15 | @players = Models::List.new(self) 16 | end 17 | 18 | def from_h(hash) 19 | hash['matches'].each { |hash| @matches << Models::Match.new(hash) } 20 | hash['vods'].each { |hash| @vods << Models::Vod.new(hash) } 21 | hash['leagues'].each { |hash| @leagues << Models::League.new(hash) } 22 | hash['teams'].each { |hash| @teams << Models::Team.new(hash) } 23 | hash['players'].each { |hash| @players << Models::Player.new(hash) } 24 | end 25 | 26 | def to_h 27 | { 28 | matches: @matches.map(&:to_h), 29 | vods: @vods.map(&:to_h), 30 | leagues: @leagues.map(&:to_h), 31 | teams: @teams.map(&:to_h), 32 | players: @players.map(&:to_h) 33 | } 34 | end 35 | 36 | def trim! 37 | used_league_ids = @matches.map { |match| match.riot_league_id } 38 | league_ids_to_remove = @leagues.map(&:riot_id) - used_league_ids 39 | league_ids_to_remove.each { |league_id| @leagues.delete(league_id) } 40 | 41 | used_team_ids = @matches.map { |match| [match.combined_team_1_id, match.combined_team_2_id] }.flatten.compact 42 | team_ids_to_remove = @teams.map(&:riot_id) - used_team_ids 43 | team_ids_to_remove.each { |team_id| @teams.delete(team_id) } 44 | 45 | used_player_ids = @matches.map { |match| [match.combined_player_1_id, match.combined_player_2_id] }.flatten.compact 46 | player_ids_to_remove = @players.map(&:riot_id) - used_player_ids 47 | player_ids_to_remove.each { |player_id| @players.delete(player_id) } 48 | 49 | used_vod_ids = @matches.map { |match| match.riot_game_ids }.flatten 50 | vod_ids_to_remove = @vods.map(&:riot_id) - used_vod_ids 51 | vod_ids_to_remove.each { |vod_id| @vods.delete(vod_id) } 52 | end 53 | end 54 | 55 | -------------------------------------------------------------------------------- /lib/models/match.rb: -------------------------------------------------------------------------------- 1 | class Models::Match < Models::Model 2 | set_fields :riot_league_id, :type, :riot_team_1_id, :riot_team_2_id, :riot_player_1_id, :riot_player_2_id, :time, 3 | :riot_game_ids, :bracket_name 4 | 5 | finder name: :league, relation: :leagues, key: :riot_league_id 6 | finder name: :team_1, relation: :teams, key: :combined_team_1_id 7 | finder name: :team_2, relation: :teams, key: :combined_team_2_id 8 | finder name: :player_1, relation: :players, key: :combined_player_1_id 9 | finder name: :player_2, relation: :players, key: :combined_player_2_id 10 | 11 | def combined_team_1_id 12 | "#{riot_league_id}-#{riot_team_1_id}" 13 | end 14 | 15 | def combined_team_2_id 16 | "#{riot_league_id}-#{riot_team_2_id}" 17 | end 18 | 19 | def combined_player_1_id 20 | "#{riot_league_id}-#{riot_player_1_id}" 21 | end 22 | 23 | def combined_player_2_id 24 | "#{riot_league_id}-#{riot_player_2_id}" 25 | end 26 | 27 | def vods 28 | source.vods.find_all(riot_game_ids) 29 | end 30 | 31 | def vod_urls 32 | vods.map { |vod| vod.url }.compact 33 | end 34 | 35 | def stream 36 | return if league.streams.empty? 37 | return unless league.stream_match_ids && league.stream_match_ids.include?(riot_id) 38 | 39 | league.streams.find { |s| s[:priority] } || league.streams.first 40 | end 41 | 42 | def stream_url 43 | stream&.dig("url") 44 | end 45 | 46 | def rtime 47 | @rtime ||= parse_time(time) 48 | end 49 | 50 | def parse_time(time) 51 | Time.xmlschema("#{time[0..18]}+00:00") 52 | end 53 | 54 | def year 55 | time[0..3].to_i 56 | end 57 | 58 | def team? 59 | type == 'team' 60 | end 61 | 62 | def single? 63 | type == 'single' 64 | end 65 | 66 | def teams 67 | team? ? [team_1, team_2] : [] 68 | end 69 | 70 | def players 71 | single? ? [player_1, player_2] : [] 72 | end 73 | 74 | def spoiler?(now) 75 | patterns = [ 76 | /(playoffs|promotion_relegation|regionals)$/, 77 | /^(group_stage|bracket_stage|playoffs_bracket)$/, 78 | /^(elimination|semifinals|finals)$/, 79 | /^playoffs/, 80 | /^(regional qualifier|play in groups|play in knockouts|knockouts)$/ 81 | ] 82 | patterns << /^groups$/ if league.international? 83 | 84 | (year == now.year) && patterns.any? { |regex| bracket_name.downcase =~ regex } 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require_relative './lib/lolschedule.rb' 2 | 3 | desc 'Download league, tournament, match, and team data' 4 | task :data do 5 | source = Models::Persistence.load(Build.archived_source_path) 6 | Seeders::Seeder.new(source).seed 7 | Models::Persistence.save(source, Build.source_path) 8 | end 9 | 10 | namespace :build do 11 | namespace :logos do 12 | desc 'Download logos from Akamai' 13 | task :download do 14 | source = Models::Persistence.load(Build.source_path) 15 | Build::LogosDownloader.new(source).download 16 | end 17 | 18 | desc 'Delete downloaded logos' 19 | task :clean do 20 | Build.logos_path.children.each { |file| file.rmtree } 21 | end 22 | 23 | desc 'Generate sprite sheet of downloaded logos' 24 | task :sprite do 25 | Build::SpritesBuilder.new.build 26 | end 27 | end 28 | 29 | desc 'Build HTML page containing schedule' 30 | task :html do 31 | Build::Html.new.build 32 | end 33 | 34 | desc 'Build JSON files containing schedule' 35 | task :json do 36 | Build::Json.new.build 37 | end 38 | end 39 | 40 | desc 'Build complete HTML page and team logos' 41 | task build: ['build:logos:download', 'build:logos:sprite', 'build:html', 'build:json'] 42 | 43 | namespace :clean do 44 | desc 'Delete generated HTML page and logos sprite sheet' 45 | task :build do 46 | Build.logos_path.children.each { |file| file.rmtree } 47 | 48 | logos_css_path = (Build.build_path + 'css' + 'logos.css') 49 | logos_css_path.delete if logos_css_path.exist? 50 | 51 | Build::Html::SEASONS.each_pair do |year, file| 52 | html_path = (Build.output_path + file) 53 | html_path.delete if html_path.exist? 54 | end 55 | 56 | logos_png_path = (Build.output_path + 'logos.png') 57 | logos_png_path.delete if logos_png_path.exist? 58 | end 59 | end 60 | 61 | desc 'Start an IRB console with the project and data loaded' 62 | task :console do 63 | require 'irb' 64 | ARGV.clear 65 | $source = Models::Persistence.load(Build.source_path) 66 | IRB.start 67 | end 68 | 69 | desc 'Load the generated HTML page in your default browser' 70 | task :output do 71 | output_path = URI.join('file:///', (Build.output_path + 'index.html').realpath.to_s).to_s 72 | `xdg-open #{output_path}` 73 | end 74 | 75 | task :develop do 76 | exec('find . | entr rake -t build') 77 | end 78 | 79 | desc 'Download data and then build HTML page and logos' 80 | task default: [:data, :build] 81 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/RandieM/sprite-factory 3 | revision: a2ffe4c1f37cfbe5c39912deed49a82fe0dad026 4 | specs: 5 | sprite-factory (1.7.3) 6 | 7 | GEM 8 | remote: http://rubygems.org/ 9 | specs: 10 | addressable (2.8.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | base64 (0.2.0) 13 | coderay (1.1.1) 14 | diff-lcs (1.2.5) 15 | dotenv (2.1.1) 16 | em-websocket (0.5.1) 17 | eventmachine (>= 0.12.9) 18 | http_parser.rb (~> 0.6.0) 19 | eventmachine (1.2.0.1) 20 | excon (0.71.0) 21 | ffi (1.15.4) 22 | formatador (0.2.5) 23 | guard (2.13.0) 24 | formatador (>= 0.2.4) 25 | listen (>= 2.7, <= 4.0) 26 | lumberjack (~> 1.0) 27 | nenv (~> 0.1) 28 | notiffany (~> 0.0) 29 | pry (>= 0.9.12) 30 | shellany (~> 0.0) 31 | thor (>= 0.18.1) 32 | guard-compat (1.2.1) 33 | guard-livereload (2.5.2) 34 | em-websocket (~> 0.5) 35 | guard (~> 2.8) 36 | guard-compat (~> 1.0) 37 | multi_json (~> 1.8) 38 | guard-yield (0.1.0) 39 | guard-compat (~> 1.0) 40 | hamlit (2.8.7) 41 | temple (>= 0.8.0) 42 | thor 43 | tilt 44 | http_parser.rb (0.6.0) 45 | listen (3.8.0) 46 | rb-fsevent (~> 0.10, >= 0.10.3) 47 | rb-inotify (~> 0.9, >= 0.9.10) 48 | lumberjack (1.0.10) 49 | method_source (0.8.2) 50 | multi_json (1.11.3) 51 | nenv (0.3.0) 52 | notiffany (0.0.8) 53 | nenv (~> 0.1) 54 | shellany (~> 0.0) 55 | observer (0.1.2) 56 | pkg-config (1.5.6) 57 | pry (0.10.3) 58 | coderay (~> 1.1.0) 59 | method_source (~> 0.8.1) 60 | slop (~> 3.4) 61 | public_suffix (4.0.6) 62 | rake (13.1.0) 63 | rb-fsevent (0.11.2) 64 | rb-inotify (0.10.1) 65 | ffi (~> 1.0) 66 | rmagick (5.3.0) 67 | pkg-config (~> 1.4) 68 | rollbar (2.12.0) 69 | multi_json 70 | rspec (3.5.0) 71 | rspec-core (~> 3.5.0) 72 | rspec-expectations (~> 3.5.0) 73 | rspec-mocks (~> 3.5.0) 74 | rspec-core (3.5.1) 75 | rspec-support (~> 3.5.0) 76 | rspec-expectations (3.5.0) 77 | diff-lcs (>= 1.2.0, < 2.0) 78 | rspec-support (~> 3.5.0) 79 | rspec-mocks (3.5.0) 80 | diff-lcs (>= 1.2.0, < 2.0) 81 | rspec-support (~> 3.5.0) 82 | rspec-support (3.5.0) 83 | shellany (0.0.1) 84 | slop (3.6.0) 85 | temple (0.8.0) 86 | thor (0.19.1) 87 | tilt (2.0.8) 88 | vcr (3.0.3) 89 | 90 | PLATFORMS 91 | ruby 92 | 93 | DEPENDENCIES 94 | addressable 95 | base64 96 | dotenv 97 | excon 98 | guard 99 | guard-livereload 100 | guard-yield 101 | hamlit 102 | observer 103 | rake 104 | rmagick 105 | rollbar 106 | rspec 107 | sprite-factory! 108 | vcr 109 | 110 | RUBY VERSION 111 | ruby 3.3.0p0 112 | 113 | BUNDLED WITH 114 | 2.5.4 115 | -------------------------------------------------------------------------------- /lib/build/json_renderer.rb: -------------------------------------------------------------------------------- 1 | class Build::JsonRenderer 2 | def initialize(now) 3 | @now = now 4 | end 5 | 6 | def render(matches:, leagues:, generated:, data_generated:) 7 | result = { 8 | version: 1, 9 | generated: generated, 10 | data_generated: data_generated, 11 | streams: render_streams(leagues), 12 | matches: render_matches(matches), 13 | logos: render_logos 14 | } 15 | 16 | return result.to_json 17 | end 18 | 19 | def render_streams(leagues) 20 | leagues.reject { |league| league.streams.empty? }.map do |league| 21 | league.streams.map { |stream| render_stream(league: league, stream: stream) } 22 | end.flatten 23 | end 24 | 25 | def render_stream(league:, stream:) 26 | stream_url = Build::StreamUrl.new(stream['url']) 27 | 28 | result = { 29 | name: "#{league.brand_name_short(@now.year)}#{" #{stream['id']}" if stream['id']}", 30 | url: stream_url.url 31 | } 32 | 33 | result.merge!(league_data(league: league, year: @now.year)) 34 | 35 | result 36 | end 37 | 38 | def league_data(league:, year:) 39 | { 40 | league: league.brand_name_short(year), 41 | league_long: league.brand_name(year), 42 | league_slug: league.slug 43 | } 44 | end 45 | 46 | def render_matches(matches) 47 | matches.sort_by { |match| match.rtime }.map do |match| 48 | render_match(match) 49 | end 50 | end 51 | 52 | def render_match(match) 53 | result = { 54 | time: match.rtime.iso8601, 55 | tags: render_tags(match), 56 | bracket: match.bracket_name 57 | } 58 | 59 | result.merge!(league_data(league: match.league, year: match.year)) 60 | 61 | if match.team? 62 | result.merge!({ 63 | team_1: match.team_1.acronym, 64 | team_1_logo: match.team_1.slug, 65 | team_2: match.team_2.acronym, 66 | team_2_logo: match.team_2.slug 67 | }) 68 | end 69 | 70 | if match.single? 71 | result.merge!({ 72 | player_1: match.player_1.name, 73 | player_2: match.player_2.name 74 | }) 75 | end 76 | 77 | if match.stream_url 78 | result.merge!({ 79 | stream: render_stream(league: match.league, stream: match.stream) 80 | }) 81 | end 82 | 83 | if match.vod_urls.any? 84 | result.merge!({ 85 | vods: render_vods(vod_urls: match.vod_urls) 86 | }) 87 | end 88 | 89 | result 90 | end 91 | 92 | def render_vods(vod_urls:) 93 | vod_urls.map { |vod_url| Build::VodUrl.new(vod_url).youtube } 94 | end 95 | 96 | def render_tags(match) 97 | result = [match.league.slug] 98 | result << 'live' if match.stream_url 99 | result << 'games' if match.vod_urls.any? 100 | result << 'spoiler' if match.spoiler?(@now) 101 | result.push(match.team_1.acronym, match.team_2.acronym) if match.team? 102 | result.push(match.player_1.name, match.player_2.name) if match.single? 103 | result 104 | end 105 | 106 | def render_logos 107 | Build.grouped_logos.map do |group| 108 | aliases = group.map { |file| file.basename(file.extname).to_s } 109 | 110 | { 111 | aliases: aliases, 112 | data: Base64.strict_encode64(group.first.read) 113 | } 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | 20 | require_relative '../lib/lolschedule.rb' 21 | 22 | require 'vcr' 23 | 24 | Dir[File.dirname(__FILE__) + "/support/*.rb"].each { |f| require f } 25 | 26 | RSpec.configure do |config| 27 | # rspec-expectations config goes here. You can use an alternate 28 | # assertion/expectation library such as wrong or the stdlib/minitest 29 | # assertions if you prefer. 30 | config.expect_with :rspec do |expectations| 31 | # This option will default to `true` in RSpec 4. It makes the `description` 32 | # and `failure_message` of custom matchers include text for helper methods 33 | # defined using `chain`, e.g.: 34 | # be_bigger_than(2).and_smaller_than(4).description 35 | # # => "be bigger than 2 and smaller than 4" 36 | # ...rather than: 37 | # # => "be bigger than 2" 38 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 39 | end 40 | 41 | # rspec-mocks config goes here. You can use an alternate test double 42 | # library (such as bogus or mocha) by changing the `mock_with` option here. 43 | config.mock_with :rspec do |mocks| 44 | # Prevents you from mocking or stubbing a method that does not exist on 45 | # a real object. This is generally recommended, and will default to 46 | # `true` in RSpec 4. 47 | mocks.verify_partial_doubles = true 48 | end 49 | 50 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 51 | # have no way to turn it off -- the option exists only for backwards 52 | # compatibility in RSpec 3). It causes shared context metadata to be 53 | # inherited by the metadata hash of host groups and examples, rather than 54 | # triggering implicit auto-inclusion in groups with matching metadata. 55 | config.shared_context_metadata_behavior = :apply_to_host_groups 56 | 57 | 58 | # This allows you to limit a spec run to individual examples or groups 59 | # you care about by tagging them with `:focus` metadata. When nothing 60 | # is tagged with `:focus`, all examples get run. RSpec also provides 61 | # aliases for `it`, `describe`, and `context` that include `:focus` 62 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 63 | config.filter_run_when_matching :focus 64 | 65 | # Allows RSpec to persist some state between runs in order to support 66 | # the `--only-failures` and `--next-failure` CLI options. We recommend 67 | # you configure your source control system to ignore this file. 68 | #config.example_status_persistence_file_path = "spec/examples.txt" 69 | 70 | # Limits the available syntax to the non-monkey patched syntax that is 71 | # recommended. For more details, see: 72 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 73 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 74 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 75 | config.disable_monkey_patching! 76 | 77 | # This setting enables warnings. It's recommended, but in some cases may 78 | # be too noisy due to issues in dependencies. 79 | config.warnings = true 80 | 81 | # Many RSpec users commonly either run the entire suite or an individual 82 | # file, and it's useful to allow more verbose output when running an 83 | # individual spec file. 84 | if config.files_to_run.one? 85 | # Use the documentation formatter for detailed output, 86 | # unless a formatter has already been configured 87 | # (e.g. via a command-line flag). 88 | config.default_formatter = 'doc' 89 | end 90 | 91 | # Print the 10 slowest examples and example groups at the 92 | # end of the spec run, to help surface which specs are running 93 | # particularly slow. 94 | config.profile_examples = 10 95 | 96 | # Run specs in random order to surface order dependencies. If you find an 97 | # order dependency and want to debug it, you can fix the order by providing 98 | # the seed, which is printed after each run. 99 | # --seed 1234 100 | config.order = :random 101 | 102 | # Seed global randomization in this process using the `--seed` CLI option. 103 | # Setting this allows you to use `--seed` to deterministically reproduce 104 | # test failures related to randomization by passing the same `--seed` value 105 | # as the one that triggered the failure. 106 | Kernel.srand config.seed 107 | end -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Notes on deployment 2 | 3 | ## Install packages required to run project 4 | 5 | ````bash 6 | # As root 7 | add-apt-repository ppa:certbot/certbot 8 | apt-get update 9 | apt-get install build-essential pkg-config git nginx certbot 10 | ```` 11 | 12 | ## User setup & Web root configuration 13 | 14 | ````bash 15 | # As root 16 | mkhomedir_helper ubuntu 17 | chsh ubuntu -s /bin/bash 18 | useradd -G www-data ubuntu 19 | chown -R ubuntu:www-data /var/www/html 20 | chmod -R g+rwx /var/www/html 21 | chmod g+s /var/www/html 22 | rm /var/www/html/index.nginx-debian.html 23 | ```` 24 | 25 | ## Install brew and packages 26 | 27 | ````bash 28 | # As root 29 | usermod -a -G sudo ubuntu 30 | su ubuntu 31 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 32 | brew install ruby@3.3 33 | brew install imagemagick 34 | exit 35 | deluser ubuntu sudo 36 | ```` 37 | 38 | ## Switch to ubuntu and configure SSH 39 | 40 | ````bash 41 | su ubuntu 42 | cd ~ 43 | mkdir .ssh 44 | chmod 700 .ssh 45 | cd .ssh 46 | touch authorized_keys 47 | chmod 600 authorized_keys 48 | # Edit authorized_keys file to add your SSH public key 49 | cd ~ 50 | ```` 51 | 52 | ## Bare Git repo setup 53 | 54 | ````bash 55 | git clone --bare https://github.com/bloopletech/lolschedule.git /home/ubuntu/lolschedule.git 56 | cat < /home/ubuntu/lolschedule.git/hooks/post-receive 57 | #!/bin/bash 58 | git --work-tree=/home/ubuntu/lolschedule --git-dir=/home/ubuntu/lolschedule.git checkout -f 59 | cd /home/ubuntu/lolschedule 60 | . /home/ubuntu/environment 61 | export PKG_CONFIG_PATH="/home/linuxbrew/.linuxbrew/lib/pkgconfig:$PKG_CONFIG_PATH" 62 | bundle install 63 | EOF 64 | chmod +x /home/ubuntu/lolschedule.git/hooks/post-receive 65 | ```` 66 | 67 | ## Create lolschedule directory 68 | 69 | ````bash 70 | mkdir /home/ubuntu/lolschedule 71 | cd /home/ubuntu/lolschedule 72 | bundle config set deployment true 73 | bundle config set without 'development test guard' 74 | ```` 75 | 76 | ## Environment variables 77 | 78 | ````bash 79 | cat < /home/ubuntu/environment 80 | eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 81 | export PATH="/home/linuxbrew/.linuxbrew/lib/ruby/gems/3.3.0/bin:$PATH" 82 | 83 | export LOLSCHEDULE_ENV="production" 84 | export LOLSCHEDULE_OUTPUT_DIR="/var/www/html" 85 | export ROLLBAR_TOKEN="" 86 | EOF 87 | ```` 88 | 89 | ## Configure Git remote and first push 90 | 91 | ````bash 92 | # On your local machine, in lolschedule directory 93 | git remote add droplet ssh://ubuntu@/home/ubuntu/lolschedule.git 94 | git commit --allow-empty -m "Bump" 95 | git push droplet master 96 | ```` 97 | 98 | ## Crontab configuration 99 | 100 | ````bash 101 | # As root 102 | cat < /var/spool/cron/crontabs/ubuntu 103 | */10 * * * * /home/ubuntu/lolschedule/lolschedule 104 | EOF 105 | 106 | chown ubuntu:ubuntu /var/spool/cron/crontabs/ubuntu 107 | service cron restart 108 | ```` 109 | 110 | ## Nginx gzip config block 111 | 112 | ```` 113 | gzip on; 114 | gzip_static on; 115 | gzip_disable "msie6"; 116 | 117 | gzip_vary on; 118 | gzip_proxied any; 119 | gzip_comp_level 6; 120 | gzip_buffers 16 8k; 121 | gzip_http_version 1.1; 122 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 123 | ```` 124 | 125 | ## Nginx server config block 126 | 127 | ```` 128 | # As root 129 | cat < /etc/nginx/sites-available/default 130 | server { 131 | listen 80; 132 | listen [::]:80 ipv6only=on; 133 | return 301 https://\$host\$request_uri; 134 | } 135 | 136 | server { 137 | listen 443 default_server ssl; 138 | 139 | ssl_certificate /etc/letsencrypt/live/lol.bloople.net/fullchain.pem; 140 | ssl_certificate_key /etc/letsencrypt/live/lol.bloople.net/privkey.pem; 141 | 142 | ssl_dhparam /etc/ssl/private/dhparams.pem; 143 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 144 | ssl_prefer_server_ciphers on; 145 | ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:ECDHE-RSA-AES128-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA128:DHE-RSA-AES128-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA128:ECDHE-RSA-AES128-SHA384:ECDHE-RSA-AES128-SHA128:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES128-SHA128:DHE-RSA-AES128-SHA128:DHE-RSA-AES128-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA384:AES128-GCM-SHA128:AES128-SHA128:AES128-SHA128:AES128-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; 146 | #ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH"; 147 | ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0 148 | ssl_session_cache shared:SSL:10m; 149 | ssl_session_tickets off; # Requires nginx >= 1.5.9 150 | ssl_stapling on; # Requires nginx >= 1.3.7 151 | ssl_stapling_verify on; # Requires nginx => 1.3.7 152 | resolver 8.8.4.4 8.8.8.8 valid=300s; 153 | resolver_timeout 5s; 154 | 155 | root /var/www/html; 156 | index index.html; 157 | server_name lol.bloople.net; 158 | 159 | location / { 160 | # First attempt to serve request as file, then 161 | # as directory, then fall back to displaying a 404. 162 | try_files \$uri \$uri/ =404; 163 | expires 10m; 164 | } 165 | 166 | location /logos.png { 167 | expires 1h; 168 | } 169 | 170 | access_log off; 171 | } 172 | EOF 173 | 174 | service nginx restart 175 | ```` 176 | 177 | ## SSL config 178 | 179 | ````bash 180 | # As root 181 | openssl dhparam -dsaparam -out /etc/ssl/private/dhparams.pem 4096 182 | certbot certonly --webroot -w /var/www/html -d lol.bloople.net 183 | ```` 184 | 185 | ## Logrotate config 186 | 187 | ````bash 188 | # As root 189 | cat < /etc/logrotate.d/lolschedule 190 | /home/ubuntu/lolschedule.log { 191 | daily 192 | missingok 193 | rotate 14 194 | compress 195 | delaycompress 196 | notifempty 197 | create 640 ubuntu ubuntu 198 | } 199 | EOF 200 | ```` 201 | --------------------------------------------------------------------------------