spotify web setup is done
parent
b368a750fe
commit
5eaad428ff
4
Gemfile
4
Gemfile
|
@ -54,3 +54,7 @@ group :test do
|
|||
end
|
||||
|
||||
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)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
erb (5.0.1)
|
||||
erubi (1.13.1)
|
||||
|
@ -117,6 +119,9 @@ GEM
|
|||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
http-accept (1.7.0)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.1.0)
|
||||
|
@ -145,6 +150,10 @@ GEM
|
|||
net-smtp
|
||||
marcel (1.0.4)
|
||||
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)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.8.0)
|
||||
|
@ -157,6 +166,7 @@ GEM
|
|||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
netrc (0.11.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
|
@ -245,6 +255,11 @@ GEM
|
|||
responders (3.1.1)
|
||||
actionpack (>= 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)
|
||||
rubocop (1.77.0)
|
||||
json (~> 2.3)
|
||||
|
@ -339,12 +354,14 @@ DEPENDENCIES
|
|||
capybara
|
||||
debug
|
||||
devise (~> 4.9)
|
||||
dotenv (~> 3.1)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
pg (~> 1.1)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.0.2)
|
||||
rest-client (~> 2.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
solid_queue
|
||||
|
|
|
@ -1,4 +1,16 @@
|
|||
class ApplicationController < ActionController::Base
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
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
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
|
|
@ -4,6 +4,8 @@ class User < ApplicationRecord
|
|||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
|
||||
has_many :logins
|
||||
|
||||
def is_admin?
|
||||
id == 1
|
||||
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"
|
||||
# priority: 2
|
||||
# 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 ("/")
|
||||
root "statistics#index"
|
||||
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
|
||||
|
|
Loading…
Reference in New Issue