...
This commit is contained in:
parent
efc03bac91
commit
b9fe935e98
@ -61,6 +61,7 @@ class SynologyProxyController {
|
||||
}
|
||||
}
|
||||
|
||||
// 1. [수정] list 메서드: additional 제거 (에러 해결)
|
||||
@PostMapping("/list")
|
||||
suspend fun list(@RequestBody payload: Map<String, Any>): String {
|
||||
try {
|
||||
@ -70,6 +71,8 @@ class SynologyProxyController {
|
||||
val limit = payload["limit"].toString()
|
||||
|
||||
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"
|
||||
|
||||
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")
|
||||
suspend fun getImage(
|
||||
@RequestParam nasAddress: String,
|
||||
|
||||
8
src/main/resources/static/js/exif-js.js
Normal file
8
src/main/resources/static/js/exif-js.js
Normal file
File diff suppressed because one or more lines are too long
@ -168,7 +168,7 @@
|
||||
<ins class="adsbygoogle"
|
||||
style="display:block; width:100%; height:100%;"
|
||||
data-ad-client="ca-pub-9504446465764716"
|
||||
data-ad-slot="YOUR_AD_SLOT_ID"
|
||||
data-ad-slot="5334609005"
|
||||
data-ad-format="auto"
|
||||
data-full-width-responsive="true"></ins>
|
||||
</div>
|
||||
|
||||
@ -4,82 +4,54 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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:hover { background: rgba(228, 76, 101, 0.8); color: white; }
|
||||
|
||||
/* 로그인 폼 (중앙 정렬) */
|
||||
#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;
|
||||
/* 정보 오버레이 */
|
||||
#info-overlay {
|
||||
position: absolute; top: 30px; left: 30px; z-index: 15;
|
||||
text-shadow: 1px 1px 5px rgba(0,0,0,0.9);
|
||||
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 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; transition: 0.2s; }
|
||||
#login-form button:hover { background: #d03d56; }
|
||||
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; font-size: 1em; }
|
||||
|
||||
/* 사진 컨테이너 (전체 화면) */
|
||||
#photo-container {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
z-index: 1;
|
||||
/* 이미지가 겹쳐지도록 relative 설정 */
|
||||
position: relative;
|
||||
}
|
||||
#photo-container { position: relative; width: 100%; height: 100%; z-index: 1; }
|
||||
.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; }
|
||||
.slide-image.active { opacity: 1; }
|
||||
|
||||
/* 개별 슬라이드 이미지 스타일 (겹치기용) */
|
||||
.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; /* 성능 최적화 */
|
||||
}
|
||||
.slide-image.active {
|
||||
opacity: 1; /* 활성화된 이미지만 보임 */
|
||||
}
|
||||
#ad-container { position: absolute; bottom: 0; width: 100%; text-align: center; z-index: 10; padding-bottom: 20px; pointer-events: none; }
|
||||
#ad-container ins { pointer-events: auto; display: inline-block; }
|
||||
|
||||
/* 하단 컨트롤러 (속도 조절) */
|
||||
#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;
|
||||
}
|
||||
/* 마우스 올리면 컨트롤러 보이기 */
|
||||
#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; }
|
||||
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; }
|
||||
|
||||
/* 슬라이더 스타일 */
|
||||
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');
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="exit-btn">✕ Close</a>
|
||||
|
||||
<div id="login-container">
|
||||
<div id="login-form">
|
||||
<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_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="username" placeholder="ID">
|
||||
<input type="password" id="password" placeholder="Password">
|
||||
@ -88,7 +60,19 @@
|
||||
</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 id="controls">
|
||||
@ -97,57 +81,40 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 전역 변수
|
||||
let sid = '';
|
||||
let nasAddress = '';
|
||||
const allPhotoIds = [];
|
||||
let slideshowTimeout; // setTimeout ID 저장
|
||||
let currentSpeed = 5000; // 기본 5초
|
||||
let slideshowTimeout;
|
||||
let currentSpeed = 5000;
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
|
||||
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
|
||||
|
||||
// UI 유틸리티
|
||||
function showMessage(msg) { document.getElementById('message').innerText = msg; }
|
||||
function updateSpeed(val) {
|
||||
currentSpeed = val * 1000;
|
||||
document.getElementById('speed-display').innerText = val;
|
||||
}
|
||||
function updateSpeed(val) { currentSpeed = val * 1000; document.getElementById('speed-display').innerText = val; }
|
||||
|
||||
// 1. 로그인 및 목록 가져오기
|
||||
async function loginAndStart() {
|
||||
nasAddress = document.getElementById('nas-address').value.trim();
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!nasAddress || !username || !password) {
|
||||
showMessage('모든 정보를 입력해주세요.'); return;
|
||||
}
|
||||
if (!nasAddress || !username || !password) { showMessage('모든 정보를 입력해주세요.'); return; }
|
||||
showMessage('로그인 중...');
|
||||
|
||||
try {
|
||||
// 로그인 요청
|
||||
const response = await fetch('/api/synology/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
|
||||
body: JSON.stringify({ nasAddress, username, password })
|
||||
});
|
||||
const loginData = await response.json();
|
||||
|
||||
if (loginData.success && loginData.data.sid) {
|
||||
sid = loginData.data.sid;
|
||||
showMessage('목록 가져오는 중...');
|
||||
await fetchAllPhotos(0);
|
||||
} else {
|
||||
showMessage('로그인 실패 (정보 확인 필요)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showMessage('서버 통신 오류');
|
||||
}
|
||||
} else { showMessage('로그인 실패 (정보 확인 필요)'); }
|
||||
} catch (error) { console.error(error); showMessage('서버 통신 오류'); }
|
||||
}
|
||||
|
||||
// 2. 전체 사진 ID 수집 (재귀)
|
||||
async function fetchAllPhotos(offset) {
|
||||
const limit = 5000;
|
||||
try {
|
||||
@ -157,77 +124,133 @@
|
||||
body: JSON.stringify({ nasAddress, sid, offset, limit })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
data.data.list.forEach(item => allPhotoIds.push(item.id));
|
||||
const total = data.data.total;
|
||||
|
||||
if (offset + limit < total) {
|
||||
showMessage(`${allPhotoIds.length} / ${total} 장 로딩...`);
|
||||
await fetchAllPhotos(offset + limit);
|
||||
} else {
|
||||
// 로딩 완료 -> UI 전환 및 슬라이드 시작
|
||||
startSlideshow();
|
||||
}
|
||||
}
|
||||
} catch (error) { showMessage('목록 불러오기 실패'); }
|
||||
}
|
||||
|
||||
// 3. UI 전환
|
||||
function startSlideshow() {
|
||||
document.getElementById('login-container').style.display = 'none'; // 로그인창 숨김
|
||||
document.getElementById('controls').style.display = 'flex'; // 컨트롤러 보이기
|
||||
|
||||
// 첫 사진 로드
|
||||
document.getElementById('login-container').style.display = 'none';
|
||||
document.getElementById('controls').style.display = 'flex';
|
||||
document.getElementById('ad-container').style.display = 'block';
|
||||
document.getElementById('info-overlay').style.opacity = 1;
|
||||
loadNextPhoto();
|
||||
}
|
||||
|
||||
// 4. [핵심] 부드러운 이미지 교체 로직
|
||||
function loadNextPhoto() {
|
||||
if (allPhotoIds.length === 0) return;
|
||||
|
||||
// 랜덤 ID 선택
|
||||
const photoId = allPhotoIds[Math.floor(Math.random() * allPhotoIds.length)];
|
||||
const imgUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${photoId}`;
|
||||
|
||||
// 새 이미지 태그 생성
|
||||
const newImg = document.createElement('img');
|
||||
newImg.src = imgUrl;
|
||||
newImg.className = 'slide-image'; // opacity: 0 상태
|
||||
// [중요] 오버레이 초기화 (이전 정보 지우기)
|
||||
updateOverlayText(null, null);
|
||||
|
||||
// 이미지가 로드되면 화면에 붙이고 페이드인
|
||||
newImg.onload = () => {
|
||||
const newImg = document.createElement('img');
|
||||
// crossOrigin 설정은 Proxy를 타기 때문에 필수는 아니지만 안전장치로 추가
|
||||
newImg.crossOrigin = "Anonymous";
|
||||
newImg.src = imgUrl;
|
||||
newImg.className = 'slide-image';
|
||||
|
||||
newImg.onload = function() {
|
||||
const container = document.getElementById('photo-container');
|
||||
container.appendChild(newImg);
|
||||
|
||||
// 브라우저가 DOM 추가를 인식할 시간을 줌 (약간의 딜레이)
|
||||
requestAnimationFrame(() => {
|
||||
newImg.classList.add('active'); // opacity: 1로 변경 (CSS transition 발동)
|
||||
// [핵심] EXIF 라이브러리를 사용해 이미지에서 직접 정보 추출
|
||||
EXIF.getData(newImg, function() {
|
||||
// 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');
|
||||
if (oldImages.length > 1) {
|
||||
// 새 이미지가 완전히 나타난 후(1.5초 뒤) 이전 이미지 삭제
|
||||
setTimeout(() => {
|
||||
// 가장 오래된 이미지부터 삭제 (마지막에 추가된 게 현재 이미지)
|
||||
for(let i = 0; i < oldImages.length - 1; i++) {
|
||||
container.removeChild(oldImages[i]);
|
||||
}
|
||||
}, 1500); // CSS transition 시간과 일치
|
||||
for(let i = 0; i < oldImages.length - 1; i++) { container.removeChild(oldImages[i]); }
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// 다음 슬라이드 예약 (재귀 호출)
|
||||
// setInterval 대신 setTimeout을 써야 속도 변경이 즉시 반영됨
|
||||
clearTimeout(slideshowTimeout);
|
||||
slideshowTimeout = setTimeout(loadNextPhoto, currentSpeed);
|
||||
};
|
||||
|
||||
// 이미지 로드 실패 시 재시도
|
||||
newImg.onerror = () => {
|
||||
console.log("Image load failed, retrying...");
|
||||
setTimeout(loadNextPhoto, 1000);
|
||||
};
|
||||
newImg.onerror = () => { 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>
|
||||
</body>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user