Skip to content

Commit

Permalink
feat(artist): add import route
Browse files Browse the repository at this point in the history
  • Loading branch information
nikolvs committed Mar 9, 2024
1 parent 391fd10 commit 23c3eb9
Show file tree
Hide file tree
Showing 28 changed files with 1,104 additions and 52 deletions.
7 changes: 6 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ DATABASE_URL="file:./local.db"

# App variables
NEXT_PUBLIC_APP_TITLE=Geração 666 | Diretoria
NEXT_PUBLIC_APP_DESCRIPTION=Gerenciador de discografias do Geração 666.
NEXT_PUBLIC_APP_DESCRIPTION=Gerenciador de discografias do Geração 666.

# GitHub Auth
GITHUB_API_TOKEN=
GITHUB_USER_NAME=
GITHUB_USER_EMAIL=
8 changes: 8 additions & 0 deletions app/api/artist/ArtistAlreadyExistsError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ArtistPayload } from "./types"

export default class ArtistAlreadyExistsError extends Error {
constructor(artist: ArtistPayload) {
super(`Artist already exists: ${artist.name}`)
Object.setPrototypeOf(this, ArtistAlreadyExistsError.prototype)
}
}
101 changes: 101 additions & 0 deletions app/api/artist/artist.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { ArtistPayload, ReleasePayload } from "./types";
import createSlug from "@/app/utils/createSlug";
import prisma from '@/app/lib/prisma'
import ArtistAlreadyExistsError from "./ArtistAlreadyExistsError";
import { uploadArtworkImage, uploadCoverImage } from "../image/image.service";

const connectOrCreateTags = (tags: string[]) => ({
connectOrCreate: tags.map((name: string) => ({
where: { name },
create: { name }
}))
})

// TODO: Make releases unique by a conjunction of release name and artists.
// TODO: Make discs and tracks unique too.
const makeRelease = async (
artistName: string,
artistSlug: string,
release: ReleasePayload,
shaFromContent: boolean = false
) => {
const [artwork] = await uploadArtworkImage(artistName, artistSlug, release, {
shaFromContent
})

return {
artwork,
name: release.name,
downloadUrl: release.downloadUrl,
type: {
connect: { name: release.type }
},
discs: {
create: release.tracks.map((discTracks: string[], discIndex: number) => ({
number: discIndex + 1,
tracks: {
create: discTracks.map((name, trackIndex) => ({
name,
number: trackIndex + 1
}))
}
}))
}
}
}

const createReleases = async (
artistName: string,
artistSlug: string,
releases: ReleasePayload[],
shaFromContent: boolean = false
) => {
return {
create: await Promise.all(
releases.map((release) => makeRelease(
artistName,
artistSlug,
release,
shaFromContent
))
)
}
}

export const artistExists = (slug: string) => {
return prisma.artist.findFirst({
where: { slug }
})
}

export const createArtist = async (
payload: ArtistPayload,
options: { shaFromPayload: boolean } = { shaFromPayload: false }
) => {
const slug = createSlug(payload.name)
if (await artistExists(slug)) {
throw new ArtistAlreadyExistsError(payload)
}

const [cover] = await uploadCoverImage(payload.name, slug, payload.cover, {
shaFromContent: options.shaFromPayload
})

const entity = {
slug,
cover,
name: payload.name,
origin: payload.origin,
tags: connectOrCreateTags(payload.tags),
releases: await createReleases(payload.name, slug, payload.releases, options.shaFromPayload)
}

return await prisma.artist.upsert({
where: {
slug,
name: entity.name,
},
update: { ...entity },
create: { ...entity }
})
}
23 changes: 23 additions & 0 deletions app/api/artist/import/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { StatusCodes } from "http-status-codes"
import ArtistAlreadyExistsError from "../ArtistAlreadyExistsError"
import { createArtist } from "../artist.service"

export async function POST(request: Request) {
try {
const payload = await request.json()
const artist = await createArtist(payload, {
shaFromPayload: true
})

return Response.json(artist)
} catch (err) {
if (err instanceof ArtistAlreadyExistsError) {
return Response.json(
{ error: err.message },
{ status: StatusCodes.CONFLICT }
)
}

throw err
}
}
21 changes: 21 additions & 0 deletions app/api/artist/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createArtist } from './artist.service'
import ArtistAlreadyExistsError from './ArtistAlreadyExistsError'
import { StatusCodes } from 'http-status-codes'

export async function POST(request: Request) {
try {
const payload = await request.json()
const artist = await createArtist(payload)

return Response.json(artist)
} catch (err) {
if (err instanceof ArtistAlreadyExistsError) {
return Response.json(
{ error: err.message },
{ status: StatusCodes.CONFLICT }
)
}

throw err
}
}
26 changes: 0 additions & 26 deletions app/api/artist/route.tsx

This file was deleted.

16 changes: 16 additions & 0 deletions app/api/artist/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type ArtistPayload = {
name: string
origin: string
cover: string
tags: string[]
releases: ReleasePayload[]
}

export type ReleaseType = 'album' | 'compilation' | 'dvd' | 'ep' | 'live' | 'single' | 'split'
export type ReleasePayload = {
type: ReleaseType
name: string
artwork: string
downloadUrl: string
tracks: string[][]
}
6 changes: 6 additions & 0 deletions app/api/github/GitHubAPILimitReachedError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default class GitHubAPILimitReachedError extends Error {
constructor(message: string = 'GitHub API calls have reached its limit') {
super(message)
Object.setPrototypeOf(this, GitHubAPILimitReachedError.prototype)
}
}
62 changes: 62 additions & 0 deletions app/api/github/github.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import octokit from '@/app/lib/octokit'
import { RateLimiter, RateLimiterOpts } from 'limiter'
import GitHubAPILimitReachedError from './GitHubAPILimitReachedError'
import { GITHUB_API_RATE_LIMIT, GITHUB_COMMITTER } from '@/app/config/github'
import { GitHubFileUploadParams, GitHubGetFileParams } from './types'
import { RequestError } from 'octokit'

/**
* Limiter used to keep track of GitHub API calls.
*/
const limiter = new RateLimiter({
...GITHUB_API_RATE_LIMIT,
fireImmediately: true
} as RateLimiterOpts)

/**
* Remove 1 token from the rate limiter.
*/
const removeToken = async () => {
const remainingRequests = await limiter.removeTokens(1)
if (remainingRequests < 0) {
throw new GitHubAPILimitReachedError()
}
}

/**
* Get the SHA of a file using the GitHub API.
*/
const getFileSHA = async (params: GitHubGetFileParams) => {
try {
await removeToken()
const { data } = await octokit.rest.repos.getContent(params)

if (Array.isArray(data)) {
const [item] = data
return item.sha
}

return data.sha
} catch (err) {
if (err instanceof RequestError && err.status === 404) {
return null
}

throw err
}
}

/**
* Upload a file using the GitHub API.
*/
export const uploadFile = async (params: GitHubFileUploadParams) => {
const { repo, owner, path } = params
const sha = params.sha ?? await getFileSHA({ repo, owner, path })

await removeToken()
return await octokit.rest.repos.createOrUpdateFileContents({
committer: GITHUB_COMMITTER,
...params,
...(sha ? { sha } : {})
})
}
6 changes: 6 additions & 0 deletions app/api/github/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Endpoints } from '@octokit/types'

export type GitHubGetFileParams = Endpoints["GET /repos/{owner}/{repo}/contents/{path}"]["parameters"]

export type GitHubFileUploadParams = Endpoints["PUT /repos/{owner}/{repo}/contents/{path}"]["parameters"]
export type GitHubFileUploadResponse = Endpoints["PUT /repos/{owner}/{repo}/contents/{path}"]["response"]
72 changes: 72 additions & 0 deletions app/api/image/image.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import path from "path"
import { uploadFile } from "../github/github.service"
import { ReleasePayload } from "../artist/types"
import createSlug from "@/app/utils/createSlug"
import { ImageUploadOptions, ImageUploadResponse } from "./types"
import { getBlobSHA } from "@/app/utils/hash"

/**
* The repository which the images will be uploaded.
*/
const IMAGE_REPOSITORY = {
repo: 'images',
owner: 'geracao666',
branch: 'main',
}

const getArtistDir = (artistSlug: string) => {
const firstChar = artistSlug.charAt(0)
const dir = /[a-z]/.test(firstChar) ? firstChar : '0-9'

return path.join(dir, artistSlug)
}

const getCoverPath = (artistSlug: string) => {
const dir = getArtistDir(artistSlug)
return path.join(dir, 'cover.jpg')
}

const getArtworkPath = (artistSlug: string, release: ReleasePayload) => {
const dir = getArtistDir(artistSlug)
const artworkSlug = createSlug(release.name)

return path.join(dir, 'artworks', `${artworkSlug}.jpg`)
}

export const uploadCoverImage = async (
artistName: string,
artistSlug: string,
coverBase64: string,
{ shaFromContent = false }: ImageUploadOptions = {}
): Promise<ImageUploadResponse> => {
const coverPath = getCoverPath(artistSlug)
const sha = shaFromContent && getBlobSHA(coverBase64)
const commit = {
...IMAGE_REPOSITORY,
...(sha ? { sha } : {}),
path: coverPath,
content: coverBase64,
message: `:framed_picture: Upload "${artistName}" cover image.`,
}

return [coverPath, await uploadFile(commit)]
}

export const uploadArtworkImage = async (
artistName: string,
artistSlug: string,
release: ReleasePayload,
{ shaFromContent = false }: ImageUploadOptions = {}
): Promise<ImageUploadResponse> => {
const artworkPath = getArtworkPath(artistSlug, release)
const sha = shaFromContent && getBlobSHA(release.artwork)
const commit = {
...IMAGE_REPOSITORY,
...(sha ? { sha } : {}),
path: artworkPath,
content: release.artwork,
message: `:art: Upload ${artistName}'s "${release.name}" artwork image.`,
}

return [artworkPath, await uploadFile(commit)]
}
7 changes: 7 additions & 0 deletions app/api/image/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { GitHubFileUploadResponse } from "../github/types"

export type ImageUploadOptions = {
shaFromContent?: boolean
}

export type ImageUploadResponse = [string, GitHubFileUploadResponse | null]
2 changes: 0 additions & 2 deletions app/api/tag/route.tsx → app/api/tag/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,5 @@ export async function GET() {
} catch (err) {
// TODO: Handle error correctly
console.log('Error while retrieving tags:', err)
} finally {
await prisma.$disconnect()
}
}
Loading

0 comments on commit 23c3eb9

Please sign in to comment.