spotify web setup is done

main
Guido Schweizer 2025-06-22 10:01:32 +02:00
parent b368a750fe
commit 5eaad428ff
16 changed files with 365 additions and 15 deletions

View File

@ -54,3 +54,7 @@ group :test do
end
gem "devise", "~> 4.9"
gem "rest-client", "~> 2.1"
gem "dotenv", "~> 3.1"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,4 @@
class LoadActivitiesJob < ApplicationJob
def perform
end
end

View File

@ -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

View File

@ -4,6 +4,8 @@ class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :logins
def is_admin?
id == 1
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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