Skip to content

Commit

Permalink
Merge pull request #343 from blackcandy-org/discogs-sync-image
Browse files Browse the repository at this point in the history
Automatically fetch cover art from discogs after sync
  • Loading branch information
aidewoode committed Feb 1, 2024
2 parents c3ac66a + ae69fd9 commit 3cf4b57
Show file tree
Hide file tree
Showing 47 changed files with 640 additions and 197 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -10,6 +10,8 @@
# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-*
/db/**/*.sqlite3
/db/**/*.sqlite3-*

# Ignore all logfiles and tempfiles.
/log/*
Expand Down
12 changes: 9 additions & 3 deletions Gemfile
Expand Up @@ -22,6 +22,15 @@ gem "jsbundling-rails", "~> 1.1.2"
# Use Puma as the app server
gem "puma", "~> 6.4.0"

# Default database
gem "sqlite3", "~> 1.7.0"

# Background job processing
gem "solid_queue", github: "basecamp/solid_queue", branch: "main"

# Default stack for cache and pub/sub
gem "litestack", "~> 0.4.2"

# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem "jbuilder", "~> 2.11.5"

Expand Down Expand Up @@ -55,9 +64,6 @@ gem "listen", "~> 3.8.0"
# For daemonize library sync process
gem "daemons", "~> 1.4.0"

# Default stack for database, cache, background job and pub/sub
gem "litestack", "~> 0.4.2"

# Optional support for sidekiq as background job
gem "sidekiq", "~> 7.1.2"

Expand Down
12 changes: 11 additions & 1 deletion Gemfile.lock
@@ -1,3 +1,11 @@
GIT
remote: https://github.com/basecamp/solid_queue.git
revision: d57b8e280dd27b1ffda0e203e0fa2afce0b2bb12
branch: main
specs:
solid_queue (0.2.0)
rails (~> 7.1)

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -335,7 +343,7 @@ GEM
simplecov-lcov (0.8.0)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (1.6.9)
sqlite3 (1.7.0)
mini_portile2 (~> 2.8.0)
sshkit (1.21.5)
net-scp (>= 1.1.2)
Expand Down Expand Up @@ -414,6 +422,8 @@ DEPENDENCIES
sidekiq (~> 7.1.2)
simplecov (~> 0.22.0)
simplecov-lcov (~> 0.8.0)
solid_queue!
sqlite3 (~> 1.7.0)
standard (~> 1.25.0)
standard-rails
stimulus-rails (~> 1.2.1)
Expand Down
8 changes: 4 additions & 4 deletions app/assets/stylesheets/utilities/_image.scss
@@ -1,11 +1,11 @@
.u-image-large {
width: 180px;
height: 180px;
width: 300px;
height: 300px;
}

.u-image-medium {
width: 150px;
height: 150px;
width: 200px;
height: 200px;
}

.u-image-small {
Expand Down
1 change: 0 additions & 1 deletion app/controllers/albums_controller.rb
Expand Up @@ -16,7 +16,6 @@ def index

def show
@groped_songs = @album.songs.includes(:artist).group_by(&:discnum)
@album.attach_cover_image_from_discogs
end

def update
Expand Down
2 changes: 0 additions & 2 deletions app/controllers/artists_controller.rb
Expand Up @@ -13,8 +13,6 @@ def index
def show
@albums = @artist.albums.with_attached_cover_image.load_async
@appears_on_albums = @artist.appears_on_albums.with_attached_cover_image.load_async

@artist.attach_cover_image_from_discogs
end

def update
Expand Down
17 changes: 16 additions & 1 deletion app/helpers/application_helper.rb
Expand Up @@ -33,16 +33,31 @@ def icon_tag(name, options = {})
end
end

def cover_image_url_for(object, size: :medium)
def cover_image_url_for(object, size: :medium, default: false)
return unless object.respond_to?(:cover_image)

sizes_options = %i[small medium large]
size = size.in?(sizes_options) ? size : :medium
default_cover_url = "#{root_url}images/default_#{object.class.name.downcase}_#{size}.png"

return default_cover_url if default

object.has_cover_image? ? full_url_for(object.cover_image.variant(size)) : default_cover_url
end

def cover_image_tag(object, size: :medium, **options)
data_attribute = {
"controller" => "cover-image",
"cover-image-default-url-value" => cover_image_url_for(object, size: size, default: true)
}.merge(options.delete(:data) || {})

image_tag(
cover_image_url_for(object, size: size),
data: data_attribute,
**options
)
end

def loader_tag(size: "")
size_class = size.blank? ? "" : "c-loader--#{size}"
tag.div class: "o-animation-spin c-loader #{size_class}"
Expand Down
25 changes: 25 additions & 0 deletions app/javascript/controllers/cover_image_controller.js
@@ -0,0 +1,25 @@
import { Controller } from '@hotwired/stimulus'
import { installEventHandler } from './mixins/event_handler'

export default class extends Controller {
static values = {
defaultUrl: String
}

initialize () {
installEventHandler(this)
}

connect () {
if (!this.hasDefaultUrlValue) { return }

this.handleEvent('error', {
on: this.element,
with: this.loadDefault
})
}

loadDefault = () => {
this.element.src = this.defaultUrlValue
}
}
4 changes: 4 additions & 0 deletions app/javascript/controllers/index.js
Expand Up @@ -36,6 +36,8 @@ import ThemeBridgeController from './theme_bridge_controller.js'

import DropdownController from './dropdown_controller.js'

import CoverImageController from './cover_image_controller.js'

application.register('dialog', DialogController)

application.register('element', ElementController)
Expand Down Expand Up @@ -67,3 +69,5 @@ application.register('theme', ThemeController)
application.register('theme-bridge', ThemeBridgeController)

application.register('dropdown', DropdownController)

application.register('cover-image', CoverImageController)
19 changes: 7 additions & 12 deletions app/jobs/attach_cover_image_from_discogs_job.rb
Expand Up @@ -2,21 +2,16 @@

require "open-uri"
class AttachCoverImageFromDiscogsJob < ApplicationJob
retry_on Integrations::Service::TooManyRequests, wait: 1.minute, attempts: :unlimited
queue_as :default

def perform(discogs_imageable)
image_url = DiscogsApi.image(discogs_imageable)
return unless image_url.present?
def perform(imageable)
return if imageable.has_cover_image?

image_file = OpenURI.open_uri(image_url)
content_type = image_file.content_type
image_format = Mime::Type.lookup(content_type).symbol
return unless image_format.present?
discogs_client = Integrations::Discogs.new
image_resource = discogs_client.cover_image(imageable)
return unless image_resource.present?

discogs_imageable.cover_image.attach(
io: image_file,
filename: "cover.#{image_format}",
content_type: content_type
)
imageable.cover_image.attach(image_resource)
end
end
4 changes: 3 additions & 1 deletion app/jobs/media_sync_job.rb
@@ -1,7 +1,9 @@
class MediaSyncJob < ApplicationJob
queue_as :default
queue_as :critical

def perform(type = :all, file_paths = [])
return if Media.syncing?

if type == :all
Media.sync_all
else
Expand Down
7 changes: 7 additions & 0 deletions app/models/album.rb
Expand Up @@ -23,6 +23,13 @@ class Album < ApplicationRecord
sort_by :name, :year, :created_at
sort_by_associations artist: :name

scope :lack_metadata, -> {
includes(:artist, :cover_image_attachment)
.where(cover_image_attachment: {id: nil})
.where.not(name: Album::UNKNOWN_NAME)
.where.not(artists: {name: Artist::UNKNOWN_NAME})
}

def unknown?
name == UNKNOWN_NAME
end
Expand Down
6 changes: 6 additions & 0 deletions app/models/artist.rb
Expand Up @@ -19,6 +19,12 @@ class Artist < ApplicationRecord

sort_by :name, :created_at

scope :lack_metadata, -> {
includes(:cover_image_attachment)
.where(cover_image_attachment: {id: nil})
.where.not(name: [Artist::UNKNOWN_NAME, Artist::VARIOUS_NAME])
}

def unknown?
name == UNKNOWN_NAME
end
Expand Down
18 changes: 8 additions & 10 deletions app/models/concerns/imageable_concern.rb
Expand Up @@ -6,32 +6,30 @@ module ImageableConcern
included do
has_one_attached :cover_image do |attachable|
attachable.variant :small, resize_to_fill: [200, 200]
attachable.variant :medium, resize_to_fill: [300, 300], preprocessed: true
attachable.variant :large, resize_to_fill: [400, 400]
attachable.variant :medium, resize_to_fill: [400, 400]
attachable.variant :large, resize_to_fill: [600, 600]
end

validate :content_type_of_cover_image

after_commit :transform_cover_image, if: :has_cover_image?
end

def has_cover_image?
cover_image.attached?
end

def attach_cover_image_from_discogs
AttachCoverImageFromDiscogsJob.perform_later(self) if needs_cover_image_from_discogs?
end

private

def needs_cover_image_from_discogs?
Setting.discogs_token.present? && !has_cover_image? && !unknown?
end

def content_type_of_cover_image
return unless cover_image.attached?

unless cover_image.content_type.in?(ALLOWED_IMAGE_CONTENT_TYPES)
errors.add(:cover_image, :invalid_content_type)
end
end

def transform_cover_image
cover_image.variant(:medium).processed
end
end
40 changes: 0 additions & 40 deletions app/models/discogs_api.rb

This file was deleted.

38 changes: 38 additions & 0 deletions app/models/integrations/discogs.rb
@@ -0,0 +1,38 @@
# frozen_string_literal: true

class Integrations::Discogs < Integrations::Service
base_uri "https://api.discogs.com"

def initialize(token = Setting.discogs_token)
self.class.headers "Authorization" => "Discogs token=#{token}"
end

def cover_image(object)
case object
when Artist
artist_cover_image(object)
when Album
album_cover_image(object)
end
end

private

def search_cover_image(options)
json = request("/database/search", options)
image_url = json&.dig(:results, 0, :cover_image)
return unless image_url.present?

download_image(image_url)
end

def artist_cover_image(artist)
options = {query: {type: "artist", q: artist.name}, format: :plain}
search_cover_image(options)
end

def album_cover_image(album)
options = {query: {type: "master", release_title: album.name, artist: album.artist.name}, format: :plain}
search_cover_image(options)
end
end
37 changes: 37 additions & 0 deletions app/models/integrations/service.rb
@@ -0,0 +1,37 @@
# frozen_string_literal: true

require "open-uri"

class Integrations::Service
include HTTParty

def request(path, options = {}, method = :get)
response = self.class.send(method, path, options)
raise TooManyRequests if response.code.to_i == 429

parse_json(response)
end

def download_image(url)
image_file = OpenURI.open_uri(url)
content_type = image_file.content_type
image_format = Mime::Type.lookup(content_type).symbol
return unless image_format.present?

{
io: image_file,
filename: "cover.#{image_format}",
content_type: content_type
}
rescue OpenURI::HTTPError => e
raise TooManyRequests if e.io.status[0].to_i == 429
end

private

def parse_json(response)
JSON.parse response.body, symbolize_names: true if response.success?
end

class TooManyRequests < StandardError; end
end

0 comments on commit 3cf4b57

Please sign in to comment.