This commit is contained in:
lunaticbum 2025-12-29 18:06:51 +09:00
parent efc03bac91
commit b9fe935e98
4 changed files with 191 additions and 115 deletions

View File

@ -61,6 +61,7 @@ class SynologyProxyController {
} }
} }
// 1. [수정] list 메서드: additional 제거 (에러 해결)
@PostMapping("/list") @PostMapping("/list")
suspend fun list(@RequestBody payload: Map<String, Any>): String { suspend fun list(@RequestBody payload: Map<String, Any>): String {
try { try {
@ -70,6 +71,8 @@ class SynologyProxyController {
val limit = payload["limit"].toString() val limit = payload["limit"].toString()
val baseUrl = buildBaseUrl(nasAddress) val baseUrl = buildBaseUrl(nasAddress)
// 순수하게 목록만 요청
val url = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=list&limit=$limit&offset=$offset&_sid=$sid" val url = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=list&limit=$limit&offset=$offset&_sid=$sid"
logger.info("List Request URL: $url") logger.info("List Request URL: $url")
@ -80,6 +83,48 @@ class SynologyProxyController {
} }
} }
// 2. [추가] info 메서드: 사진 1장의 상세 정보(날짜, 위치) 조회
// [수정] 유효한 additional 항목("exif", "gps")만 요청하도록 변경
@PostMapping("/info")
suspend fun getInfo(@RequestBody payload: Map<String, Any>): String {
try {
val nasAddress = payload["nasAddress"].toString()
val sid = payload["sid"].toString()
val id = payload["id"].toString()
val baseUrl = buildBaseUrl(nasAddress)
// [핵심 변경] "address", "taken_time"은 유효하지 않음 -> "exif", "gps"로 변경
// ["exif","gps"] -> %5B%22exif%22%2C%22gps%22%5D
val additionalEncoded = "%5B%22exif%22%2C%22gps%22%5D"
val urlString = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=get&id=$id&additional=$additionalEncoded&_sid=$sid"
val uri = java.net.URI(urlString)
return webClient.get().uri(uri).retrieve().awaitBody()
} catch (e: Exception) {
logger.error("Info Error: ", e)
throw e
}
}
@GetMapping("/geocode")
suspend fun geocode(
@RequestParam lat: String,
@RequestParam lon: String
): String {
// OpenStreetMap API URL
val url = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
// Nominatim은 User-Agent 헤더가 필수입니다.
return webClient.get()
.uri(url)
.header("User-Agent", "MyNasSlideshow/1.0 (contact@example.com)") // 임의의 식별 문자열
.retrieve()
.awaitBody()
}
@GetMapping("/image") @GetMapping("/image")
suspend fun getImage( suspend fun getImage(
@RequestParam nasAddress: String, @RequestParam nasAddress: String,

File diff suppressed because one or more lines are too long

View File

@ -168,7 +168,7 @@
<ins class="adsbygoogle" <ins class="adsbygoogle"
style="display:block; width:100%; height:100%;" style="display:block; width:100%; height:100%;"
data-ad-client="ca-pub-9504446465764716" data-ad-client="ca-pub-9504446465764716"
data-ad-slot="YOUR_AD_SLOT_ID" data-ad-slot="5334609005"
data-ad-format="auto" data-ad-format="auto"
data-full-width-responsive="true"></ins> data-full-width-responsive="true"></ins>
</div> </div>

View File

@ -4,82 +4,54 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BUM's NAS Slideshow</title> <title>BUM's NAS Slideshow</title>
<style>
/* 기본 레이아웃 */
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color: #000; color: #fff; font-family: sans-serif; overflow: hidden; }
/* 나가기 버튼 */ <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716" crossorigin="anonymous"></script>
<script th:src="@{/js/exif-js.js}"></script>
<style>
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color: #000; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; }
.exit-btn { position: absolute; top: 20px; right: 20px; z-index: 100; color: rgba(255,255,255,0.7); text-decoration: none; background: rgba(0,0,0,0.5); padding: 8px 15px; border-radius: 30px; font-size: 0.9em; transition: 0.3s; } .exit-btn { position: absolute; top: 20px; right: 20px; z-index: 100; color: rgba(255,255,255,0.7); text-decoration: none; background: rgba(0,0,0,0.5); padding: 8px 15px; border-radius: 30px; font-size: 0.9em; transition: 0.3s; }
.exit-btn:hover { background: rgba(228, 76, 101, 0.8); color: white; } .exit-btn:hover { background: rgba(228, 76, 101, 0.8); color: white; }
/* 로그인 폼 (중앙 정렬) */ /* 정보 오버레이 */
#login-container { #info-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%; position: absolute; top: 30px; left: 30px; z-index: 15;
display: flex; align-items: center; justify-content: center; text-shadow: 1px 1px 5px rgba(0,0,0,0.9);
z-index: 50; background: #000; pointer-events: none; opacity: 0; transition: opacity 1s;
max-width: 50%;
} }
#info-date { font-size: 1.8em; font-weight: 700; color: #fff; margin-bottom: 5px; letter-spacing: -0.5px; }
#info-location { font-size: 1.1em; color: #eee; display: flex; align-items: center; gap: 8px; font-weight: 400; }
#info-location i { color: #e44c65; font-size: 1.0em; }
/* 로그인 폼 */
#login-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 50; background: #000; }
#login-form { display: flex; flex-direction: column; gap: 15px; padding: 40px; background: #222; border-radius: 12px; width: 320px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); } #login-form { display: flex; flex-direction: column; gap: 15px; padding: 40px; background: #222; border-radius: 12px; width: 320px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; outline: none; } #login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; outline: none; }
#login-form input:focus { border-color: #e44c65; } #login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; font-size: 1em; }
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; font-size: 1em; transition: 0.2s; }
#login-form button:hover { background: #d03d56; }
/* 사진 컨테이너 (전체 화면) */ #photo-container { position: relative; width: 100%; height: 100%; z-index: 1; }
#photo-container { .slide-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; opacity: 0; transition: opacity 1.5s ease-in-out; will-change: opacity; }
position: absolute; top: 0; left: 0; width: 100%; height: 100%; .slide-image.active { opacity: 1; }
z-index: 1;
/* 이미지가 겹쳐지도록 relative 설정 */
position: relative;
}
/* 개별 슬라이드 이미지 스타일 (겹치기용) */ #ad-container { position: absolute; bottom: 0; width: 100%; text-align: center; z-index: 10; padding-bottom: 20px; pointer-events: none; }
.slide-image { #ad-container ins { pointer-events: auto; display: inline-block; }
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
object-fit: contain; /* 비율 유지하며 화면에 맞춤 */
opacity: 0; /* 기본적으로 투명 */
transition: opacity 1.5s ease-in-out; /* 부드러운 전환 효과 */
will-change: opacity; /* 성능 최적화 */
}
.slide-image.active {
opacity: 1; /* 활성화된 이미지만 보임 */
}
/* 하단 컨트롤러 (속도 조절) */ #controls { position: absolute; bottom: 0; left: 0; width: 100%; padding: 20px 0 30px 0; background: linear-gradient(to top, rgba(0,0,0,0.9), transparent); z-index: 20; display: none; justify-content: center; align-items: center; gap: 15px; opacity: 0; transition: opacity 0.5s; }
#controls {
position: absolute; bottom: 0; left: 0; width: 100%;
padding: 20px;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
z-index: 20;
display: none; /* 슬라이드 시작 전엔 숨김 */
justify-content: center;
align-items: center;
gap: 15px;
opacity: 0; transition: opacity 0.5s;
}
/* 마우스 올리면 컨트롤러 보이기 */
body:hover #controls { opacity: 1; } body:hover #controls { opacity: 1; }
input[type=range] { -webkit-appearance: none; width: 300px; height: 6px; background: rgba(255,255,255,0.3); border-radius: 3px; outline: none; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #e44c65; cursor: pointer; }
/* 슬라이더 스타일 */ @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css');
input[type=range] {
-webkit-appearance: none; width: 300px; height: 6px; background: rgba(255,255,255,0.3); border-radius: 3px; outline: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #e44c65; cursor: pointer;
}
</style> </style>
</head> </head>
<body> <body>
<a href="/" class="exit-btn">✕ Close</a> <a href="/" class="exit-btn">✕ Close</a>
<div id="login-container"> <div id="login-container">
<div id="login-form"> <div id="login-form">
<h2 style="color:#e44c65; text-align:center; margin:0 0 20px 0;">Synology Login</h2> <h2 style="color:#e44c65; text-align:center; margin:0 0 20px 0;">Synology Login</h2>
<meta name="_csrf" th:content="${_csrf.token}"/> <meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/> <meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<input type="text" id="nas-address" placeholder="NAS IP:Port (ex: 192.168.0.5:5000)"> <input type="text" id="nas-address" placeholder="NAS IP:Port (ex: 192.168.0.5:5000)">
<input type="text" id="username" placeholder="ID"> <input type="text" id="username" placeholder="ID">
<input type="password" id="password" placeholder="Password"> <input type="password" id="password" placeholder="Password">
@ -88,7 +60,19 @@
</div> </div>
</div> </div>
<div id="photo-container"> <div id="photo-container"></div>
<div id="info-overlay">
<div id="info-date"></div>
<div id="info-location">
<i class="fas fa-map-marker-alt"></i>
<span id="info-loc-text"></span>
</div>
</div>
<div id="ad-container" style="display:none;">
<ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-9504446465764716" data-ad-slot="YOUR_AD_SLOT_ID"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div> </div>
<div id="controls"> <div id="controls">
@ -97,57 +81,40 @@
</div> </div>
<script> <script>
// 전역 변수
let sid = ''; let sid = '';
let nasAddress = ''; let nasAddress = '';
const allPhotoIds = []; const allPhotoIds = [];
let slideshowTimeout; // setTimeout ID 저장 let slideshowTimeout;
let currentSpeed = 5000; // 기본 5초 let currentSpeed = 5000;
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content'); const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content'); const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
// UI 유틸리티
function showMessage(msg) { document.getElementById('message').innerText = msg; } function showMessage(msg) { document.getElementById('message').innerText = msg; }
function updateSpeed(val) { function updateSpeed(val) { currentSpeed = val * 1000; document.getElementById('speed-display').innerText = val; }
currentSpeed = val * 1000;
document.getElementById('speed-display').innerText = val;
}
// 1. 로그인 및 목록 가져오기
async function loginAndStart() { async function loginAndStart() {
nasAddress = document.getElementById('nas-address').value.trim(); nasAddress = document.getElementById('nas-address').value.trim();
const username = document.getElementById('username').value.trim(); const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
if (!nasAddress || !username || !password) { showMessage('모든 정보를 입력해주세요.'); return; }
if (!nasAddress || !username || !password) {
showMessage('모든 정보를 입력해주세요.'); return;
}
showMessage('로그인 중...'); showMessage('로그인 중...');
try { try {
// 로그인 요청
const response = await fetch('/api/synology/login', { const response = await fetch('/api/synology/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
body: JSON.stringify({ nasAddress, username, password }) body: JSON.stringify({ nasAddress, username, password })
}); });
const loginData = await response.json(); const loginData = await response.json();
if (loginData.success && loginData.data.sid) { if (loginData.success && loginData.data.sid) {
sid = loginData.data.sid; sid = loginData.data.sid;
showMessage('목록 가져오는 중...'); showMessage('목록 가져오는 중...');
await fetchAllPhotos(0); await fetchAllPhotos(0);
} else { } else { showMessage('로그인 실패 (정보 확인 필요)'); }
showMessage('로그인 실패 (정보 확인 필요)'); } catch (error) { console.error(error); showMessage('서버 통신 오류'); }
}
} catch (error) {
console.error(error);
showMessage('서버 통신 오류');
}
} }
// 2. 전체 사진 ID 수집 (재귀)
async function fetchAllPhotos(offset) { async function fetchAllPhotos(offset) {
const limit = 5000; const limit = 5000;
try { try {
@ -157,77 +124,133 @@
body: JSON.stringify({ nasAddress, sid, offset, limit }) body: JSON.stringify({ nasAddress, sid, offset, limit })
}); });
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
data.data.list.forEach(item => allPhotoIds.push(item.id)); data.data.list.forEach(item => allPhotoIds.push(item.id));
const total = data.data.total; const total = data.data.total;
if (offset + limit < total) { if (offset + limit < total) {
showMessage(`${allPhotoIds.length} / ${total} 장 로딩...`); showMessage(`${allPhotoIds.length} / ${total} 장 로딩...`);
await fetchAllPhotos(offset + limit); await fetchAllPhotos(offset + limit);
} else { } else {
// 로딩 완료 -> UI 전환 및 슬라이드 시작
startSlideshow(); startSlideshow();
} }
} }
} catch (error) { showMessage('목록 불러오기 실패'); } } catch (error) { showMessage('목록 불러오기 실패'); }
} }
// 3. UI 전환
function startSlideshow() { function startSlideshow() {
document.getElementById('login-container').style.display = 'none'; // 로그인창 숨김 document.getElementById('login-container').style.display = 'none';
document.getElementById('controls').style.display = 'flex'; // 컨트롤러 보이기 document.getElementById('controls').style.display = 'flex';
document.getElementById('ad-container').style.display = 'block';
// 첫 사진 로드 document.getElementById('info-overlay').style.opacity = 1;
loadNextPhoto(); loadNextPhoto();
} }
// 4. [핵심] 부드러운 이미지 교체 로직
function loadNextPhoto() { function loadNextPhoto() {
if (allPhotoIds.length === 0) return; if (allPhotoIds.length === 0) return;
// 랜덤 ID 선택
const photoId = allPhotoIds[Math.floor(Math.random() * allPhotoIds.length)]; const photoId = allPhotoIds[Math.floor(Math.random() * allPhotoIds.length)];
const imgUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${photoId}`; const imgUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${photoId}`;
// 새 이미지 태그 생성 // [중요] 오버레이 초기화 (이전 정보 지우기)
const newImg = document.createElement('img'); updateOverlayText(null, null);
newImg.src = imgUrl;
newImg.className = 'slide-image'; // opacity: 0 상태
// 이미지가 로드되면 화면에 붙이고 페이드인 const newImg = document.createElement('img');
newImg.onload = () => { // crossOrigin 설정은 Proxy를 타기 때문에 필수는 아니지만 안전장치로 추가
newImg.crossOrigin = "Anonymous";
newImg.src = imgUrl;
newImg.className = 'slide-image';
newImg.onload = function() {
const container = document.getElementById('photo-container'); const container = document.getElementById('photo-container');
container.appendChild(newImg); container.appendChild(newImg);
// 브라우저가 DOM 추가를 인식할 시간을 줌 (약간의 딜레이) // [핵심] EXIF 라이브러리를 사용해 이미지에서 직접 정보 추출
requestAnimationFrame(() => { EXIF.getData(newImg, function() {
newImg.classList.add('active'); // opacity: 1로 변경 (CSS transition 발동) // 1. 촬영 날짜 추출
const dateTaken = EXIF.getTag(this, "DateTimeOriginal");
let dateStr = null;
if (dateTaken) {
// EXIF 날짜 포맷은 "YYYY:MM:DD HH:MM:SS"
const parts = dateTaken.split(/[: ]/);
if(parts.length >= 3) {
// "2023. 12. 25." 형태로 변환
dateStr = `${parts[0]}. ${parts[1]}. ${parts[2]}.`;
}
}
// 2. GPS 추출 및 주소 변환
const lat = EXIF.getTag(this, "GPSLatitude");
const lon = EXIF.getTag(this, "GPSLongitude");
const latRef = EXIF.getTag(this, "GPSLatitudeRef");
const lonRef = EXIF.getTag(this, "GPSLongitudeRef");
if (lat && lon) {
const decimalLat = convertDMSToDD(lat, latRef);
const decimalLon = convertDMSToDD(lon, lonRef);
updateOverlayText(dateStr, "위치 확인 중...");
// [수정] 외부 API 대신 내 백엔드 프록시로 요청
fetch(`/api/synology/geocode?lat=${decimalLat}&lon=${decimalLon}`, {
headers: { [csrfHeader]: csrfToken } // CSRF 토큰 추가 (필요시)
})
.then(res => res.json())
.then(data => {
let addr = "";
if(data.address) {
const city = data.address.city || data.address.province || "";
const district = data.address.borough || data.address.district || data.address.suburb || "";
const country = data.address.country || "";
if (country !== "대한민국") addr += country + " ";
addr += `${city} ${district}`;
}
updateOverlayText(dateStr, addr.trim() || "Unknown Location");
})
.catch(err => {
console.error(err);
updateOverlayText(dateStr, "위치 정보 없음");
});
} else {
updateOverlayText(dateStr, null);
}
}); });
// 기존 이미지 정리 (메모리 누수 방지) requestAnimationFrame(() => { newImg.classList.add('active'); });
const oldImages = container.querySelectorAll('img'); const oldImages = container.querySelectorAll('img');
if (oldImages.length > 1) { if (oldImages.length > 1) {
// 새 이미지가 완전히 나타난 후(1.5초 뒤) 이전 이미지 삭제
setTimeout(() => { setTimeout(() => {
// 가장 오래된 이미지부터 삭제 (마지막에 추가된 게 현재 이미지) for(let i = 0; i < oldImages.length - 1; i++) { container.removeChild(oldImages[i]); }
for(let i = 0; i < oldImages.length - 1; i++) { }, 1500);
container.removeChild(oldImages[i]);
} }
}, 1500); // CSS transition 시간과 일치
}
// 다음 슬라이드 예약 (재귀 호출)
// setInterval 대신 setTimeout을 써야 속도 변경이 즉시 반영됨
clearTimeout(slideshowTimeout); clearTimeout(slideshowTimeout);
slideshowTimeout = setTimeout(loadNextPhoto, currentSpeed); slideshowTimeout = setTimeout(loadNextPhoto, currentSpeed);
}; };
// 이미지 로드 실패 시 재시도 newImg.onerror = () => { setTimeout(loadNextPhoto, 1000); };
newImg.onerror = () => { }
console.log("Image load failed, retrying...");
setTimeout(loadNextPhoto, 1000); // [유틸] GPS 도분초(DMS) -> 십진수(DD) 변환 함수
}; function convertDMSToDD(coord, ref) {
let dd = coord[0] + (coord[1] / 60) + (coord[2] / 3600);
if (ref === "S" || ref === "W") {
dd = dd * -1;
}
return dd;
}
function updateOverlayText(date, location) {
const dateDiv = document.getElementById('info-date');
const locDiv = document.getElementById('info-location');
const locText = document.getElementById('info-loc-text');
if (date) { dateDiv.innerText = date; dateDiv.style.display = 'block'; }
else if (date === null) { dateDiv.style.display = 'none'; }
// date가 undefined가 아니라 null로 명시적으로 오면 끔, 기존 값 유지하고 싶으면 아예 호출 안함
if (location && location.trim() !== "") { locText.innerText = location; locDiv.style.display = 'flex'; }
else if (location === null) { locDiv.style.display = 'none'; }
} }
</script> </script>
</body> </body>