Skip to content

Commit

Permalink
Album zipfiles (#399)
Browse files Browse the repository at this point in the history
  • Loading branch information
jcraigk authored Oct 4, 2024
1 parent d310c08 commit c865cc1
Show file tree
Hide file tree
Showing 17 changed files with 228 additions and 14 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ DEPENDENCIES
rubocop-rspec_rails
ruby-mp3info
ruby-progressbar
rubyzip
selenium-webdriver
sentry-rails
sentry-ruby
Expand Down
8 changes: 8 additions & 0 deletions app/api/api_v2/entities/show.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions app/javascript/components/CoverArt.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down Expand Up @@ -32,9 +32,9 @@ const CoverArt = ({ coverArtUrls, albumCoverUrl, openAppModal, size }) => {
onClick={handleOpenModal}
>
<img
src={size === "large" ? coverArtUrls?.medium : coverArtUrls?.small}
src={["large", "medium"].includes(size) ? coverArtUrls?.medium : coverArtUrls?.small}
alt="Cover art"
className={size === "large" ? "cover-art-large" : "cover-art-small"}
className={`cover-art-${size}`}
onLoad={handleImageLoad}
/>
</div>
Expand Down
80 changes: 80 additions & 0 deletions app/javascript/components/CoverArtInspector.jsx
Original file line number Diff line number Diff line change
@@ -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 = (
<div className="sidebar-content">
<p className="sidebar-title">Album Covers</p>
<p className="sidebar-subtitle">{totalEntries} total</p>
</div>
);

return (
<>
<Helmet>
<title>Cover Art - Phish.in</title>
</Helmet>
<LayoutWrapper sidebarContent={sidebarContent}>
<div className="grid-container">
{shows.map((show) => (
<CoverArt
key={show.id}
coverArtUrls={show.cover_art_urls}
albumCoverUrl={show.album_cover_url}
openAppModal={openAppModal}
size="medium"
/>
))}
</div>
<Pagination
totalPages={totalPages}
handlePageClick={handlePageClick}
currentPage={page}
perPage={tempPerPage}
handlePerPageInputChange={handlePerPageInputChange}
handlePerPageBlurOrEnter={handlePerPageBlurOrEnter}
/>
</LayoutWrapper>
</>
);
};

export default CoverArtInspector;

15 changes: 14 additions & 1 deletion app/javascript/components/DraftPlaylist.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,6 +26,10 @@ const DraftPlaylist = () => {
openDraftPlaylistModal();
};

const handleImportActivePlaylist = () => {
setDraftPlaylist(activePlaylist);
};

// Redirect and warn if not logged in
useEffect(() => {
if (user === "anonymous") {
Expand Down Expand Up @@ -94,6 +100,13 @@ const DraftPlaylist = () => {
<FontAwesomeIcon icon={faEdit} className="mr-1 text-gray" />
Edit
</button>

<div class="hidden-mobile mt-3">
<button onClick={handleImportActivePlaylist} className="button">
<FontAwesomeIcon icon={faFileImport} className="mr-1 text-gray" />
Import Active Playlist
</button>
</div>
</div>
);

Expand Down
9 changes: 8 additions & 1 deletion app/javascript/components/controls/ShowContextMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -97,6 +97,13 @@ const ShowContextMenu = ({ show, adjacentLinks = true }) => {
Share
</a>

{show.album_zip_url && (
<a href={show.album_zip_url} className="dropdown-item" onClick={(e) => e.stopPropagation}>
<FontAwesomeIcon icon={faDownload} className="icon" />
Download MP3s
</a>
)}

<a className="dropdown-item" onClick={openPhishNet}>
<FontAwesomeIcon icon={faExternalLinkAlt} className="icon" />
Phish.net
Expand Down
4 changes: 3 additions & 1 deletion app/javascript/components/layout/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ const Navbar = ({ user, handleLogout }) => {
<div className={`dropdown navbar-item user-dropdown ${isUserDropdownOpen ? "is-active" : ""}`} ref={userDropdownRef}>
<div className="dropdown-trigger">
<button className="button" onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}>
<FontAwesomeIcon icon={faUser} className="icon" />
<span className="icon">
<FontAwesomeIcon icon={faUser} />
</span>
<span>{user.username}</span>
</button>
</div>
Expand Down
7 changes: 7 additions & 0 deletions app/javascript/components/routes/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -181,6 +182,12 @@ const routes = (props) => [
path: "/settings",
element: <Settings />,
},
// Unlisted pages
{
path: "/cover-art",
element: <CoverArtInspector />,
loader: coverArtInspectorLoader,
},
{
path: "*",
element: <DynamicRoute />,
Expand Down
15 changes: 15 additions & 0 deletions app/javascript/stylesheets/content.css.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
60 changes: 60 additions & 0 deletions app/jobs/album_zip_job.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion app/models/concerns/has_cover_art.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/show.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion app/services/cover_art_image_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@ 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: {
"Authorization" => "Bearer #{openai_api_token}",
"Content-Type" => "application/json"
},
body: {
prompt: show.cover_art_prompt,
prompt:,
n: 1,
size: "512x512"
}.to_json
Expand Down
9 changes: 6 additions & 3 deletions app/services/cover_art_prompt_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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
)
Expand Down
Loading

0 comments on commit c865cc1

Please sign in to comment.