spotify web setup is done
parent
b368a750fe
commit
5eaad428ff
4
Gemfile
4
Gemfile
|
@ -54,3 +54,7 @@ group :test do
|
||||||
end
|
end
|
||||||
|
|
||||||
gem "devise", "~> 4.9"
|
gem "devise", "~> 4.9"
|
||||||
|
|
||||||
|
gem "rest-client", "~> 2.1"
|
||||||
|
|
||||||
|
gem "dotenv", "~> 3.1"
|
||||||
|
|
17
Gemfile.lock
17
Gemfile.lock
|
@ -107,6 +107,8 @@ GEM
|
||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
|
domain_name (0.6.20240107)
|
||||||
|
dotenv (3.1.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
erb (5.0.1)
|
erb (5.0.1)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
|
@ -117,6 +119,9 @@ GEM
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
|
http-accept (1.7.0)
|
||||||
|
http-cookie (1.0.8)
|
||||||
|
domain_name (~> 0.5)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
importmap-rails (2.1.0)
|
importmap-rails (2.1.0)
|
||||||
|
@ -145,6 +150,10 @@ GEM
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
|
mime-types (3.7.0)
|
||||||
|
logger
|
||||||
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
|
mime-types-data (3.2025.0617)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
|
@ -157,6 +166,7 @@ GEM
|
||||||
timeout
|
timeout
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
|
netrc (0.11.0)
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
|
@ -245,6 +255,11 @@ GEM
|
||||||
responders (3.1.1)
|
responders (3.1.1)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
|
rest-client (2.1.0)
|
||||||
|
http-accept (>= 1.7.0, < 2.0)
|
||||||
|
http-cookie (>= 1.0.2, < 2.0)
|
||||||
|
mime-types (>= 1.16, < 4.0)
|
||||||
|
netrc (~> 0.8)
|
||||||
rexml (3.4.1)
|
rexml (3.4.1)
|
||||||
rubocop (1.77.0)
|
rubocop (1.77.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
|
@ -339,12 +354,14 @@ DEPENDENCIES
|
||||||
capybara
|
capybara
|
||||||
debug
|
debug
|
||||||
devise (~> 4.9)
|
devise (~> 4.9)
|
||||||
|
dotenv (~> 3.1)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
pg (~> 1.1)
|
pg (~> 1.1)
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.0.2)
|
rails (~> 8.0.2)
|
||||||
|
rest-client (~> 2.1)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
solid_queue
|
solid_queue
|
||||||
|
|
|
@ -1,4 +1,16 @@
|
||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
|
before_action :check_spotify_login
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_spotify_login
|
||||||
|
if user_signed_in? && current_user.logins.where(platform: "spotify").none?
|
||||||
|
unless request.path == spotify_connect_path || request.path == spotify_path || request.path == spotify_callback_path || devise_controller?
|
||||||
|
redirect_to spotify_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
class SpotifyController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def index
|
||||||
|
# If already connected, redirect to dashboard
|
||||||
|
redirect_to statistics_path if current_user.logins.exists?(platform: "spotify")
|
||||||
|
end
|
||||||
|
|
||||||
|
def connect
|
||||||
|
# If user already has a login, redirect to statistics dashboard
|
||||||
|
return redirect_to statistics_path if current_user.logins.exists?(platform: "spotify")
|
||||||
|
client_id = ENV["SPOTIFY_CLIENT_ID"]
|
||||||
|
redirect_uri = SpotifyClient::SPOTIFY_REDIRECT_URI
|
||||||
|
scope = SpotifyClient::SCOPE
|
||||||
|
state = SecureRandom.hex(16)
|
||||||
|
session[:spotify_auth_state] = state
|
||||||
|
auth_url = "https://accounts.spotify.com/authorize?" + {
|
||||||
|
client_id: client_id,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
scope: scope,
|
||||||
|
state: state
|
||||||
|
}.to_query
|
||||||
|
redirect_to auth_url, allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def callback
|
||||||
|
if params[:state] != session.delete(:spotify_auth_state)
|
||||||
|
return redirect_to spotify_path, alert: "Invalid state parameter. Please try again."
|
||||||
|
end
|
||||||
|
if params[:code].present?
|
||||||
|
if handle_spotify_callback(params[:code])
|
||||||
|
redirect_to statistics_path, notice: "Spotify authorization successful."
|
||||||
|
else
|
||||||
|
redirect_to spotify_path, alert: "Spotify authorization failed. Please try again."
|
||||||
|
end
|
||||||
|
else
|
||||||
|
redirect_to spotify_path, alert: "Spotify authorization failed."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def handle_spotify_callback(code)
|
||||||
|
begin
|
||||||
|
token_response = SpotifyClient.new(current_user).token_response_from_code(code)
|
||||||
|
Login.find_or_create_for_response!(current_user, token_response)
|
||||||
|
true
|
||||||
|
rescue RestClient::Exception, JSON::ParserError => e
|
||||||
|
Rails.logger.error("Spotify callback error: #{e.class} - #{e.message}")
|
||||||
|
false
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Unexpected Spotify callback error: #{e.class} - #{e.message}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,4 @@
|
||||||
|
class LoadActivitiesJob < ApplicationJob
|
||||||
|
def perform
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,3 +1,37 @@
|
||||||
class Login < ApplicationRecord
|
class Login < ApplicationRecord
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
|
scope :alive, -> { where(dead_since: nil) }
|
||||||
|
scope :dead, -> { where.not(dead_since: nil) }
|
||||||
|
|
||||||
|
CLOSE_TO_EXPIRATION = 60
|
||||||
|
|
||||||
|
def self.find_or_create_for_response!(user, response, platform = "spotify")
|
||||||
|
login = user.logins.find_by_platform(platform)
|
||||||
|
login ||= Login.new(
|
||||||
|
user: user,
|
||||||
|
platform: platform,
|
||||||
|
last_refresh_at: Time.new
|
||||||
|
)
|
||||||
|
|
||||||
|
login.credentials = response
|
||||||
|
login.dead_since = nil
|
||||||
|
login.save!
|
||||||
|
login
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_access_token!(spotify_response)
|
||||||
|
self.credentials = credentials.merge(spotify_response)
|
||||||
|
self.last_refresh_at = Time.new
|
||||||
|
save!
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def about_to_expire?
|
||||||
|
Time.new > last_refresh_at + credentials["expires_in"].to_i - CLOSE_TO_EXPIRATION
|
||||||
|
end
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
Time.new > last_refresh_at + credentials["expires_in"].to_i
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,6 +4,8 @@ class User < ApplicationRecord
|
||||||
devise :database_authenticatable, :registerable,
|
devise :database_authenticatable, :registerable,
|
||||||
:recoverable, :rememberable, :validatable
|
:recoverable, :rememberable, :validatable
|
||||||
|
|
||||||
|
has_many :logins
|
||||||
|
|
||||||
def is_admin?
|
def is_admin?
|
||||||
id == 1
|
id == 1
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
class NetflixShakti
|
|
||||||
URL = "https://www.netflix.com/de/".freeze
|
|
||||||
SHAKTI_PART = "BUILD_IDENTIFIER".freeze
|
|
||||||
|
|
||||||
def self.call
|
|
||||||
RestClient.get(URL)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.current_version
|
|
||||||
body = call.body
|
|
||||||
shakti = body.split(SHAKTI_PART)[1].split("\"")[2]
|
|
||||||
|
|
||||||
shakti
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
class CreateSpotifyActivity
|
||||||
|
attr_accessor :response, :user
|
||||||
|
|
||||||
|
def initialize(user, spotify_response)
|
||||||
|
@response = spotify_response
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize
|
||||||
|
def perform
|
||||||
|
response['items'].reverse.each do |play|
|
||||||
|
activity = new_activity
|
||||||
|
activity.item_ref = play['track']['id']
|
||||||
|
artist_names = play['track']['artists'].map { |a| a['name'] }
|
||||||
|
artists = artist_names.join(', ')
|
||||||
|
title = "#{artists} - #{play['track']['name']}"
|
||||||
|
activity.item_title = title
|
||||||
|
activity.item_length = play['track']['duration'].to_s
|
||||||
|
activity.started_at = DateTime.parse(play['played_at'])
|
||||||
|
activity.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize
|
||||||
|
|
||||||
|
def new_activity
|
||||||
|
Activity.new(user: user, platform: 'spotify')
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,20 @@
|
||||||
|
class InitSpotifyLogin
|
||||||
|
def self.create_new_login
|
||||||
|
spotify = Spotify.new
|
||||||
|
puts 'Enter email:'
|
||||||
|
email = $stdin.gets.chomp
|
||||||
|
|
||||||
|
user = User.find_or_create_by!(email: email)
|
||||||
|
|
||||||
|
puts "\nopen in browser:"
|
||||||
|
puts spotify.auth_redirect_url.to_s
|
||||||
|
puts "\ncopy url from browser and paste: "
|
||||||
|
code_url = $stdin.gets.chomp
|
||||||
|
|
||||||
|
puts 'parsing...'
|
||||||
|
response = spotify.auth_code_authorize_url(code_url)
|
||||||
|
Login.find_or_create_for_response!(user, response)
|
||||||
|
|
||||||
|
puts 'login created successful'
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,53 @@
|
||||||
|
class LoadPlays
|
||||||
|
attr_reader :user
|
||||||
|
|
||||||
|
def initialize(user)
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
user.logins.alive.each do |login|
|
||||||
|
if login.spotify?
|
||||||
|
update_spotify(login)
|
||||||
|
elsif login.netflix?
|
||||||
|
update_netflix(login)
|
||||||
|
else
|
||||||
|
puts "login #{login.id}, #{login.type} unknown"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_spotify(login)
|
||||||
|
client = Spotify.new(login)
|
||||||
|
last_spotify_activity_at = latest_activity_iso(login.platform)
|
||||||
|
new_activities = client.load_since(last_spotify_activity_at)
|
||||||
|
create_action = CreateSpotifyActivity.new(user, new_activities)
|
||||||
|
create_action.perform
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error(e)
|
||||||
|
LogLoadFailedWorker.perform_async(login.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_netflix(login)
|
||||||
|
client = Netflix.new(login)
|
||||||
|
latest_netflix_activity = latest_activity(login.platform)
|
||||||
|
new_activites = client.viewingactivity
|
||||||
|
create_action = CreateNetflixActivity.new(user, latest_netflix_activity, new_activites)
|
||||||
|
create_action.perform
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error(e)
|
||||||
|
LogLoadFailedWorker.perform_async(login.id)
|
||||||
|
# Rails.logger.info("trying to update shakti")
|
||||||
|
# ReloadShaktiPath.new.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_activity(platform)
|
||||||
|
latest = Activity.where(user: user, platform: platform).order(started_at: :desc).limit(1).last
|
||||||
|
latest.started_at&.to_datetime if latest.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_activity_iso(platform)
|
||||||
|
latest = latest_activity(platform)
|
||||||
|
latest.strftime('%Q') if latest.present?
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,113 @@
|
||||||
|
require "rest-client"
|
||||||
|
require "base64"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
class SpotifyClient
|
||||||
|
SPOTIFY_REDIRECT_URI = "#{ENV['SPOTIFY_REDIRECT_URI']}/spotify_callback".freeze
|
||||||
|
SCOPE = "user-read-recently-played".freeze
|
||||||
|
|
||||||
|
BASE_URL = "https://api.spotify.com/v1/".freeze
|
||||||
|
|
||||||
|
attr_reader :login
|
||||||
|
|
||||||
|
def initialize(login = nil)
|
||||||
|
@login = login
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_since(load_since)
|
||||||
|
params = {
|
||||||
|
"limit" => "50"
|
||||||
|
}
|
||||||
|
params["after"] = load_since unless load_since.nil?
|
||||||
|
get("me/player/recently-played", params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_response_from_code(code)
|
||||||
|
auth_code_authorize(code)
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_redirect_url
|
||||||
|
"https://accounts.spotify.com/authorize?" \
|
||||||
|
"response_type=code&" \
|
||||||
|
"client_id=#{ENV['SPOTIFY_CLIENT_ID']}&" \
|
||||||
|
"scope=#{SCOPE}&" \
|
||||||
|
"redirect_uri=#{CGI.escape(SPOTIFY_REDIRECT_URI)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get(path, params = {})
|
||||||
|
refresh_token!
|
||||||
|
begin
|
||||||
|
call(path, params)
|
||||||
|
rescue RestClient::ExceptionWithResponse => e
|
||||||
|
if e.response.code == 401
|
||||||
|
puts "token expired, forcing refresh"
|
||||||
|
refresh_token!(force: true)
|
||||||
|
begin
|
||||||
|
call(path, params)
|
||||||
|
rescue RestClient::ExceptionWithResponse
|
||||||
|
puts "token dead"
|
||||||
|
LogLoadFailedWorker.perform_async(login.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(path, params)
|
||||||
|
headers = { "Authorization" => "Bearer #{login.credentials['access_token']}" }
|
||||||
|
headers[:params] = params if params.present?
|
||||||
|
JSON.parse RestClient.get("#{BASE_URL}#{path}", headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token!(force: false)
|
||||||
|
return unless login.about_to_expire? || force
|
||||||
|
|
||||||
|
request_body = {
|
||||||
|
'refresh_token': login.credentials["refresh_token"],
|
||||||
|
'grant_type': "refresh_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
begin
|
||||||
|
auth_response = RestClient.post(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
request_body,
|
||||||
|
client_auth_headers
|
||||||
|
)
|
||||||
|
new_credentials = JSON.parse auth_response
|
||||||
|
login.update_access_token!(new_credentials)
|
||||||
|
rescue RestClient::ExceptionWithResponse => e
|
||||||
|
login.update(dead_since: Time.new)
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def client_auth_headers
|
||||||
|
auth_plain = "#{ENV['SPOTIFY_CLIENT_ID']}:#{ENV['SPOTIFY_CLIENT_SECRET']}"
|
||||||
|
auth_string = Base64.strict_encode64 auth_plain
|
||||||
|
{
|
||||||
|
'Authorization': "Basic #{auth_string}"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_code_authorize(code)
|
||||||
|
request_body = {
|
||||||
|
'code': code,
|
||||||
|
'redirect_uri': SPOTIFY_REDIRECT_URI,
|
||||||
|
'grant_type': "authorization_code"
|
||||||
|
}
|
||||||
|
begin
|
||||||
|
auth_response = RestClient.post(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
request_body,
|
||||||
|
client_auth_headers
|
||||||
|
)
|
||||||
|
rescue RestClient::ExceptionWithResponse => e
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
|
||||||
|
raise StandardError.new("Authorization failed", auth_response) unless auth_response.code == 200
|
||||||
|
|
||||||
|
JSON.parse auth_response.body
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
<div class="dashboard-widget dashboard-spotify-connect">
|
||||||
|
<h2 style="margin-top:0; font-size: 1.4em; color: #1DB954;">Connect to Spotify</h2>
|
||||||
|
<p>To get started, connect your Spotify account. This will allow Plays Hub to load your listening activity and statistics.</p>
|
||||||
|
<%= button_to "Connect with Spotify", spotify_connect_path, method: :post, class: "spotify-connect-btn" %>
|
||||||
|
</div>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<div class="dashboard-widget dashboard-spotify-connect">
|
||||||
|
<h2 style="margin-top:0; font-size: 1.4em; color: #1DB954;">Connect to Spotify</h2>
|
||||||
|
<p>To use Plays Hub, you need to connect your Spotify account. This allows us to load your listening activity and generate your statistics dashboard.</p>
|
||||||
|
<%= button_to "Connect with Spotify", spotify_connect_path, method: :get, class: "spotify-connect-btn", style: "margin-top: 1.5em; background: #1DB954; color: #fff; border: none; border-radius: 12px; padding: 0.8em 2.2em; font-size: 1.1em; font-weight: 600; cursor: pointer;" %>
|
||||||
|
</div>
|
|
@ -8,3 +8,10 @@
|
||||||
# command: "SoftDeletedRecord.due.delete_all"
|
# command: "SoftDeletedRecord.due.delete_all"
|
||||||
# priority: 2
|
# priority: 2
|
||||||
# schedule: at 5am every day
|
# schedule: at 5am every day
|
||||||
|
# development:
|
||||||
|
# periodic_command:
|
||||||
|
# class: TestJob
|
||||||
|
# queue: background
|
||||||
|
# schedule: every minute
|
||||||
|
|
||||||
|
|
|
@ -13,4 +13,8 @@ Rails.application.routes.draw do
|
||||||
# Defines the root path route ("/")
|
# Defines the root path route ("/")
|
||||||
root "statistics#index"
|
root "statistics#index"
|
||||||
get "statistics", to: "statistics#index", as: :statistics
|
get "statistics", to: "statistics#index", as: :statistics
|
||||||
|
|
||||||
|
get "spotify", to: "spotify#index", as: :spotify
|
||||||
|
get "spotify/connect", to: "spotify#connect", as: :spotify_connect
|
||||||
|
get "spotify_callback", to: "spotify#callback", as: :spotify_callback
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue