From c865cc100b3f8c50519ca0516d335e2c530b0977 Mon Sep 17 00:00:00 2001 From: Justin Craig-Kuhn Date: Fri, 4 Oct 2024 02:11:15 -0700 Subject: [PATCH] Album zipfiles (#399) --- Gemfile | 1 + Gemfile.lock | 1 + app/api/api_v2/entities/show.rb | 8 ++ app/javascript/components/CoverArt.jsx | 6 +- .../components/CoverArtInspector.jsx | 80 +++++++++++++++++++ app/javascript/components/DraftPlaylist.jsx | 15 +++- .../components/controls/ShowContextMenu.jsx | 9 ++- app/javascript/components/layout/Navbar.jsx | 4 +- app/javascript/components/routes/routes.js | 7 ++ app/javascript/stylesheets/content.css.scss | 15 ++++ app/jobs/album_zip_job.rb | 60 ++++++++++++++ app/models/concerns/has_cover_art.rb | 8 +- app/models/show.rb | 1 + app/services/cover_art_image_service.rb | 6 +- app/services/cover_art_prompt_service.rb | 9 ++- app/services/show_importer/orchestrator.rb | 2 +- lib/tasks/shows.rake | 10 ++- 17 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 app/javascript/components/CoverArtInspector.jsx create mode 100644 app/jobs/album_zip_job.rb diff --git a/Gemfile b/Gemfile index bcb7392d..c1936ffb 100644 --- a/Gemfile +++ b/Gemfile @@ -28,6 +28,7 @@ gem "rails" gem "react_on_rails" gem "ruby-mp3info" gem "ruby-progressbar" +gem "rubyzip" gem "sentry-rails" gem "sentry-ruby" gem "sidekiq" diff --git a/Gemfile.lock b/Gemfile.lock index 68fa2afd..d4c6e823 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -559,6 +559,7 @@ DEPENDENCIES rubocop-rspec_rails ruby-mp3info ruby-progressbar + rubyzip selenium-webdriver sentry-rails sentry-ruby diff --git a/app/api/api_v2/entities/show.rb b/app/api/api_v2/entities/show.rb index 37d0ba4d..37f0be7e 100644 --- a/app/api/api_v2/entities/show.rb +++ b/app/api/api_v2/entities/show.rb @@ -31,6 +31,14 @@ class ApiV2::Entities::Show < ApiV2::Entities::Base } ) + expose( + :album_zip_url, + documentation: { + type: "String", + desc: "URL of zipfile containing the show's MP3s, cover art, and taper notes" + } + ) + expose \ :duration, documentation: { diff --git a/app/javascript/components/CoverArt.jsx b/app/javascript/components/CoverArt.jsx index 83643942..fa7f17c3 100644 --- a/app/javascript/components/CoverArt.jsx +++ b/app/javascript/components/CoverArt.jsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -const CoverArt = ({ coverArtUrls, albumCoverUrl, openAppModal, size }) => { +const CoverArt = ({ coverArtUrls, albumCoverUrl, openAppModal, size = "small" }) => { const [isLoaded, setIsLoaded] = useState(false); const handleOpenModal = () => { @@ -32,9 +32,9 @@ const CoverArt = ({ coverArtUrls, albumCoverUrl, openAppModal, size }) => { onClick={handleOpenModal} > Cover art diff --git a/app/javascript/components/CoverArtInspector.jsx b/app/javascript/components/CoverArtInspector.jsx new file mode 100644 index 00000000..71463d2a --- /dev/null +++ b/app/javascript/components/CoverArtInspector.jsx @@ -0,0 +1,80 @@ +export const coverArtInspectorLoader = async ({ request }) => { + const url = new URL(request.url); + const page = url.searchParams.get("page") || 1; + const perPage = url.searchParams.get("per_page") || 50; + + try { + const response = await fetch(`/api/v2/shows?page=${page}&per_page=${perPage}`); + if (!response.ok) throw response; + const data = await response.json(); + return { + shows: data.shows, + totalPages: data.total_pages, + totalEntries: data.total_entries, + page: parseInt(page, 10) - 1, + perPage: parseInt(perPage) + }; + } catch (error) { + throw new Response("Error fetching data", { status: 500 }); + } +}; + +import React, { useState } from "react"; +import { useLoaderData, useNavigate, useOutletContext } from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import LayoutWrapper from "./layout/LayoutWrapper"; +import CoverArt from "./CoverArt"; +import Pagination from "./controls/Pagination"; +import { paginationHelper } from "./helpers/pagination"; + +const CoverArtInspector = () => { + const { shows, totalPages, totalEntries, page, perPage } = useLoaderData(); // Fetch the data from the loader + const { openAppModal } = useOutletContext(); + const navigate = useNavigate(); + + const { + tempPerPage, + handlePageClick, + handlePerPageInputChange, + handlePerPageBlurOrEnter + } = paginationHelper(page, "", perPage); + + const sidebarContent = ( +
+

Album Covers

+

{totalEntries} total

+
+ ); + + return ( + <> + + Cover Art - Phish.in + + +
+ {shows.map((show) => ( + + ))} +
+ +
+ + ); +}; + +export default CoverArtInspector; + diff --git a/app/javascript/components/DraftPlaylist.jsx b/app/javascript/components/DraftPlaylist.jsx index 7891f0e0..aec06fa5 100644 --- a/app/javascript/components/DraftPlaylist.jsx +++ b/app/javascript/components/DraftPlaylist.jsx @@ -5,11 +5,13 @@ import Tracks from "./Tracks"; import { formatDurationShow } from "./helpers/utils"; import { useFeedback } from "./controls/FeedbackContext"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExclamationCircle, faClock, faGlobe, faLock, faEdit, faShareFromSquare, faCompactDisc, faCircleCheck } from "@fortawesome/free-solid-svg-icons"; +import { faExclamationCircle, faClock, faGlobe, faLock, faEdit, faShareFromSquare, faCompactDisc, faCircleCheck, faFileImport } from "@fortawesome/free-solid-svg-icons"; const DraftPlaylist = () => { const { + activePlaylist, draftPlaylist, + setDraftPlaylist, draftPlaylistMeta, isDraftPlaylistSaved, openDraftPlaylistModal, @@ -24,6 +26,10 @@ const DraftPlaylist = () => { openDraftPlaylistModal(); }; + const handleImportActivePlaylist = () => { + setDraftPlaylist(activePlaylist); + }; + // Redirect and warn if not logged in useEffect(() => { if (user === "anonymous") { @@ -94,6 +100,13 @@ const DraftPlaylist = () => { Edit + +
+ +
); diff --git a/app/javascript/components/controls/ShowContextMenu.jsx b/app/javascript/components/controls/ShowContextMenu.jsx index 81e454d1..069cd93c 100644 --- a/app/javascript/components/controls/ShowContextMenu.jsx +++ b/app/javascript/components/controls/ShowContextMenu.jsx @@ -4,7 +4,7 @@ import { formatDate } from "../helpers/utils"; import { useFeedback } from "./FeedbackContext"; import LikeButton from "./LikeButton"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faEllipsis, faShareFromSquare, faExternalLinkAlt, faClipboard, faCirclePlus, faMapMarkerAlt, faLandmark, faCircleChevronLeft, faCircleChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { faEllipsis, faShareFromSquare, faExternalLinkAlt, faClipboard, faCirclePlus, faMapMarkerAlt, faLandmark, faCircleChevronLeft, faCircleChevronRight, faDownload } from "@fortawesome/free-solid-svg-icons"; const ShowContextMenu = ({ show, adjacentLinks = true }) => { const dropdownRef = useRef(null); @@ -97,6 +97,13 @@ const ShowContextMenu = ({ show, adjacentLinks = true }) => { Share + {show.album_zip_url && ( + e.stopPropagation}> + + Download MP3s + + )} + Phish.net diff --git a/app/javascript/components/layout/Navbar.jsx b/app/javascript/components/layout/Navbar.jsx index 8dc76169..435ff802 100644 --- a/app/javascript/components/layout/Navbar.jsx +++ b/app/javascript/components/layout/Navbar.jsx @@ -229,7 +229,9 @@ const Navbar = ({ user, handleLogout }) => {
diff --git a/app/javascript/components/routes/routes.js b/app/javascript/components/routes/routes.js index 08bc516f..36050325 100644 --- a/app/javascript/components/routes/routes.js +++ b/app/javascript/components/routes/routes.js @@ -22,6 +22,7 @@ import TopShows, { topShowsLoader } from "../TopShows"; import TopTracks, { topTracksLoader } from "../TopTracks"; import VenueIndex, { venueIndexLoader } from "../VenueIndex"; import VenueShows, { venueShowsLoader } from "../VenueShows"; +import CoverArtInspector, { coverArtInspectorLoader } from "../CoverArtInspector"; // Simple pages with no sidebar import ApiDocs from "../pages/ApiDocs"; @@ -181,6 +182,12 @@ const routes = (props) => [ path: "/settings", element: , }, + // Unlisted pages + { + path: "/cover-art", + element: , + loader: coverArtInspectorLoader, + }, { path: "*", element: , diff --git a/app/javascript/stylesheets/content.css.scss b/app/javascript/stylesheets/content.css.scss index ab8fb2c7..e36dc057 100644 --- a/app/javascript/stylesheets/content.css.scss +++ b/app/javascript/stylesheets/content.css.scss @@ -14,6 +14,15 @@ object-fit: cover; } +.cover-art-medium { + width: 120px; + height: 120px; + border-radius: var(--bulma-radius); + object-fit: cover; + position: relative; + margin-right: 0.5rem; +} + .cover-art-small { width: 44px; height: 44px; @@ -136,6 +145,12 @@ max-width: 60%; padding-right: 1rem; + .cover-art { + width: 32px; + height: 32px; + margin-right: 0.5rem; + } + .cover-art-small { width: 32px; height: 32px; diff --git a/app/jobs/album_zip_job.rb b/app/jobs/album_zip_job.rb new file mode 100644 index 00000000..db087caa --- /dev/null +++ b/app/jobs/album_zip_job.rb @@ -0,0 +1,60 @@ +require "zip" + +class AlbumZipJob + include Sidekiq::Job + + attr_reader :show_id + + def perform(show_id) + @show_id = show_id + create_and_attach_album_zip + end + + private + + def create_and_attach_album_zip + Tempfile.open([ "album-zip-#{show_id}", ".zip" ]) do |temp_zip| # rubocop:disable Metrics/BlockLength + Zip::File.open(temp_zip.path, Zip::File::CREATE) do |zipfile| + # Tracks + show.tracks.order(:position).each do |track| + track_filename = "#{format("%02d", track.position)} #{sanitize(track.title)}.mp3" + zipfile.get_output_stream(track_filename) do |stream| + stream.write track.audio_file.read + end + end + + # taper-notes.txt + zipfile.get_output_stream("taper-notes.txt") do |stream| + stream.write show.taper_notes + end + + # cover-art.jpg + if show.cover_art.attached? && show.cover_art + zipfile.get_output_stream("cover-art.jpg") do |stream| + stream.write show.cover_art.download + end + end + + # album-cover.jpg + if show.album_cover.attached? + zipfile.get_output_stream("album-cover.jpg") do |stream| + stream.write show.album_cover.download + end + end + end + + show.album_zip.attach \ + io: File.open(temp_zip.path), + filename: "Phish #{show.date} MP3 Album.zip", + content_type: "application/zip" + end + end + + def sanitize(str) + str.gsub(/[\/\\<>:"|?*]/, " ").gsub(",", " ").squeeze(" ").strip + end + + def show + @show ||= Show.find(show_id) + end +end diff --git a/app/models/concerns/has_cover_art.rb b/app/models/concerns/has_cover_art.rb index 9741bbfd..abc52536 100644 --- a/app/models/concerns/has_cover_art.rb +++ b/app/models/concerns/has_cover_art.rb @@ -13,11 +13,17 @@ def album_cover_url attachment_url(album_cover, "cover-art-medium.jpg") end - def generate_album_cover! + def generate_album! CoverArtPromptService.call(self) CoverArtImageService.call(self) AlbumCoverService.call(self) tracks.each(&:apply_id3_tags) + AlbumZipJob.perform_async(id) + end + + def album_zip_url + return unless album_zip.attached? + Rails.application.routes.url_helpers.rails_blob_url(album_zip) end private diff --git a/app/models/show.rb b/app/models/show.rb index 4619704a..20995fe7 100644 --- a/app/models/show.rb +++ b/app/models/show.rb @@ -15,6 +15,7 @@ class Show < ApplicationRecord preprocessed: true end has_one_attached :album_cover + has_one_attached :album_zip extend FriendlyId friendly_id :date diff --git a/app/services/cover_art_image_service.rb b/app/services/cover_art_image_service.rb index 7ab45fa4..fc87d86b 100644 --- a/app/services/cover_art_image_service.rb +++ b/app/services/cover_art_image_service.rb @@ -16,6 +16,10 @@ def generate_and_save_cover_art show.cover_art.attach(parent_show.cover_art.blob) # Otherwise, generate new cover art else + prompt = show.cover_art_prompt + prompt += "\n\nDo not include any text, words, or numbers in the image." + prompt += "\n\nAvoid images of people or human faces." + response = Typhoeus.post( "https://api.openai.com/v1/images/generations", headers: { @@ -23,7 +27,7 @@ def generate_and_save_cover_art "Content-Type" => "application/json" }, body: { - prompt: show.cover_art_prompt, + prompt:, n: 1, size: "512x512" }.to_json diff --git a/app/services/cover_art_prompt_service.rb b/app/services/cover_art_prompt_service.rb index c68ed535..2ea1f7a2 100644 --- a/app/services/cover_art_prompt_service.rb +++ b/app/services/cover_art_prompt_service.rb @@ -72,7 +72,12 @@ def run_kickoff_show .where("date < ?", kickoff_show.date) .order(date: :desc) .first + + # Break if prior show doesn't exist, is at a different venue, + # or is more than 4 days apart (early shows at Hunt's / Nectar's) break unless prior_show && prior_show.venue_id == kickoff_show.venue_id + break if (kickoff_show.date - prior_show.date).to_i > 4 + kickoff_show = prior_show end kickoff_show @@ -167,8 +172,6 @@ def unique_songs def chatgpt_response return @chatgpt_response if defined?(@chatgpt_response) - content = chatgpt_prompt + "\n\nDo not include any text, words, or numbers in the image." - response = Typhoeus.post( "https://api.openai.com/v1/chat/completions", headers: { @@ -179,7 +182,7 @@ def chatgpt_response model: "gpt-4o", messages: [ { role: "system", content: "You are an expert in generating DALL-E prompts." }, - { role: "user", content: } + { role: "user", content: chatgpt_prompt } ] }.to_json ) diff --git a/app/services/show_importer/orchestrator.rb b/app/services/show_importer/orchestrator.rb index b6595296..0ec6ac45 100644 --- a/app/services/show_importer/orchestrator.rb +++ b/app/services/show_importer/orchestrator.rb @@ -47,7 +47,7 @@ def save show.reload.save_duration show.update!(published: true) - show.generate_album_cover! + show.generate_album! create_announcement diff --git a/lib/tasks/shows.rake b/lib/tasks/shows.rake index 95f88f3b..0fb3e0fc 100644 --- a/lib/tasks/shows.rake +++ b/lib/tasks/shows.rake @@ -1,6 +1,6 @@ namespace :shows do - desc "Generate cover art prompts" - task generate_cover_art: :environment do + desc "Generate cover prompts, images, and zips for all shows or specific date" + task generate_albums: :environment do date = ENV.fetch("DATE", nil) force = ENV.fetch("FORCE", nil).present? @@ -38,6 +38,12 @@ namespace :shows do track.apply_id3_tags end end + + if force || !show.album_zip.attached? + AlbumZipJob.new.perform(show.id) + end + + puts show.url end pbar.finish