This commit is contained in:
lunaticbum 2025-12-31 15:44:15 +09:00
parent 1fa47201a7
commit 526a7b598f
4 changed files with 569 additions and 169 deletions

View File

@ -1,6 +1,12 @@
package kr.lunaticbum.back.lun.controllers
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.PhotoMetadata
import kr.lunaticbum.back.lun.repository.PhotoMetadataRepository
import org.slf4j.LoggerFactory
import org.springframework.core.io.buffer.DataBuffer
import org.springframework.core.io.buffer.DataBufferUtils
import org.springframework.http.MediaType
@ -9,9 +15,8 @@ import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import reactor.core.publisher.Flux
import java.nio.charset.StandardCharsets
import reactor.core.publisher.Mono
// DTO 정의
data class SynologyListRequest(
val nasAddress: String,
val sid: String,
@ -19,13 +24,23 @@ data class SynologyListRequest(
val limit: Int
)
data class MetadataRequest(
val id: String,
val memo: String?,
val tags: List<String>
)
@RestController
@RequestMapping("/api/synology")
class SynologyProxyController {
class SynologyProxyController(
private val photoMetadataRepository: PhotoMetadataRepository
) {
private val logger = LoggerFactory.getLogger(SynologyProxyController::class.java)
private val mapper = ObjectMapper()
private val webClient = WebClient.builder()
.codecs { configurer ->
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB 버퍼
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)
}
.build()
@ -33,6 +48,88 @@ class SynologyProxyController {
return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress"
}
// [핵심 로직] 라이브 포토의 짝꿍 비디오 ID 찾기 (Plan A -> Plan B)
private fun fetchLivePhotoUnitId(
baseUrl: String,
sid: String,
folderId: String,
uuid: String?,
originalFilename: String?
): String? {
try {
// ========================================================================
// PLAN A: MediaGroupUUID로 검색 (가장 정확함)
// ========================================================================
if (!uuid.isNullOrEmpty()) {
val searchUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
.path("/webapi/entry.cgi")
.queryParam("api", "SYNO.Foto.Browse.Item")
.queryParam("version", "1")
.queryParam("method", "list")
.queryParam("folder_id", folderId)
.queryParam("type", "video")
.queryParam("filter_media_group_uuid", uuid)
.queryParam("limit", 1)
.queryParam("_sid", sid)
.build().toUri()
val response = webClient.get().uri(searchUri).retrieve().bodyToMono(String::class.java).block()
logger.info(">>> [Smart Lookup] Plan A response: $response")
val list = mapper.readTree(response).path("data").path("list")
if (list.isArray && list.size() > 0) {
val foundId = list.get(0).path("id").asText()
logger.info(">>> [Smart Lookup] Plan A Success! Found ID via UUID: $foundId")
return foundId
}
}
// ========================================================================
// PLAN B: 파일명 매칭 (UUID 실패 시 시도)
// 예: IMG_1234.HEIC -> 같은 폴더의 IMG_1234.MOV 찾기
// ========================================================================
if (!originalFilename.isNullOrEmpty()) {
val baseName = originalFilename.substringBeforeLast(".")
// 해당 폴더의 비디오 목록 조회 (최대 1000개)
val listUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
.path("/webapi/entry.cgi")
.queryParam("api", "SYNO.Foto.Browse.Item")
.queryParam("version", "1")
.queryParam("method", "list")
.queryParam("folder_id", folderId)
.queryParam("offset", 0)
.queryParam("type", "video")
.queryParam("limit", 1000)
.queryParam("_sid", sid)
.build().toUri()
val response = webClient.get().uri(listUri).retrieve().bodyToMono(String::class.java).block()
logger.info(">>> [Smart Lookup] Plan B response: $response")
val list = mapper.readTree(response).path("data").path("list")
if (list.isArray) {
for (node in list) {
val videoName = node.path("filename").asText("")
val videoBase = videoName.substringBeforeLast(".")
// 확장자 제외한 이름이 같으면 빙고!
if (videoBase.equals(baseName, ignoreCase = true)) {
val foundId = node.path("id").asText()
logger.info(">>> [Smart Lookup] Plan B Success! Found matching file: $videoName (ID: $foundId)")
return foundId
}
}
}
}
logger.warn(">>> [Smart Lookup] Failed. Both Plan A and B failed for file: $originalFilename")
return null
} catch (e: Exception) {
logger.error(">>> [Smart Lookup] Error", e)
return null
}
}
@PostMapping("/login")
suspend fun login(@RequestBody request: Map<String, String>): ResponseEntity<Any> {
val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress")
@ -43,11 +140,7 @@ class SynologyProxyController {
val uri = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=$username&passwd=$password&session=Foto&format=cookie"
return try {
val response = webClient.get()
.uri(uri)
.retrieve()
.bodyToMono(String::class.java)
.awaitSingle()
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
ResponseEntity.ok(response)
} catch (e: Exception) {
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
@ -55,11 +148,8 @@ class SynologyProxyController {
}
@PostMapping("/list")
suspend fun getList(
@RequestBody request: SynologyListRequest
): ResponseEntity<Any> {
suspend fun getList(@RequestBody request: SynologyListRequest): ResponseEntity<Any> {
val baseUrl = buildBaseUrl(request.nasAddress)
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
.path("/webapi/entry.cgi")
.queryParam("api", "SYNO.Foto.Browse.Item")
@ -67,18 +157,13 @@ class SynologyProxyController {
.queryParam("method", "list")
.queryParam("offset", request.offset)
.queryParam("limit", request.limit)
// [중요] 썸네일 키(cache_key)를 받아오기 위해 필수
.queryParam("additional", "[\"thumbnail\"]")
// [중요] 정보를 미리 확보 (thumbnail, exif)
.queryParam("additional", "[\"thumbnail\",\"exif\"]")
.queryParam("_sid", request.sid)
.build()
.toUri()
.build().toUri()
return try {
val response = webClient.get()
.uri(uri)
.retrieve()
.bodyToMono(String::class.java)
.awaitSingle()
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
ResponseEntity.ok(response)
} catch (e: Exception) {
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
@ -87,23 +172,40 @@ class SynologyProxyController {
@GetMapping("/geocode")
suspend fun reverseGeocode(
@RequestParam lat: Double,
@RequestParam lon: Double
@RequestParam id: String, @RequestParam lat: Double, @RequestParam lon: Double
): ResponseEntity<Any> {
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
return try {
val response = webClient.get()
.uri(uri)
.header("User-Agent", "SynoPhotoSlideshow/1.0")
.retrieve()
.bodyToMono(String::class.java)
.awaitSingle()
ResponseEntity.ok(response)
} catch (e: Exception) {
ResponseEntity.ok("{}")
val existingData = photoMetadataRepository.findById(id).awaitSingleOrNull()
if (existingData != null) {
return ResponseEntity.ok(mapOf(
"address" to (existingData.address ?: ""),
"memo" to (existingData.memo ?: ""),
"tags" to existingData.tags
))
}
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
return try {
val responseString = webClient.get().uri(uri).header("User-Agent", "SynoPhotoSlideshow/1.0").retrieve().bodyToMono(String::class.java).awaitSingle()
val node = mapper.readTree(responseString)
val addressName = node.path("display_name").asText(null)
if (!addressName.isNullOrEmpty()) {
val metadata = PhotoMetadata(id = id, address = addressName, latitude = lat, longitude = lon)
photoMetadataRepository.save(metadata).awaitSingle()
return ResponseEntity.ok(mapOf("address" to addressName, "memo" to "", "tags" to emptyList<String>()))
}
ResponseEntity.ok(mapOf("address" to "Unknown"))
} catch (e: Exception) {
ResponseEntity.ok(mapOf("address" to "Unknown"))
}
}
@PostMapping("/metadata")
suspend fun saveMetadata(@RequestBody req: MetadataRequest): ResponseEntity<Any> {
val existingMetadata = photoMetadataRepository.findById(req.id).awaitSingleOrNull()
val metadata = existingMetadata ?: PhotoMetadata(id = req.id)
metadata.memo = req.memo
metadata.tags = req.tags.toMutableList()
photoMetadataRepository.save(metadata).awaitSingle()
return ResponseEntity.ok(mapOf("success" to true))
}
@GetMapping("/image")
@ -112,21 +214,37 @@ class SynologyProxyController {
@RequestParam sid: String,
@RequestParam id: String,
@RequestParam(defaultValue = "download") mode: String,
@RequestParam(required = false) cacheKey: String?
@RequestParam(required = false) cacheKey: String?,
@RequestParam(required = false) folderId: String?,
@RequestParam(required = false) uuid: String?,
@RequestParam(required = false) filename: String?
): ResponseEntity<StreamingResponseBody> {
val baseUrl = buildBaseUrl(nasAddress)
val rawUrl = if (mode == "thumbnail") {
val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else ""
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid"
} else {
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
var targetId = id
// [Smart Lookup 발동 조건] video 모드 + 폴더ID 존재
if (mode == "video" && !folderId.isNullOrEmpty()) {
val unitId = fetchLivePhotoUnitId(baseUrl, sid, folderId, uuid, filename)
if (unitId != null) targetId = unitId
}
val rawUrl = when (mode) {
"thumbnail" -> {
val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else ""
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid"
}
"video" -> {
// 다운로드 API로 비디오 스트리밍 (MP4/MOV 원본)
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&id=$targetId&cache_key=$cacheKey&_sid=$sid"
}
else -> {
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
}
}
val uri = java.net.URI.create(rawUrl)
try {
// [해결] retrieve() + toEntityFlux() 사용
// exchangeToMono의 복잡한 스코프 문제를 피하고, 헤더와 바디 스트림을 안전하게 가져옵니다.
val responseEntity = webClient.get()
.uri(uri)
.accept(MediaType.ALL)
@ -137,30 +255,17 @@ class SynologyProxyController {
val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM
val contentLength = responseEntity.headers.contentLength
// 1. 에러 체크 (JSON 응답이 오면 에러로 간주)
if (contentType.includes(MediaType.APPLICATION_JSON)) {
// 스트림을 문자열로 읽어서 로그 출력
val errorBody = responseEntity.body?.collectList()?.block()?.joinToString("") {
it.toString(StandardCharsets.UTF_8)
} ?: "Unknown Error"
println(">>> NAS Error (ID: $id): $errorBody")
return ResponseEntity.notFound().build()
}
// 2. 정상 스트리밍 (DataBufferUtils.write 사용)
// Flux<DataBuffer>를 OutputStream에 바로 씁니다. (메모리 효율 최적)
val streamingBody = StreamingResponseBody { outputStream ->
val flux = responseEntity.body ?: Flux.empty()
DataBufferUtils.write(flux, outputStream)
.blockLast() // 쓰기가 완료될 때까지 대기
DataBufferUtils.write(flux, outputStream).blockLast()
}
val builder = ResponseEntity.ok().contentType(contentType)
if (contentLength > 0) {
builder.contentLength(contentLength)
}
if (contentLength > 0) builder.contentLength(contentLength)
return builder.body(streamingBody)
} catch (e: Exception) {

View File

@ -0,0 +1,19 @@
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
@Document(collection = "photo_metadata")
data class PhotoMetadata(
@Id
val id: String,
var address: String? = null,
var latitude: Double? = null,
var longitude: Double? = null,
var memo: String? = null,
// [추가] 태그 리스트
var tags: MutableList<String> = mutableListOf()
)

View File

@ -0,0 +1,7 @@
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.PhotoMetadata
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
interface PhotoMetadataRepository : ReactiveMongoRepository<PhotoMetadata,String> {
}

View File

@ -2,7 +2,8 @@
<html xmlns:th="http://www.thymeleaf.org">
<head>
<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, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#000000">
<title>BUM's NAS Slideshow</title>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716" crossorigin="anonymous"></script>
@ -16,6 +17,9 @@
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; display: flex; flex-direction: column; font-family: 'Noto Sans KR', sans-serif; background: #000; }
:fullscreen { background: #000; }
::backdrop { background: #000; }
#bg-layer {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(to top, #09203f 0%, #537895 100%);
@ -30,16 +34,17 @@
#card-stage {
position: relative; width: 100%; height: 100%;
display: flex; justify-content: center; align-items: center; padding-bottom: 0; /* 하단 여백 제거 */
display: flex; justify-content: center; align-items: center; padding-bottom: 0;
}
.polaroid-card {
position: absolute; background-color: #fff; color: #333;
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
width: 320px; height: 400px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
width: 300px; height: 400px;
display: flex; flex-direction: column; border-radius: 4px;
transition: opacity var(--anim-duration) ease-in-out, transform var(--anim-duration) cubic-bezier(0.25, 0.8, 0.25, 1);
opacity: 0; z-index: 1; transform: scale(1.05); will-change: opacity, transform; overflow: hidden;
max-width: 94vw; max-height: 90vh;
}
.polaroid-card.active { opacity: 1; z-index: 10; transform: scale(1); }
@ -66,7 +71,7 @@
}
.p-date {
font-size: calc(1.8em * var(--text-scale)); font-weight: bold; line-height: 1.2;
margin-bottom: calc(15px * var(--text-scale)); transition: font-size 0.3s, margin-bottom 0.3s; min-height: 1.2em;
margin-bottom: calc(5px * var(--text-scale)); transition: font-size 0.3s, margin-bottom 0.3s; min-height: 1.2em;
}
.p-location {
font-size: calc(1.3em * var(--text-scale)); color: #555; display: flex; align-items: center; gap: 5px;
@ -74,40 +79,57 @@
}
.typing-cursor::after { content: '|'; display: inline-block; margin-left: 2px; color: #e44c65; animation: blink 0.8s step-start infinite; font-weight: normal; }
@keyframes blink { 50% { opacity: 0; } }
.p-keywords { font-size: calc(1.0em * var(--text-scale)); color: #e44c65; margin-left: 10px; transition: font-size 0.3s; }
.p-meta-info {
font-size: 0.9em; color: #666; margin-top: 5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: 'Noto Sans KR', sans-serif;
display: flex; gap: 5px; align-items: center; flex-wrap: wrap;
}
.tag-badge {
background: #eee; color: #555; padding: 2px 6px; border-radius: 4px;
font-size: 0.8em; display: inline-block; margin-right: 2px;
}
.memo-text { color: #888; margin-left: 5px; font-size: 0.9em; }
#exif-full-overlay {
position: absolute; bottom: 90px; right: 20px; z-index: 25;
background: rgba(0, 0, 0, 0.75); padding: 15px; border-radius: 10px;
background: rgba(0, 0, 0, 0.85); padding: 15px; border-radius: 10px;
width: 280px; max-height: 60vh; overflow-y: auto;
font-size: 0.85em; color: #ccc; backdrop-filter: blur(5px);
display: none; text-align: right; box-shadow: 0 5px 20px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);
}
#exif-full-overlay::-webkit-scrollbar { width: 6px; }
#exif-full-overlay::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
.exif-item { border-bottom: 1px dashed #444; padding: 4px 0; display: flex; justify-content: space-between; }
.exif-key { color: #888; font-weight: bold; margin-right: 10px; }
.exif-val { color: #fff; word-break: break-all; text-align: right; }
@media (max-width: 600px) {
#exif-full-overlay { right: 10px; left: 10px; width: auto; bottom: 120px; }
}
/* [수정] 컨트롤 바: 평소엔 숨김 */
#controls-bar {
position: absolute; bottom: 30px;
display: flex; align-items: center; gap: 15px;
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(5px);
padding: 10px 25px; border-radius: 50px;
z-index: 50;
/* 애니메이션 설정 */
opacity: 0;
transform: translateY(20px);
opacity: 0; transform: translateY(20px);
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: none; /* 숨겨진 상태에선 클릭 방지 */
pointer-events: none;
max-width: 95%; overflow-x: auto; white-space: nowrap;
}
#controls-bar.controls-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
#controls-bar::-webkit-scrollbar { display: none; }
/* [NEW] 활성화 클래스 */
#controls-bar.controls-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto; /* 보이게 되면 클릭 허용 */
.ctrl-filter-group {
display: flex; gap: 10px; align-items: center; background: rgba(255,255,255,0.1); padding: 5px 15px; border-radius: 20px; margin-right: 5px;
}
.ctrl-filter-group label {
color: #ccc; font-size: 0.8em; cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none;
}
.ctrl-filter-group input { accent-color: #e44c65; cursor: pointer; }
@media (max-width: 600px) {
#controls-bar { gap: 10px; padding: 10px 15px; bottom: 20px; }
.slider-group, .divider { display: none; }
.ctrl-filter-group { padding: 5px 10px; gap: 8px; }
.ctrl-filter-group label span { display: none; }
}
#palette-panel { position: absolute; bottom: 100px; left: 50%; transform: translateX(-50%); background: rgba(255,255,255,0.9); padding: 10px; border-radius: 10px; display: none; grid-template-columns: repeat(6, 1fr); gap: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 30; }
@ -116,7 +138,7 @@
.color-chip.auto-chip { background: linear-gradient(135deg, red, orange, yellow, green, blue, indigo, violet); }
.color-chip.auto-chip::after { content: 'A'; position: absolute; top:50%; left:50%; transform:translate(-50%, -50%); color: white; font-weight: bold; font-family: sans-serif; text-shadow: 0 0 2px black; }
.ctrl-btn { background: none; border: none; color: white; font-size: 1.2em; cursor: pointer; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
.ctrl-btn { background: none; border: none; color: white; font-size: 1.2em; cursor: pointer; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: 0.2s; flex-shrink: 0; }
.ctrl-btn:hover { background: rgba(255,255,255,0.2); color: #e44c65; }
.ctrl-btn.active { color: #e44c65; background: rgba(255,255,255,0.1); }
@ -133,6 +155,31 @@
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; }
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; }
.exit-btn { position: absolute; top: 20px; right: 20px; z-index: 1000; 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; }
/* 편집 모달 */
#edit-modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); z-index: 2000;
display: none; justify-content: center; align-items: center;
backdrop-filter: blur(5px);
}
.modal-content {
background: #222; color: #fff; padding: 25px; border-radius: 15px;
width: 90%; max-width: 400px; display: flex; flex-direction: column; gap: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid #444;
}
.modal-title { font-size: 1.2em; font-weight: bold; color: #e44c65; }
.modal-input {
width: 100%; padding: 12px; border-radius: 5px; border: 1px solid #555;
background: #333; color: #fff; font-family: inherit; box-sizing: border-box; outline: none;
}
.modal-input:focus { border-color: #e44c65; }
.modal-btns { display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px; }
.m-btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; transition: 0.2s; }
.btn-save { background: #e44c65; color: white; }
.btn-save:hover { background: #ff6b81; }
.btn-cancel { background: #555; color: white; }
.btn-cancel:hover { background: #666; }
</style>
</head>
<body>
@ -152,7 +199,8 @@
</div>
<div class="p-bottom">
<div class="p-date"></div>
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span><span class="p-tags p-keywords"></span></div>
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span></div>
<div class="p-meta-info"></div>
</div>
</div>
@ -167,7 +215,8 @@
</div>
<div class="p-bottom">
<div class="p-date"></div>
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span><span class="p-tags p-keywords"></span></div>
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span></div>
<div class="p-meta-info"></div>
</div>
</div>
</div>
@ -175,11 +224,40 @@
<div id="palette-panel"></div>
<div id="exif-full-overlay"><div style="color:#e44c65; font-weight:bold; margin-bottom:10px; border-bottom:1px solid #555; padding-bottom:5px;">EXIF INFO</div><div id="exif-list">Loading...</div></div>
<div id="edit-modal">
<div class="modal-content">
<div class="modal-title"><i class="fas fa-edit"></i> 정보 수정</div>
<div>
<label style="font-size:0.9em; color:#aaa; display:block; margin-bottom:5px;">메모</label>
<input type="text" id="edit-memo" class="modal-input" placeholder="이 사진에 대한 메모...">
</div>
<div>
<label style="font-size:0.9em; color:#aaa; display:block; margin-bottom:5px;">태그 (콤마로 구분)</label>
<input type="text" id="edit-tags" class="modal-input" placeholder="예: 여행, 가족, 맛집">
</div>
<div class="modal-btns">
<button class="m-btn btn-cancel" onclick="closeEditModal()">취소</button>
<button class="m-btn btn-save" onclick="saveMetadata()">저장</button>
</div>
</div>
</div>
<div id="controls-bar">
<div class="ctrl-filter-group" title="필터">
<label><input type="checkbox" id="chk-photo" checked><i class="fas fa-image"></i><span>Img</span></label>
<label><input type="checkbox" id="chk-video" checked><i class="fas fa-video"></i><span>Vid</span></label>
<label><input type="checkbox" id="chk-live" checked><i class="fas fa-bolt"></i><span>Live</span></label>
</div>
<button class="ctrl-btn" onclick="prevPhoto()" title="이전"><i class="fas fa-step-backward"></i></button>
<button class="ctrl-btn" onclick="togglePause()" id="btn-play" title="재생/정지"><i class="fas fa-pause"></i></button>
<button class="ctrl-btn" onclick="nextPhotoManual()" title="다음"><i class="fas fa-step-forward"></i></button>
<div class="divider"></div>
<div class="slider-group" title="사진 대기 시간">
<i class="fas fa-stopwatch"></i><input type="range" min="3" max="60" value="10" step="1" oninput="updateSpeed(this.value)"><span class="slider-label"><span id="speed-display">10</span>s</span>
</div>
@ -191,9 +269,12 @@
<div class="slider-group" title="텍스트 프레임 크기">
<i class="fas fa-text-height"></i><input type="range" min="0.6" max="1.4" value="1.0" step="0.1" oninput="updateTextScale(this.value)">
</div>
<div class="divider"></div>
<button class="ctrl-btn" id="btn-edit" onclick="openEditModal()" title="정보 편집"><i class="fas fa-pen"></i></button>
<button class="ctrl-btn" id="btn-info" onclick="toggleInfo()" title="상세 정보"><i class="fas fa-info-circle"></i></button>
<button class="ctrl-btn" onclick="togglePalette()" title="배경 변경"><i class="fas fa-palette"></i></button>
<button class="ctrl-btn" onclick="toggleFullscreen()" id="btn-fullscreen" title="전체화면"><i class="fas fa-expand"></i></button>
</div>
</div>
@ -230,10 +311,10 @@
};
let isDynamicBg = true;
let showFullExif = false;
// [NEW] 컨트롤 바 숨김/표시 타이머 변수
let controlsHideTimer;
let currentMetadata = { id: null, memo: "", tags: [] };
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
@ -249,34 +330,106 @@
window.onload = () => {
initPalette();
initControlsAutoHider(); // [NEW] 자동 숨김 초기화
initControlsAutoHider();
initFullscreenListener();
};
// [NEW] 컨트롤 바 자동 숨김 로직
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => console.error(err));
} else {
if (document.exitFullscreen) document.exitFullscreen();
}
}
function initFullscreenListener() {
document.addEventListener('fullscreenchange', () => {
const btn = document.getElementById('btn-fullscreen');
if (document.fullscreenElement) {
btn.innerHTML = '<i class="fas fa-compress"></i>';
btn.title = "전체화면 종료";
} else {
btn.innerHTML = '<i class="fas fa-expand"></i>';
btn.title = "전체화면";
}
});
}
function openEditModal() {
if (!currentMetadata.id) return;
if (!isPaused) togglePause();
document.getElementById('edit-memo').value = currentMetadata.memo || "";
document.getElementById('edit-tags').value = currentMetadata.tags ? currentMetadata.tags.join(", ") : "";
document.getElementById('edit-modal').style.display = 'flex';
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
async function saveMetadata() {
const memoVal = document.getElementById('edit-memo').value.trim();
const tagsStr = document.getElementById('edit-tags').value.trim();
const tagsArr = tagsStr.split(',').map(t => t.trim()).filter(t => t !== "");
const payload = {
id: currentMetadata.id,
memo: memoVal,
tags: tagsArr
};
try {
const res = await fetch('/api/synology/metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
body: JSON.stringify(payload)
});
if (res.ok) {
currentMetadata.memo = memoVal;
currentMetadata.tags = tagsArr;
updateCardMetaUI(activeCardNum);
closeEditModal();
} else {
alert("저장 실패");
}
} catch (e) {
console.error(e);
alert("네트워크 오류");
}
}
function updateCardMetaUI(cardNum) {
const card = document.getElementById(`polaroid-card-${cardNum}`);
const metaDiv = card.querySelector('.p-meta-info');
let html = "";
if (currentMetadata.tags && currentMetadata.tags.length > 0) {
currentMetadata.tags.forEach(tag => {
html += `<span class="tag-badge">#${tag}</span>`;
});
}
if (currentMetadata.memo) {
html += `<span class="memo-text">${currentMetadata.memo}</span>`;
}
metaDiv.innerHTML = html;
}
function initControlsAutoHider() {
const controls = document.getElementById('controls-bar');
function showControls() {
controls.classList.add('controls-visible');
resetHideTimer();
}
function resetHideTimer() {
clearTimeout(controlsHideTimer);
// 마우스가 컨트롤 바 위에 있거나 슬라이더 조작 중일 때는 숨기지 않음
if (controls.matches(':hover')) return;
controlsHideTimer = setTimeout(() => {
controls.classList.remove('controls-visible');
}, 3000); // 3초 뒤 숨김
}, 3000);
}
// 이벤트 감지: 마우스 이동, 클릭, 터치
document.addEventListener('mousemove', showControls);
document.addEventListener('click', showControls);
document.addEventListener('touchstart', showControls);
// 컨트롤 바에서 마우스가 나갈 때 타이머 재시작
controls.addEventListener('mouseleave', resetHideTimer);
}
@ -320,11 +473,10 @@
function showMessage(msg) { document.getElementById('message').innerText = msg; }
// [수정] 슬라이더 조작 시 타이머 리셋 (숨김 방지)
function updateSpeed(val) {
currentSpeed = val * 1000;
document.getElementById('speed-display').innerText = val;
clearTimeout(controlsHideTimer); // 조작 중엔 숨기지 않음
clearTimeout(controlsHideTimer);
}
function updateAnimSpeed(val) {
document.documentElement.style.setProperty('--anim-duration', val + 's');
@ -360,11 +512,26 @@
if (data.success) {
data.data.list.forEach(item => {
if (item.type === 'folder') return;
// [중요] 타입 판별 및 저장
let type = 'photo';
if (item.type === 'video') type = 'video';
else if (item.type === 'live' || (item.additional && item.additional.live_photo)) type = 'live';
item._customType = type; // 필터링용 태그
allPhotoItems.push(item);
});
const total = data.data.total;
if (offset + limit < total) { showMessage(`${allPhotoItems.length} 로딩 ...`); await fetchAllPhotos(offset + limit); }
else { document.getElementById('login-overlay').style.display = 'none'; loadNextPhoto(); }
else {
if (allPhotoItems.length === 0) {
showMessage("No items found.");
return;
}
document.getElementById('login-overlay').style.display = 'none';
loadNextPhoto();
}
}
} catch (error) { showMessage('Fetch Failed'); }
}
@ -379,12 +546,54 @@
function nextPhotoManual() { isPaused = true; updatePlayButton(); loadNextPhoto(true); }
function updatePlayButton() { document.getElementById('btn-play').innerHTML = isPaused ? '<i class="fas fa-play"></i>' : '<i class="fas fa-pause"></i>'; }
// [핵심] 랜덤 뽑기 시 필터 체크
function loadNextPhoto(forceNew = false) {
clearTimeout(slideshowTimeout);
if (allPhotoItems.length === 0) return;
let nextItem;
if (!forceNew && historyIndex < photoHistory.length - 1) { historyIndex++; nextItem = photoHistory[historyIndex]; }
else { nextItem = allPhotoItems[Math.floor(Math.random() * allPhotoItems.length)]; photoHistory.push(nextItem); historyIndex = photoHistory.length - 1; }
// 1. 현재 필터 상태 확인
const showPhoto = document.getElementById('chk-photo').checked;
const showVideo = document.getElementById('chk-video').checked;
const showLive = document.getElementById('chk-live').checked;
if (!showPhoto && !showVideo && !showLive) {
if(!isPaused) togglePause();
alert("적어도 하나의 미디어 타입은 선택해야 합니다.");
return;
}
let nextItem = null;
if (!forceNew && historyIndex < photoHistory.length - 1) {
historyIndex++;
nextItem = photoHistory[historyIndex];
}
else {
// 필터에 맞는 아이템 찾을 때까지 랜덤 시도
let maxAttempts = 2000;
while (maxAttempts > 0) {
const candidate = allPhotoItems[Math.floor(Math.random() * allPhotoItems.length)];
const type = candidate._customType;
if ((type === 'photo' && showPhoto) ||
(type === 'video' && showVideo) ||
(type === 'live' && showLive)) {
nextItem = candidate;
break;
}
maxAttempts--;
}
if (!nextItem) {
alert("조건에 맞는 사진이 없습니다.");
if(!isPaused) togglePause();
return;
}
photoHistory.push(nextItem);
historyIndex = photoHistory.length - 1;
}
if (photoHistory.length > 100) { photoHistory.shift(); historyIndex--; }
displayPhoto(nextItem);
}
@ -435,19 +644,56 @@
return rawExts.includes(ext) || heicExts.includes(ext);
}
function displayPhoto(item) {
async function displayPhoto(item) {
let cacheKey = "";
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.cache_key) {
cacheKey = item.additional.thumbnail.cache_key;
}
let baseApiUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${item.id}&cacheKey=${cacheKey}`;
// [정보 추출: FolderID, UUID, Filename]
let folderId = item.folder_id;
let mediaUuid = null;
if (item.additional && item.additional.exif) {
if (item.additional.exif.MediaGroupUUID) {
mediaUuid = item.additional.exif.MediaGroupUUID;
} else if (item.additional.exif.ContentIdentifier) {
const cid = item.additional.exif.ContentIdentifier;
mediaUuid = cid.includes('#') ? cid.split('#')[1] : cid;
}
}
let liveUnitId = null;
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.live_photo_unit_id) {
liveUnitId = item.additional.thumbnail.live_photo_unit_id;
}
const isLive = (item.type === 'live' || !!liveUnitId || !!mediaUuid);
const isVideo = isVideoItem(item);
const isRaw = isRawItem(item);
const requestMode = isRaw ? '&mode=thumbnail' : '&mode=download';
const finalUrl = baseApiUrl + requestMode;
let requestMode = '&mode=download';
let extraParams = '';
if (isLive) {
// if (liveUnitId) {
// requestMode = '&mode=video';
// } else if (folderId) {
// // [핵심] 백엔드의 Plan A(UUID) + Plan B(Filename)를 위해 정보 전달
// requestMode = '&mode=video';
// extraParams = `&folderId=${folderId}&filename=${encodeURIComponent(item.filename)}`;
// if (mediaUuid) extraParams += `&uuid=${encodeURIComponent(mediaUuid)}`;
// } else {
requestMode = '&mode=thumbnail';
// }
} else if (isVideo) {
requestMode = '&mode=video';
} else if (isRaw) {
requestMode = '&mode=thumbnail';
}
let targetId = liveUnitId || item.id;
let baseApiUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${targetId}&cacheKey=${cacheKey}`;
const finalUrl = baseApiUrl + requestMode + extraParams;
const nextCardNum = (activeCardNum === 1) ? 2 : 1;
const nextCard = document.getElementById(`polaroid-card-${nextCardNum}`);
@ -455,9 +701,30 @@
const nextImgEl = nextCard.querySelector('.card-img');
const nextVidEl = nextCard.querySelector('.card-vid');
currentMetadata = { id: item.id, memo: "", tags: [] };
let imageSrc = finalUrl;
const isPlayingVideo = (requestMode === '&mode=video');
if (!isPlayingVideo) {
try {
const response = await fetch(finalUrl);
if (!response.ok) throw new Error("Network response was not ok");
const blob = await response.blob();
imageSrc = URL.createObjectURL(blob);
} catch (err) {
console.error("Image Fetch Error:", err);
imageSrc = finalUrl;
}
}
const tempImgUrl = isPlayingVideo ?
`/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${item.id}&cacheKey=${cacheKey}&mode=thumbnail` :
imageSrc;
const tempImg = new Image();
tempImg.crossOrigin = "Anonymous";
tempImg.src = finalUrl;
tempImg.src = tempImgUrl;
tempImg.onload = function() {
const dims = calculateOptimalSize(tempImg.naturalWidth, tempImg.naturalHeight);
@ -468,17 +735,18 @@
resetCardInfo(nextCard, nextCardNum);
if (isVideo) {
if (isPlayingVideo) {
nextImgEl.style.display = 'none';
nextVidEl.style.display = 'block';
nextVidEl.removeAttribute('src');
nextVidEl.src = finalUrl;
nextVidEl.load();
nextVidEl.play().catch(e => console.log("Auto-play blocked", e));
let dateText = (item.type === 'live') ? "Live Photo" : "Video";
let dateText = isLive ? "Live Photo" : "Video";
if (item.takenTime) dateText = new Date(item.takenTime * 1000).toLocaleDateString();
typeText(nextCard.querySelector('.p-date'), dateText, nextCardNum, 'date', null);
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>${item.type === 'live' ? 'Live Photo' : 'Video File'}<br>${item.filename || ""}</div>`;
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>${dateText}<br>${item.filename || ""}</div>`;
updateBgFromImage(tempImg);
} else {
@ -486,11 +754,11 @@
nextVidEl.pause();
nextVidEl.removeAttribute('src');
nextImgEl.style.display = 'block';
nextImgEl.src = tempImg.src;
nextImgEl.src = imageSrc;
let nextFullExifHtml = "<div style='text-align:center'>No Data</div>";
EXIF.getData(tempImg, function() {
bindExifData(this, nextCard, nextCardNum);
bindExifDataOnly(this, nextCard, nextCardNum);
nextFullExifHtml = generateFullExifHtml(this);
if (isRaw) {
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>RAW/HEIC Preview<br>${item.filename}</div>`;
@ -498,7 +766,6 @@
});
updateBgFromImage(tempImg);
if (!isRaw) {
requestAnimationFrame(() => {
document.getElementById('exif-list').innerHTML = nextFullExifHtml;
@ -506,9 +773,17 @@
}
}
fetchMetadataAndAddress(item, nextCard, nextCardNum);
requestAnimationFrame(() => {
nextCard.classList.add('active');
currentCard.classList.remove('active');
const prevImg = currentCard.querySelector('.card-img');
if (prevImg.src.startsWith('blob:')) {
URL.revokeObjectURL(prevImg.src);
prevImg.removeAttribute('src');
}
const prevVid = currentCard.querySelector('.card-vid');
if(prevVid) { prevVid.pause(); prevVid.removeAttribute('src'); prevVid.load(); }
});
@ -527,24 +802,11 @@
};
}
function generateFullExifHtml(img) {
const allTags = EXIF.getAllTags(img);
let html = "";
for (let key in allTags) {
if (key === 'thumbnail' || key.startsWith('Thumbnail')) continue;
let val = allTags[key];
if (typeof val === 'object') {
if (val instanceof Number) val = val.valueOf(); else val = JSON.stringify(val);
}
if (String(val).length > 40) val = String(val).substring(0, 40) + "...";
html += `<div class="exif-item"><span class="exif-key">${key}</span><span class="exif-val">${val}</span></div>`;
}
return html || "<div style='text-align:center'>No EXIF Data</div>";
}
function calculateOptimalSize(imgW, imgH) {
const maxW = window.innerWidth * 0.95;
const maxH = window.innerHeight * 0.82;
const safeMargin = 20;
const maxW = window.innerWidth - safeMargin;
const maxH = window.innerHeight * 0.85;
const scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--text-scale')) || 1.0;
const chromeH = (30 * scale) + (70 * scale) + 20;
const maxImgH = maxH - chromeH;
@ -558,7 +820,6 @@
targetImgW = targetImgW * scaleFactor;
targetImgH = targetImgH * scaleFactor;
}
if (targetImgW < 320) targetImgW = 320;
return {
cardWidth: Math.round(targetImgW),
@ -570,14 +831,9 @@
card.querySelector('.p-camera').innerHTML = '<i class="fas fa-camera"></i> -';
card.querySelector('.p-zoom').innerHTML = '<i class="fas fa-search"></i> -';
card.querySelector('.p-coords').innerText = '';
const dateEl = card.querySelector('.p-date');
dateEl.innerText = ''; dateEl.classList.remove('typing-cursor');
const addrEl = card.querySelector('.p-address');
addrEl.innerText = ''; addrEl.classList.remove('typing-cursor');
card.querySelector('.p-tags').innerText = '';
card.querySelector('.p-date').innerText = '';
card.querySelector('.p-address').innerText = '';
card.querySelector('.p-meta-info').innerHTML = '';
if(typingTimers[cardNum].date) clearTimeout(typingTimers[cardNum].date);
if(typingTimers[cardNum].addr) clearTimeout(typingTimers[cardNum].addr);
@ -601,7 +857,7 @@
typeFunc();
}
function bindExifData(img, card, cardNum) {
function bindExifDataOnly(img, card, cardNum) {
const make = EXIF.getTag(img, "Make"); const model = EXIF.getTag(img, "Model");
let camText = model ? model : (make ? make : "Unknown Camera");
if(camText.length > 20) camText = camText.substring(0, 18) + "..";
@ -616,42 +872,55 @@
const parts = dateTaken.split(/[: ]/);
if(parts.length >= 3) dateStr = `${parts[0]}. ${parts[1]}. ${parts[2]}.`;
}
typeText(card.querySelector('.p-date'), dateStr, cardNum, 'date');
const lat = EXIF.getTag(img, "GPSLatitude"); const lon = EXIF.getTag(img, "GPSLongitude");
const addrEl = card.querySelector('.p-address');
const dateEl = card.querySelector('.p-date');
let addrPromise = Promise.resolve("No GPS Info");
if (lat && lon) {
const latRef = EXIF.getTag(img, "GPSLatitudeRef"); const lonRef = EXIF.getTag(img, "GPSLongitudeRef");
const decLat = convertDMSToDD(lat, latRef); const decLon = convertDMSToDD(lon, lonRef);
card.querySelector('.p-coords').innerText = `${decLat.toFixed(4)}, ${decLon.toFixed(4)}`;
addrPromise = fetch(`/api/synology/geocode?lat=${decLat}&lon=${decLon}`, { headers: { [csrfHeader]: csrfToken } })
.then(res => res.ok ? res.json() : {})
.then(data => {
if(data.address) {
const city = data.address.city || data.address.province || "";
const dist = data.address.borough || data.address.district || "";
const country = data.address.country || "";
return (country !== "대한민국" ? country + " " : "") + `${city} ${dist}`;
}
return "Unknown Location";
})
.catch(() => "Location Error");
} else {
card.querySelector('.p-coords').innerText = "";
}
}
typeText(dateEl, dateStr, cardNum, 'date', () => {
addrPromise.then(addrText => {
typeText(addrEl, addrText, cardNum, 'addr', null);
});
});
function fetchMetadataAndAddress(item, card, cardNum) {
fetch(`/api/synology/geocode?id=${item.id}&lat=0&lon=0`, {
headers: { [csrfHeader]: csrfToken }
})
.then(res => res.json())
.then(data => {
if(item.id == currentMetadata.id) {
currentMetadata.memo = data.memo;
currentMetadata.tags = data.tags;
const desc = EXIF.getTag(img, "ImageDescription");
card.querySelector('.p-tags').innerText = (desc && desc.trim() !== "") ? `#${desc}` : "";
let addrText = "";
if (data.address && data.address !== 'Unknown') {
if (typeof data.address === 'object') {
addrText = data.address.display_name || "Location Found";
} else {
addrText = data.address;
}
}
if(addrText) {
typeText(card.querySelector('.p-address'), addrText, cardNum, 'addr');
}
updateCardMetaUI(cardNum);
}
});
}
function generateFullExifHtml(img) {
const allTags = EXIF.getAllTags(img);
let html = "";
for (let key in allTags) {
if (key === 'thumbnail' || key.startsWith('Thumbnail')) continue;
let val = allTags[key];
if (typeof val === 'object') {
if (val instanceof Number) val = val.valueOf(); else val = JSON.stringify(val);
}
if (String(val).length > 40) val = String(val).substring(0, 40) + "...";
html += `<div class="exif-item"><span class="exif-key">${key}</span><span class="exif-val">${val}</span></div>`;
}
return html || "<div style='text-align:center'>No EXIF Data</div>";
}
function convertDMSToDD(coord, ref) {