From 526a7b598fa0a43931a1a0c66be8b19de9a04120 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 31 Dec 2025 15:44:15 +0900 Subject: [PATCH] .. --- .../controllers/SynologyProxyController.kt | 225 +++++--- .../back/lun/model/PhotoMetadata.kt | 19 + .../lun/repository/PhotoMetadataRepository.kt | 7 + .../templates/content/slideshow.html | 487 ++++++++++++++---- 4 files changed, 569 insertions(+), 169 deletions(-) create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/model/PhotoMetadata.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/repository/PhotoMetadataRepository.kt diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt index 8c5738b..762896e 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt @@ -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 +) + @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): ResponseEntity { 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 { + suspend fun getList(@RequestBody request: SynologyListRequest): ResponseEntity { 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 { - 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())) + } + ResponseEntity.ok(mapOf("address" to "Unknown")) + } catch (e: Exception) { + ResponseEntity.ok(mapOf("address" to "Unknown")) + } + } + + @PostMapping("/metadata") + suspend fun saveMetadata(@RequestBody req: MetadataRequest): ResponseEntity { + 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 { 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를 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) { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PhotoMetadata.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PhotoMetadata.kt new file mode 100644 index 0000000..7942da3 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PhotoMetadata.kt @@ -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 = mutableListOf() +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/PhotoMetadataRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PhotoMetadataRepository.kt new file mode 100644 index 0000000..4dfaa60 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/PhotoMetadataRepository.kt @@ -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 { +} \ No newline at end of file diff --git a/src/main/resources/templates/content/slideshow.html b/src/main/resources/templates/content/slideshow.html index 4e88af9..428f338 100644 --- a/src/main/resources/templates/content/slideshow.html +++ b/src/main/resources/templates/content/slideshow.html @@ -2,7 +2,8 @@ - + + BUM's NAS Slideshow @@ -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; } @@ -152,7 +199,8 @@
-
Loading...
+
Loading...
+
@@ -167,7 +215,8 @@
-
Loading...
+
Loading...
+
@@ -175,11 +224,40 @@
EXIF INFO
Loading...
+
+ +
+
+
+ + + +
+ +
+
10s
@@ -191,9 +269,12 @@
+
+ +
@@ -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 = ''; + btn.title = "전체화면 종료"; + } else { + btn.innerHTML = ''; + 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 += `#${tag}`; + }); + } + if (currentMetadata.memo) { + html += `${currentMetadata.memo}`; + } + 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 ? '' : ''; } + // [핵심] 랜덤 뽑기 시 필터 체크 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 = `
${item.type === 'live' ? 'Live Photo' : 'Video File'}
${item.filename || ""}
`; + document.getElementById('exif-list').innerHTML = `
${dateText}
${item.filename || ""}
`; 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 = "
No Data
"; EXIF.getData(tempImg, function() { - bindExifData(this, nextCard, nextCardNum); + bindExifDataOnly(this, nextCard, nextCardNum); nextFullExifHtml = generateFullExifHtml(this); if (isRaw) { document.getElementById('exif-list').innerHTML = `
RAW/HEIC Preview
${item.filename}
`; @@ -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 += `
${key}${val}
`; - } - return html || "
No EXIF Data
"; - } - 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 = ' -'; card.querySelector('.p-zoom').innerHTML = ' -'; 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 += `
${key}${val}
`; + } + return html || "
No EXIF Data
"; } function convertDMSToDD(coord, ref) {