diff --git a/Dockerfile b/Dockerfile index 0d90e6f..22c7470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,9 @@ COPY --chown=node:node --from=build /app/dist ./dist COPY --chown=node:node --from=build /app/prisma ./prisma COPY --chown=node:node --from=build /app/src/data ./src/data COPY --chown=node:node openapi ./openapi +COPY --chown=node:node public ./public COPY --chown=node:node scripts/check-public-api-contract.mjs ./scripts/check-public-api-contract.mjs +COPY --chown=node:node scripts/start-container.sh ./scripts/start-container.sh RUN mkdir -p uploads && chown -R node:node /app @@ -46,4 +48,4 @@ USER node EXPOSE 4000 -CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && node dist/prisma/seed.js --public-catalog && node dist/src/server.js"] +CMD ["sh", "scripts/start-container.sh"] diff --git a/README.md b/README.md index e989438..98369dc 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ docker compose --profile seed run --rm seed curl http://localhost:4000/v1/health ``` -API 컨테이너는 시작 시 `prisma migrate deploy`를 먼저 실행합니다. seed는 기존 사용자 데이터를 초기화할 수 있으므로 필요할 때만 별도 프로필로 실행합니다. +API 컨테이너는 시작할 때 `prisma migrate deploy`를 실행한 뒤 `--public-catalog` 공개 시드를 멱등 적용합니다. 이 시작 시드는 지역 플레이리스트와 곡 및 장소 카탈로그만 `upsert`하며 기존 사용자와 리캡 데이터는 초기화하지 않습니다. 전체 데모 seed는 필요한 로컬 환경에서만 별도로 실행합니다. ## Prisma schema diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index e5b94d0..0dba585 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -2371,6 +2371,12 @@ components: description: type: string example: 부산광역시에 어울리는 노래를 추천해드립니다. + coverImageUrl: + type: string + format: uri + backgroundImageUrl: + type: string + format: uri trackCount: type: integer example: 12 diff --git a/public/assets/playlists/andong.webp b/public/assets/playlists/andong.webp new file mode 100644 index 0000000..29ac468 Binary files /dev/null and b/public/assets/playlists/andong.webp differ diff --git a/public/assets/playlists/busan.webp b/public/assets/playlists/busan.webp new file mode 100644 index 0000000..84585fb Binary files /dev/null and b/public/assets/playlists/busan.webp differ diff --git a/public/assets/playlists/chuncheon.webp b/public/assets/playlists/chuncheon.webp new file mode 100644 index 0000000..225f2c0 Binary files /dev/null and b/public/assets/playlists/chuncheon.webp differ diff --git a/public/assets/playlists/daegu.webp b/public/assets/playlists/daegu.webp new file mode 100644 index 0000000..18f975d Binary files /dev/null and b/public/assets/playlists/daegu.webp differ diff --git a/public/assets/playlists/daejeon.webp b/public/assets/playlists/daejeon.webp new file mode 100644 index 0000000..229aae8 Binary files /dev/null and b/public/assets/playlists/daejeon.webp differ diff --git a/public/assets/playlists/gangneung.webp b/public/assets/playlists/gangneung.webp new file mode 100644 index 0000000..1d14afe Binary files /dev/null and b/public/assets/playlists/gangneung.webp differ diff --git a/public/assets/playlists/geoje.webp b/public/assets/playlists/geoje.webp new file mode 100644 index 0000000..687c2bb Binary files /dev/null and b/public/assets/playlists/geoje.webp differ diff --git a/public/assets/playlists/gwangju.webp b/public/assets/playlists/gwangju.webp new file mode 100644 index 0000000..53b6053 Binary files /dev/null and b/public/assets/playlists/gwangju.webp differ diff --git a/public/assets/playlists/gyeongju.webp b/public/assets/playlists/gyeongju.webp new file mode 100644 index 0000000..91529c6 Binary files /dev/null and b/public/assets/playlists/gyeongju.webp differ diff --git a/public/assets/playlists/incheon.webp b/public/assets/playlists/incheon.webp new file mode 100644 index 0000000..5cab4bd Binary files /dev/null and b/public/assets/playlists/incheon.webp differ diff --git a/public/assets/playlists/jeju.webp b/public/assets/playlists/jeju.webp new file mode 100644 index 0000000..1780789 Binary files /dev/null and b/public/assets/playlists/jeju.webp differ diff --git a/public/assets/playlists/jeonju.webp b/public/assets/playlists/jeonju.webp new file mode 100644 index 0000000..e1d1381 Binary files /dev/null and b/public/assets/playlists/jeonju.webp differ diff --git a/public/assets/playlists/seoul.webp b/public/assets/playlists/seoul.webp new file mode 100644 index 0000000..80f666c Binary files /dev/null and b/public/assets/playlists/seoul.webp differ diff --git a/public/assets/playlists/sokcho.webp b/public/assets/playlists/sokcho.webp new file mode 100644 index 0000000..a48ebe0 Binary files /dev/null and b/public/assets/playlists/sokcho.webp differ diff --git a/public/assets/playlists/suwon.webp b/public/assets/playlists/suwon.webp new file mode 100644 index 0000000..c69c542 Binary files /dev/null and b/public/assets/playlists/suwon.webp differ diff --git a/public/assets/playlists/tongyeong.webp b/public/assets/playlists/tongyeong.webp new file mode 100644 index 0000000..b61fd9a Binary files /dev/null and b/public/assets/playlists/tongyeong.webp differ diff --git a/public/assets/playlists/yeosu.webp b/public/assets/playlists/yeosu.webp new file mode 100644 index 0000000..c97365a Binary files /dev/null and b/public/assets/playlists/yeosu.webp differ diff --git a/scripts/check-public-api-contract.mjs b/scripts/check-public-api-contract.mjs index 800ca7f..d6853d0 100644 --- a/scripts/check-public-api-contract.mjs +++ b/scripts/check-public-api-contract.mjs @@ -188,13 +188,47 @@ async function verifyMusicMetadata() { async function verifyPlaylistCatalog() { try { - const payload = await fetchJson('/v1/playlists/geoje-ocean', { + const payload = await fetchJson('/v1/playlists/jeju-island', { authenticated: true, }); const playlist = payload?.data; - if (playlist?.id !== 'geoje-ocean' || !Array.isArray(playlist.tracks) || playlist.tracks.length === 0) { - addError('/v1/playlists/geoje-ocean did not return a seeded playlist with tracks.'); + if (playlist?.id !== 'jeju-island' || !Array.isArray(playlist.tracks) || playlist.tracks.length === 0) { + addError('/v1/playlists/jeju-island did not return a seeded playlist with tracks.'); + } + + if (typeof playlist?.backgroundImageUrl !== 'string') { + addError('/v1/playlists/jeju-island did not return a background image URL.'); + } else { + const artworkPath = new URL(playlist.backgroundImageUrl).pathname; + const { response } = await fetchText(artworkPath); + + if (!response.ok || response.headers.get('content-type') !== 'image/webp') { + addError(`${artworkPath} did not return a WebP playlist image.`); + } + } + + const featuredPayload = await fetchJson( + '/v1/home/featured-playlists?limit=20&locationRecommendationEnabled=true&recommendationMode=travel&lat=33.4996&lng=126.5312', + { authenticated: true }, + ); + const featuredIds = new Set( + Array.isArray(featuredPayload?.data) + ? featuredPayload.data.map((item) => item?.id) + : [], + ); + + if ( + !Array.isArray(featuredPayload?.data) || + featuredPayload.data.some((item) => typeof item?.coverImageUrl !== 'string') + ) { + addError('/v1/home/featured-playlists contains an item without a cover image URL.'); + } + + for (const regionalPlaylistId of ['jeju-island', 'gangneung-sea', 'yeosu-night-sea']) { + if (!featuredIds.has(regionalPlaylistId)) { + addError(`/v1/home/featured-playlists is missing ${regionalPlaylistId}.`); + } } } catch (error) { addError(error instanceof Error ? error.message : String(error)); diff --git a/scripts/start-container.sh b/scripts/start-container.sh new file mode 100644 index 0000000..c24d24d --- /dev/null +++ b/scripts/start-container.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -eu + +./node_modules/.bin/prisma migrate deploy +node dist/prisma/seed.js --public-catalog +exec node dist/src/server.js diff --git a/src/app.ts b/src/app.ts index fd55b09..94e3fac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import express from 'express'; +import path from 'node:path'; import { env } from './config/env.js'; import { ERROR_MESSAGES } from './constants/error.constants.js'; @@ -32,6 +33,13 @@ export function createApp() { app.use(urlencodedBodyParserMiddleware); app.use(requestLoggerMiddleware); app.use(createLegalRouter()); + app.use( + '/assets', + express.static(path.resolve('public/assets'), { + immutable: true, + maxAge: '1y', + }), + ); // Uploaded photos are served only through an authenticated, ownership/visibility-checked // endpoint (see uploads.router.ts) — there is no unauthenticated static file serving of // the uploads directory. diff --git a/src/data/regional-playlist-data.ts b/src/data/regional-playlist-data.ts new file mode 100644 index 0000000..baabd44 --- /dev/null +++ b/src/data/regional-playlist-data.ts @@ -0,0 +1,104 @@ +export const regionalPlaylistFallbacks = [ + { + aliases: ['서울', '남산', '한강', '홍대', '성수'], + lat: 37.5665, + lng: 126.978, + playlistId: 'seoul-night', + }, + { + aliases: ['부산', '광안리', '해운대', '태종대'], + lat: 35.1796, + lng: 129.0756, + playlistId: 'busan-ocean', + }, + { + aliases: ['거제', '바람의 언덕', '매미성'], + lat: 34.8806, + lng: 128.6211, + playlistId: 'geoje-ocean', + }, + { + aliases: ['제주', '서귀포', '성산', '우도', '애월'], + lat: 33.4996, + lng: 126.5312, + playlistId: 'jeju-island', + }, + { + aliases: ['강릉', '경포대', '안목'], + lat: 37.7519, + lng: 128.8761, + playlistId: 'gangneung-sea', + }, + { + aliases: ['속초', '설악산', '대포항'], + lat: 38.207, + lng: 128.5918, + playlistId: 'sokcho-sunrise', + }, + { + aliases: ['춘천', '남이섬', '소양강'], + lat: 37.8813, + lng: 127.7298, + playlistId: 'chuncheon-train', + }, + { + aliases: ['인천', '송도', '월미도', '영종도'], + lat: 37.4563, + lng: 126.7052, + playlistId: 'incheon-sunset', + }, + { + aliases: ['수원', '화성행궁', '광교'], + lat: 37.2636, + lng: 127.0286, + playlistId: 'suwon-fortress', + }, + { + aliases: ['대전', '성심당', '엑스포'], + lat: 36.3504, + lng: 127.3845, + playlistId: 'daejeon-city', + }, + { + aliases: ['대구', '동성로', '수성못'], + lat: 35.8714, + lng: 128.6014, + playlistId: 'daegu-energy', + }, + { + aliases: ['안동', '하회마을', '월영교'], + lat: 36.5684, + lng: 128.7294, + playlistId: 'andong-heritage', + }, + { + aliases: ['경주', '불국사', '첨성대', '황리단길'], + lat: 35.8562, + lng: 129.2247, + playlistId: 'gyeongju-starlight', + }, + { + aliases: ['전주', '한옥마을', '경기전'], + lat: 35.8242, + lng: 127.148, + playlistId: 'jeonju-hanok', + }, + { + aliases: ['광주', '무등산', '양림동'], + lat: 35.1595, + lng: 126.8526, + playlistId: 'gwangju-art', + }, + { + aliases: ['여수', '오동도', '돌산'], + lat: 34.7604, + lng: 127.6622, + playlistId: 'yeosu-night-sea', + }, + { + aliases: ['통영', '동피랑', '미륵산'], + lat: 34.8544, + lng: 128.4332, + playlistId: 'tongyeong-harbor', + }, +] as const; diff --git a/src/data/seed-data.ts b/src/data/seed-data.ts index 2a12a1f..8104a6e 100644 --- a/src/data/seed-data.ts +++ b/src/data/seed-data.ts @@ -5,6 +5,28 @@ export const defaultUser = { providerUserId: 'local-soundlog-user', }; +function createPlaylistArtworkPath(region: string) { + return `/assets/playlists/${region}.webp`; +} + +function createMusicSearchTrack(input: { + artist: string; + fallbackColor: string; + id: string; + title: string; +}) { + const query = encodeURIComponent(`${input.artist} ${input.title}`); + const searchUrl = `https://music.youtube.com/search?q=${query}`; + + return { + ...input, + externalUrl: searchUrl, + platformUrls: { + youtubeMusic: searchUrl, + } satisfies Prisma.JsonObject, + }; +} + export const tracks = [ { id: 'seoul-city', @@ -58,6 +80,150 @@ export const tracks = [ artist: 'The Black Skirts', fallbackColor: '#1F2937', }, + createMusicSearchTrack({ + id: 'jeju-blue-night', + title: '제주도의 푸른 밤', + artist: '태연', + fallbackColor: '#2D6A8A', + }), + createMusicSearchTrack({ + id: 'wind-coming', + title: '바람이 불어오는 곳', + artist: '김광석', + fallbackColor: '#54735D', + }), + createMusicSearchTrack({ + id: 'island-winner', + title: 'ISLAND', + artist: 'WINNER', + fallbackColor: '#1F8FA8', + }), + createMusicSearchTrack({ + id: 'summer-within', + title: '여름 안에서', + artist: '듀스', + fallbackColor: '#E88D4B', + }), + createMusicSearchTrack({ + id: 'wave-ateez', + title: 'WAVE', + artist: 'ATEEZ', + fallbackColor: '#2179A8', + }), + createMusicSearchTrack({ + id: 'blue-bigbang', + title: 'BLUE', + artist: 'BIGBANG', + fallbackColor: '#375A8C', + }), + createMusicSearchTrack({ + id: 'chuncheon-train', + title: '춘천가는 기차', + artist: '김현철', + fallbackColor: '#6E7C65', + }), + createMusicSearchTrack({ + id: 'departure-kimdongryul', + title: '출발', + artist: '김동률', + fallbackColor: '#C16C46', + }), + createMusicSearchTrack({ + id: 'airplane-turtles', + title: '비행기', + artist: '거북이', + fallbackColor: '#5B8DC9', + }), + createMusicSearchTrack({ + id: 'night-drive-gfriend', + title: 'Night Drive', + artist: '여자친구', + fallbackColor: '#4D457A', + }), + createMusicSearchTrack({ + id: 'square-yerinbaek', + title: 'Square (2017)', + artist: '백예린', + fallbackColor: '#6B667C', + }), + createMusicSearchTrack({ + id: 'across-universe-yerinbaek', + title: '우주를 건너', + artist: '백예린', + fallbackColor: '#373D6B', + }), + createMusicSearchTrack({ + id: 'old-song-standing-egg', + title: '오래된 노래', + artist: '스탠딩 에그', + fallbackColor: '#8E735B', + }), + createMusicSearchTrack({ + id: 'walk-yerinbaek', + title: '산책', + artist: '백예린', + fallbackColor: '#63806A', + }), + createMusicSearchTrack({ + id: 'dandelion-oohyo', + title: '민들레', + artist: '우효', + fallbackColor: '#A98A45', + }), + createMusicSearchTrack({ + id: 'yeosu-night-sea', + title: '여수 밤바다', + artist: '버스커 버스커', + fallbackColor: '#233D62', + }), + createMusicSearchTrack({ + id: 'about-romance', + title: '낭만에 대하여', + artist: '최백호', + fallbackColor: '#604C42', + }), + createMusicSearchTrack({ + id: 'deep-night-crying-nut', + title: '밤이 깊었네', + artist: '크라잉넛', + fallbackColor: '#4C315C', + }), + createMusicSearchTrack({ + id: 'busan-vacance', + title: '부산 바캉스', + artist: '레게 강 같은 평화', + fallbackColor: '#146B72', + }), + createMusicSearchTrack({ + id: 'daejeon-blues', + title: '대전 블루스', + artist: '조용필', + fallbackColor: '#46577B', + }), + createMusicSearchTrack({ + id: 'beautiful-country', + title: '아름다운 나라', + artist: '신문희', + fallbackColor: '#8A5D45', + }), + createMusicSearchTrack({ + id: 'dynamite-bts', + title: 'Dynamite', + artist: 'BTS', + fallbackColor: '#D1788D', + }), + createMusicSearchTrack({ + id: 'very-nice-seventeen', + title: '아주 NICE', + artist: 'SEVENTEEN', + fallbackColor: '#D45D6D', + }), + createMusicSearchTrack({ + id: 'way-back-home-shaun', + title: 'Way Back Home', + artist: '숀', + fallbackColor: '#436D7C', + }), ] as const; export const places = [ @@ -123,8 +289,8 @@ export const playlists = [ durationText: '32:00분', placeName: '바람의 언덕과 해안도로', reason: '거제의 바다 바람과 섬길 드라이브에 어울리는 음악이에요', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + coverImageUrl: createPlaylistArtworkPath('geoje'), + backgroundImageUrl: createPlaylistArtworkPath('geoje'), source: 'location', trackIds: [ 'geoje-dinosaur-ridge', @@ -140,14 +306,21 @@ export const playlists = [ { id: 'busan-ocean', regionName: '부산', - description: '부산광역시에 어울리는 노래를 추천해드립니다.', - durationText: '40:00분', + description: '광안리와 해운대의 시원한 바람에 어울리는 플레이리스트예요.', + durationText: '24:00분', placeName: '광안리 해변', reason: '부산 해변 산책과 어울리는 음악이에요', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + coverImageUrl: createPlaylistArtworkPath('busan'), + backgroundImageUrl: createPlaylistArtworkPath('busan'), source: 'location', - trackIds: ['seoul-city', 'moon-seoul', 'hangang', 'night-letter', 'seoul-night-track'], + trackIds: [ + 'busan-vacance', + 'wave-ateez', + 'summer-within', + 'geoje-travel', + 'deep-night-crying-nut', + 'blue-bigbang', + ], }, { id: 'seoul-night', @@ -156,11 +329,278 @@ export const playlists = [ durationText: '36:00분', placeName: '서울 야경', reason: '현재 위치를 바탕으로 추천했어요', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + coverImageUrl: createPlaylistArtworkPath('seoul'), + backgroundImageUrl: createPlaylistArtworkPath('seoul'), source: 'trend', trackIds: ['seoul-city', 'moon-seoul', 'hangang', 'night-letter', 'hongdae-kondae', 'seoul-night-track'], }, + { + id: 'jeju-island', + regionName: '제주', + description: '제주의 바람과 푸른 해안도로를 따라 듣기 좋은 음악이에요.', + durationText: '28:00분', + placeName: '애월 해안도로와 성산', + reason: '제주의 섬 풍경과 느긋한 드라이브에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('jeju'), + backgroundImageUrl: createPlaylistArtworkPath('jeju'), + source: 'location', + trackIds: [ + 'jeju-blue-night', + 'island-winner', + 'wind-coming', + 'geoje-seasons', + 'geoje-everything', + 'geoje-travel', + 'blue-bigbang', + ], + }, + { + id: 'gangneung-sea', + regionName: '강릉', + description: '경포 바다와 안목의 커피 향을 함께 담은 플레이리스트예요.', + durationText: '24:00분', + placeName: '경포해변과 안목해변', + reason: '동해의 파도와 여유로운 카페 산책에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('gangneung'), + backgroundImageUrl: createPlaylistArtworkPath('gangneung'), + source: 'location', + trackIds: [ + 'wave-ateez', + 'summer-within', + 'departure-kimdongryul', + 'geoje-seasons', + 'night-letter', + 'wind-coming', + ], + }, + { + id: 'sokcho-sunrise', + regionName: '속초', + description: '설악산에서 동해 일출까지 이어지는 여행을 위한 음악이에요.', + durationText: '24:00분', + placeName: '설악산과 대포항', + reason: '산과 바다를 함께 만나는 속초의 아침에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('sokcho'), + backgroundImageUrl: createPlaylistArtworkPath('sokcho'), + source: 'location', + trackIds: [ + 'departure-kimdongryul', + 'wind-coming', + 'dandelion-oohyo', + 'geoje-dinosaur-ridge', + 'old-song-standing-egg', + 'blue-bigbang', + ], + }, + { + id: 'chuncheon-train', + regionName: '춘천', + description: '기차를 타고 떠나는 호수 도시의 설렘을 담았어요.', + durationText: '24:00분', + placeName: '소양강과 남이섬', + reason: '춘천의 호숫길과 느긋한 산책에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('chuncheon'), + backgroundImageUrl: createPlaylistArtworkPath('chuncheon'), + source: 'location', + trackIds: [ + 'chuncheon-train', + 'old-song-standing-egg', + 'dandelion-oohyo', + 'wind-coming', + 'night-letter', + 'geoje-travel', + ], + }, + { + id: 'incheon-sunset', + regionName: '인천', + description: '송도의 도시 풍경과 월미도의 노을을 잇는 플레이리스트예요.', + durationText: '24:00분', + placeName: '송도와 월미도', + reason: '여행의 출발과 서해 노을에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('incheon'), + backgroundImageUrl: createPlaylistArtworkPath('incheon'), + source: 'location', + trackIds: [ + 'airplane-turtles', + 'departure-kimdongryul', + 'night-drive-gfriend', + 'square-yerinbaek', + 'blue-bigbang', + 'seoul-city', + ], + }, + { + id: 'suwon-fortress', + regionName: '수원', + description: '성곽길의 낮과 광교의 밤을 함께 걷는 플레이리스트예요.', + durationText: '24:00분', + placeName: '화성행궁과 광교호수공원', + reason: '도시 산책과 야경을 함께 즐기는 수원 여행에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('suwon'), + backgroundImageUrl: createPlaylistArtworkPath('suwon'), + source: 'location', + trackIds: [ + 'seoul-city', + 'way-back-home-shaun', + 'old-song-standing-egg', + 'night-drive-gfriend', + 'very-nice-seventeen', + 'dandelion-oohyo', + ], + }, + { + id: 'daejeon-city', + regionName: '대전', + description: '기차 여행의 추억과 도심의 여유를 담은 플레이리스트예요.', + durationText: '24:00분', + placeName: '대전역과 엑스포과학공원', + reason: '머물다 다시 출발하는 도시 여행에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('daejeon'), + backgroundImageUrl: createPlaylistArtworkPath('daejeon'), + source: 'location', + trackIds: [ + 'daejeon-blues', + 'departure-kimdongryul', + 'old-song-standing-egg', + 'hongdae-kondae', + 'night-drive-gfriend', + 'dandelion-oohyo', + ], + }, + { + id: 'daegu-energy', + regionName: '대구', + description: '활기찬 도심과 수성못의 저녁을 오가는 플레이리스트예요.', + durationText: '24:00분', + placeName: '동성로와 수성못', + reason: '뜨거운 도시의 에너지와 밤 산책에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('daegu'), + backgroundImageUrl: createPlaylistArtworkPath('daegu'), + source: 'location', + trackIds: [ + 'dynamite-bts', + 'very-nice-seventeen', + 'summer-within', + 'deep-night-crying-nut', + 'wave-ateez', + 'geoje-travel', + ], + }, + { + id: 'andong-heritage', + regionName: '안동', + description: '고즈넉한 마을과 월영교의 밤을 위한 플레이리스트예요.', + durationText: '24:00분', + placeName: '하회마을과 월영교', + reason: '오래된 풍경을 천천히 걷는 시간에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('andong'), + backgroundImageUrl: createPlaylistArtworkPath('andong'), + source: 'location', + trackIds: [ + 'beautiful-country', + 'wind-coming', + 'about-romance', + 'old-song-standing-egg', + 'night-letter', + 'dandelion-oohyo', + ], + }, + { + id: 'gyeongju-starlight', + regionName: '경주', + description: '천년 유적과 황리단길의 밤을 잇는 플레이리스트예요.', + durationText: '24:00분', + placeName: '첨성대와 황리단길', + reason: '오래된 도시의 고요한 야경에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('gyeongju'), + backgroundImageUrl: createPlaylistArtworkPath('gyeongju'), + source: 'location', + trackIds: [ + 'beautiful-country', + 'wind-coming', + 'old-song-standing-egg', + 'night-letter', + 'dandelion-oohyo', + 'about-romance', + ], + }, + { + id: 'jeonju-hanok', + regionName: '전주', + description: '한옥 골목의 낮과 조용한 저녁을 담은 플레이리스트예요.', + durationText: '24:00분', + placeName: '전주한옥마을과 경기전', + reason: '고즈넉한 골목과 여유로운 미식 여행에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('jeonju'), + backgroundImageUrl: createPlaylistArtworkPath('jeonju'), + source: 'location', + trackIds: [ + 'beautiful-country', + 'about-romance', + 'old-song-standing-egg', + 'night-letter', + 'wind-coming', + 'geoje-travel', + ], + }, + { + id: 'gwangju-art', + regionName: '광주', + description: '예술 산책과 무등산의 잔잔한 풍경을 위한 음악이에요.', + durationText: '24:00분', + placeName: '양림동과 무등산', + reason: '도시의 예술 감성과 자연을 함께 만나는 여행에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('gwangju'), + backgroundImageUrl: createPlaylistArtworkPath('gwangju'), + source: 'location', + trackIds: [ + 'square-yerinbaek', + 'across-universe-yerinbaek', + 'dandelion-oohyo', + 'night-drive-gfriend', + 'walk-yerinbaek', + 'geoje-everything', + ], + }, + { + id: 'yeosu-night-sea', + regionName: '여수', + description: '돌산대교 불빛과 잔잔한 밤바다를 위한 플레이리스트예요.', + durationText: '24:00분', + placeName: '여수 밤바다와 오동도', + reason: '낭만적인 항구의 밤과 해안 산책에 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('yeosu'), + backgroundImageUrl: createPlaylistArtworkPath('yeosu'), + source: 'location', + trackIds: [ + 'yeosu-night-sea', + 'about-romance', + 'deep-night-crying-nut', + 'wave-ateez', + 'blue-bigbang', + 'geoje-travel', + ], + }, + { + id: 'tongyeong-harbor', + regionName: '통영', + description: '동피랑 골목과 섬 사이 항해를 떠올리는 플레이리스트예요.', + durationText: '24:00분', + placeName: '동피랑과 미륵산', + reason: '항구의 낭만과 섬 풍경을 천천히 즐길 때 어울리는 음악이에요', + coverImageUrl: createPlaylistArtworkPath('tongyeong'), + backgroundImageUrl: createPlaylistArtworkPath('tongyeong'), + source: 'location', + trackIds: [ + 'about-romance', + 'island-winner', + 'geoje-seasons', + 'geoje-everything', + 'wind-coming', + 'yeosu-night-sea', + ], + }, { id: 'calm-walk', regionName: '잔잔한 산책', diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 4be9958..bb25a9f 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -3,6 +3,7 @@ import { ERROR_MESSAGES } from '../constants/error.constants.js'; import { findMockTrack, mockDb } from '../mock/mock-db.js'; import { badRequest, forbidden, notFound } from '../utils/http-error.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; +import { findRegionalPlaylistId } from '../utils/regional-playlist.js'; import { createPublicId } from '../utils/tokens.js'; type TrackDto = { @@ -56,6 +57,14 @@ function compact>(value: T) { ) as Partial; } +function normalizePlaylistArtworkUrl(url?: string) { + if (!url || !url.startsWith('/')) { + return url; + } + + return `${env.UPLOAD_PUBLIC_BASE_URL.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; +} + function normalizeRoutePoints(routePoints?: RoutePointDto[]) { if (!routePoints) { return undefined; @@ -790,19 +799,27 @@ function mateRequestToDto(request: (typeof mockDb.travelMateRequests)[number]) { }; } -function getDefaultPlaylistId(params?: { lat?: number; placeId?: string }) { +function getDefaultPlaylistId(params?: { lat?: number; lng?: number; placeId?: string }) { + let placeText = params?.placeId ?? ''; + if (params?.placeId) { const place = mockDb.places.find( (item) => item.id === toStoragePlaceId(params.placeId), ); - const placeText = [place?.title, place?.category, place?.overview].join(' '); - - if (/해변|바다|해수욕장|ocean|beach/i.test(placeText)) { - return 'busan-ocean'; - } + placeText = [ + params.placeId, + place?.title, + place?.address, + place?.category, + place?.overview, + ].join(' '); } - return params?.lat && params.lat < 36.5 ? 'busan-ocean' : 'seoul-night'; + return findRegionalPlaylistId({ + lat: params?.lat, + lng: params?.lng, + placeText, + }); } function toPublicPlaceId(id: string) { @@ -1039,6 +1056,7 @@ export const mockSoundlogService = { async getFeaturedPlaylists(_user: unknown, params: { lat?: number; limit?: number; + lng?: number; locationRecommendationEnabled: boolean; placeId?: string; recommendationMode?: 'everyday' | 'travel'; @@ -1046,8 +1064,12 @@ export const mockSoundlogService = { const preferredId = params.recommendationMode === 'travel' && params.locationRecommendationEnabled && - (params.lat || params.placeId) - ? getDefaultPlaylistId({ lat: params.lat, placeId: params.placeId }) + (params.lat !== undefined || params.placeId) + ? getDefaultPlaylistId({ + lat: params.lat, + lng: params.lng, + placeId: params.placeId, + }) : undefined; return [...mockDb.playlists] @@ -1068,6 +1090,8 @@ export const mockSoundlogService = { id: playlist.id, regionName: playlist.regionName, description: playlist.description, + coverImageUrl: normalizePlaylistArtworkUrl(playlist.coverImageUrl), + backgroundImageUrl: normalizePlaylistArtworkUrl(playlist.backgroundImageUrl), trackCount: playlist.trackIds.length, durationText: playlist.durationText, source: playlist.source, @@ -1126,6 +1150,7 @@ export const mockSoundlogService = { }, _idempotencyKey?: string) { const playlistId = getDefaultPlaylistId({ lat: input.location?.lat, + lng: input.location?.lng, placeId: input.placeId, }); const playlist = mockDb.playlists.find((item) => item.id === playlistId); @@ -1151,6 +1176,7 @@ export const mockSoundlogService = { }) { const playlistId = getDefaultPlaylistId({ lat: input.location?.lat, + lng: input.location?.lng, }); const playlist = mockDb.playlists.find((item) => item.id === playlistId); @@ -1165,7 +1191,7 @@ export const mockSoundlogService = { }); }, - async getPlaylist(_userId: string | undefined, playlistId: string, query: { lat?: number; placeId?: string }) { + async getPlaylist(_userId: string | undefined, playlistId: string, query: { lat?: number; lng?: number; placeId?: string }) { const id = playlistId === 'fallback' ? getDefaultPlaylistId(query) : playlistId; const playlist = mockDb.playlists.find((item) => item.id === id); diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index 4b2073e..9a8e5ad 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -29,6 +29,7 @@ import { UPLOAD_FILE_ID_PATTERN } from '../middlewares/upload.middleware.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { createPublicId } from '../utils/tokens.js'; import { badRequest, forbidden, notFound } from '../utils/http-error.js'; +import { findRegionalPlaylistId } from '../utils/regional-playlist.js'; import { reverseGeocodeLocation } from './reverse-geocoding.service.js'; type MaybeUser = { id: string } | undefined; @@ -605,6 +606,14 @@ function normalizePublicUrl(baseUrl: string, path: string) { return `${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`; } +function normalizePlaylistArtworkUrl(url?: string | null) { + if (!url || !url.startsWith('/')) { + return url ?? undefined; + } + + return normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, url); +} + function getLocalUploadedFilePath(photoUrl?: string | null) { if (!photoUrl) { return undefined; @@ -1144,8 +1153,8 @@ function playlistSummaryToDto(playlist: PlaylistSummarySource) { placeName: playlist.placeName ?? undefined, description: playlist.description ?? undefined, reason: playlist.reason, - coverImageUrl: playlist.coverImageUrl ?? undefined, - backgroundImageUrl: playlist.backgroundImageUrl ?? undefined, + coverImageUrl: normalizePlaylistArtworkUrl(playlist.coverImageUrl), + backgroundImageUrl: normalizePlaylistArtworkUrl(playlist.backgroundImageUrl), trackCount: playlist.trackCount, durationText: playlist.durationText, }); @@ -1180,8 +1189,8 @@ async function playlistToDto(playlist: PlaylistWithTracks, userId?: string) { regionName: playlist.regionName, placeName: playlist.placeName ?? undefined, reason: playlist.reason, - coverImageUrl: playlist.coverImageUrl ?? undefined, - backgroundImageUrl: playlist.backgroundImageUrl ?? undefined, + coverImageUrl: normalizePlaylistArtworkUrl(playlist.coverImageUrl), + backgroundImageUrl: normalizePlaylistArtworkUrl(playlist.backgroundImageUrl), trackCount: playlist.trackCount, durationText: playlist.durationText, context: (playlist.context as RecommendationContext | null) ?? undefined, @@ -1676,23 +1685,27 @@ function scoreMoodRecommendation( return score; } -async function findDefaultPlaylist(params?: { lat?: number; placeId?: string }) { +async function findDefaultPlaylist(params?: { lat?: number; lng?: number; placeId?: string }) { + let placeText = params?.placeId ?? ''; + if (params?.placeId) { const place = await prisma.place.findUnique({ where: { id: toStoragePlaceId(params.placeId) }, }); - const placeText = [place?.title, place?.category, place?.overview].join(' '); - - if (/해변|바다|해수욕장|ocean|beach/i.test(placeText)) { - return 'busan-ocean'; - } - } - - if (params?.lat && params.lat < 36.5) { - return 'busan-ocean'; + placeText = [ + params.placeId, + place?.title, + place?.address, + place?.category, + place?.overview, + ].join(' '); } - return 'seoul-night'; + return findRegionalPlaylistId({ + lat: params?.lat, + lng: params?.lng, + placeText, + }); } export const soundlogService = { @@ -1857,6 +1870,7 @@ export const soundlogService = { params: { lat?: number; limit?: number; + lng?: number; locationRecommendationEnabled: boolean; placeId?: string; recommendationMode?: 'everyday' | 'travel'; @@ -1867,15 +1881,19 @@ export const soundlogService = { where: { OR: [ { source: null }, - { source: { not: 'personalized' } }, + { source: { in: ['location', 'trend'] } }, ], }, }); const preferredId = params.recommendationMode === 'travel' && params.locationRecommendationEnabled && - (params.lat || params.placeId) - ? await findDefaultPlaylist({ lat: params.lat, placeId: params.placeId }) + (params.lat !== undefined || params.placeId) + ? await findDefaultPlaylist({ + lat: params.lat, + lng: params.lng, + placeId: params.placeId, + }) : undefined; return playlists @@ -1896,6 +1914,8 @@ export const soundlogService = { id: playlist.id, regionName: playlist.regionName, description: playlist.description ?? '', + coverImageUrl: normalizePlaylistArtworkUrl(playlist.coverImageUrl), + backgroundImageUrl: normalizePlaylistArtworkUrl(playlist.backgroundImageUrl), trackCount: playlist.trackCount, durationText: playlist.durationText, source: playlist.source ?? undefined, @@ -1966,6 +1986,7 @@ export const soundlogService = { const playlistId = await findDefaultPlaylist({ lat: input.location?.lat, + lng: input.location?.lng, placeId: input.placeId, }); const playlist = await prisma.playlist.findUnique({ @@ -2006,6 +2027,7 @@ export const soundlogService = { const playlistId = await findDefaultPlaylist({ lat: input.location?.lat, + lng: input.location?.lng, placeId: input.placeId, }); const playlist = await prisma.playlist.findUnique({ @@ -2033,7 +2055,7 @@ export const soundlogService = { async getPlaylist( userId: string | undefined, playlistId: string, - query: { lat?: number; placeId?: string }, + query: { lat?: number; lng?: number; placeId?: string }, ) { const id = playlistId === 'fallback' ? await findDefaultPlaylist(query) : playlistId; const playlist = await prisma.playlist.findUnique({ diff --git a/src/utils/regional-playlist.ts b/src/utils/regional-playlist.ts new file mode 100644 index 0000000..72899b1 --- /dev/null +++ b/src/utils/regional-playlist.ts @@ -0,0 +1,52 @@ +import { regionalPlaylistFallbacks } from '../data/regional-playlist-data.js'; + +type RegionalPlaylistInput = { + lat?: number; + lng?: number; + placeText?: string; +}; + +function distanceScore( + origin: { lat: number; lng: number }, + target: { lat: number; lng: number }, +) { + const latDistance = origin.lat - target.lat; + const lngDistance = (origin.lng - target.lng) * Math.cos((origin.lat * Math.PI) / 180); + + return latDistance ** 2 + lngDistance ** 2; +} + +export function findRegionalPlaylistId({ + lat, + lng, + placeText = '', +}: RegionalPlaylistInput) { + const normalizedPlaceText = placeText.toLocaleLowerCase('ko-KR'); + const placeMatch = regionalPlaylistFallbacks.find((region) => + region.aliases.some((alias) => + normalizedPlaceText.includes(alias.toLocaleLowerCase('ko-KR')), + ), + ); + + if (placeMatch) { + return placeMatch.playlistId; + } + + if (lat === undefined || lng === undefined) { + return 'seoul-night'; + } + + const isInKorea = lat >= 32 && lat <= 39.5 && lng >= 124 && lng <= 132; + + if (!isInKorea) { + return 'seoul-night'; + } + + const nearestRegion = regionalPlaylistFallbacks.reduce((nearest, region) => + distanceScore({ lat, lng }, region) < distanceScore({ lat, lng }, nearest) + ? region + : nearest, + ); + + return nearestRegion.playlistId; +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 7bf41ab..e1d1a59 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -501,11 +501,21 @@ describe('Soundlog API', () => { .query({ locationRecommendationEnabled: true, lat: 35.1532, lng: 129.1186 }); expect(featured.status).toBe(200); expect(featured.body.data.length).toBeGreaterThan(0); + expect(featured.body.data[0]).toEqual( + expect.objectContaining({ + coverImageUrl: expect.stringMatching(/^http:\/\/localhost:4000\/assets\/playlists\//), + }), + ); expect( featured.body.data.some((playlist: { source?: string }) => playlist.source === 'personalized', ), ).toBe(false); + expect( + featured.body.data.some((playlist: { source?: string }) => + playlist.source === 'ml-recommendation', + ), + ).toBe(false); const unauthenticatedFeatured = await request(app) .get('/v1/home/featured-playlists') @@ -513,6 +523,11 @@ describe('Soundlog API', () => { expect(unauthenticatedFeatured.status).toBe(401); expect(unauthenticatedFeatured.body.error.code).toBe('UNAUTHORIZED'); + const playlistArtwork = await request(app).get('/assets/playlists/busan.webp'); + expect(playlistArtwork.status).toBe(200); + expect(playlistArtwork.headers['content-type']).toBe('image/webp'); + expect(playlistArtwork.headers['cache-control']).toContain('immutable'); + const playlistIds = new Set(); for (const moodFilter of ['잔잔한', '신나는', '시원한', '설레는', '감성적인']) { @@ -665,7 +680,7 @@ describe('Soundlog API', () => { it('handles library APIs', async () => { const updated = await request(app) - .put('/v1/library/tracks/moon-seoul') + .put('/v1/library/tracks/busan-vacance') .set('Authorization', authHeader) .send({ action: 'like', playlistId: 'busan-ocean' }); expect(updated.status).toBe(200); @@ -677,16 +692,16 @@ describe('Soundlog API', () => { .query({ kind: 'liked' }); expect(list.status).toBe(200); expect(list.body.page.limit).toBeGreaterThan(0); - const moonRecord = list.body.data.find( - (item: { track: { id: string } }) => item.track.id === 'moon-seoul', + const busanRecord = list.body.data.find( + (item: { track: { id: string } }) => item.track.id === 'busan-vacance', ); - expect(moonRecord).toBeTruthy(); - expect(moonRecord.playlist).toMatchObject({ + expect(busanRecord).toBeTruthy(); + expect(busanRecord.playlist).toMatchObject({ id: 'busan-ocean', placeName: '광안리 해변', regionName: '부산', - trackCount: 5, + trackCount: 6, }); }); diff --git a/tests/mock-soundlog.service.test.ts b/tests/mock-soundlog.service.test.ts index 379d543..76e2fe0 100644 --- a/tests/mock-soundlog.service.test.ts +++ b/tests/mock-soundlog.service.test.ts @@ -49,6 +49,7 @@ describe('mockSoundlogService', () => { const playlists = await mockSoundlogService.getFeaturedPlaylists(undefined, { lat: 35.1532, limit: 3, + lng: 129.1186, locationRecommendationEnabled: true, recommendationMode: 'travel', }); @@ -76,6 +77,11 @@ describe('mockSoundlogService', () => { mood: '시원한', state: '바다', }); + const jejuRecommendedPlaylist = await mockSoundlogService.getRecommendedPlaylist(ownerId, { + location: { lat: 33.4996, lng: 126.5312 }, + mood: '시원한', + state: '드라이브', + }); expect(profile.completedOnboarding).toBe(true); expect(profile.preferredGenres).toEqual(['인디']); @@ -103,6 +109,8 @@ describe('mockSoundlogService', () => { source: 'seed-fallback', state: '바다', }); + expect(recommendedPlaylist.id).toBe('busan-ocean'); + expect(jejuRecommendedPlaylist.id).toBe('jeju-island'); }); it('stores library state and paginates saved or liked tracks', async () => { diff --git a/tests/regional-playlist.test.ts b/tests/regional-playlist.test.ts new file mode 100644 index 0000000..01c864b --- /dev/null +++ b/tests/regional-playlist.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { findRegionalPlaylistId } from '../src/utils/regional-playlist.js'; + +describe('findRegionalPlaylistId', () => { + it('prioritizes a known place name over coordinates', () => { + expect( + findRegionalPlaylistId({ + lat: 37.5665, + lng: 126.978, + placeText: '부산광역시 광안리해수욕장', + }), + ).toBe('busan-ocean'); + }); + + it.each([ + [{ lat: 33.4996, lng: 126.5312 }, 'jeju-island'], + [{ lat: 37.7519, lng: 128.8761 }, 'gangneung-sea'], + [{ lat: 34.7604, lng: 127.6622 }, 'yeosu-night-sea'], + [{ lat: 35.8562, lng: 129.2247 }, 'gyeongju-starlight'], + ])('selects the nearest regional catalog for %o', (location, playlistId) => { + expect(findRegionalPlaylistId(location)).toBe(playlistId); + }); + + it('uses Seoul when neither place nor complete coordinates are available', () => { + expect(findRegionalPlaylistId({ lat: 35.1 })).toBe('seoul-night'); + }); + + it('does not map an overseas location to the nearest Korean city', () => { + expect( + findRegionalPlaylistId({ lat: 37.7858, lng: -122.4064 }), + ).toBe('seoul-night'); + }); +}); diff --git a/tests/seed-data.test.ts b/tests/seed-data.test.ts index dc151ea..ccfcfc5 100644 --- a/tests/seed-data.test.ts +++ b/tests/seed-data.test.ts @@ -5,7 +5,9 @@ import { playlists, recaps, seedMomentLogs, + tracks, } from '../src/data/seed-data.js'; +import { regionalPlaylistFallbacks } from '../src/data/regional-playlist-data.js'; describe('seed recap/log domain consistency', () => { it('keeps one log per travel session and includes every session recap', () => { @@ -51,4 +53,35 @@ describe('seed recap/log domain consistency', () => { expect(recommendation.imageUrl).toMatch(/^https:\/\//); } }); + + it('keeps a complete regional fallback catalog with valid tracks', () => { + const playlistIds = new Set(playlists.map((playlist) => playlist.id)); + const trackIds = new Set(tracks.map((track) => track.id)); + const referencedTrackIds = new Set( + playlists.flatMap((playlist) => [...playlist.trackIds]), + ); + const regionalPlaylistIds = regionalPlaylistFallbacks.map( + (region) => region.playlistId, + ); + + expect(regionalPlaylistFallbacks).toHaveLength(17); + expect(new Set(regionalPlaylistIds).size).toBe(regionalPlaylistIds.length); + + for (const region of regionalPlaylistFallbacks) { + expect(playlistIds.has(region.playlistId)).toBe(true); + expect(region.aliases.length).toBeGreaterThanOrEqual(3); + + const playlist = playlists.find((item) => item.id === region.playlistId); + expect(playlist?.coverImageUrl).toMatch(/^\/assets\/playlists\/[a-z-]+\.webp$/); + expect(playlist?.backgroundImageUrl).toBe(playlist?.coverImageUrl); + } + + for (const playlist of playlists) { + expect(new Set(playlist.trackIds).size).toBe(playlist.trackIds.length); + expect(playlist.trackIds.length).toBeGreaterThanOrEqual(5); + expect(playlist.trackIds.every((trackId) => trackIds.has(trackId))).toBe(true); + } + + expect(tracks.every((track) => referencedTrackIds.has(track.id))).toBe(true); + }); });