본문 바로가기
개발

[안드로이드/코틀린] 유튜브 api 사용해보기

by kks950115 2024. 2. 13.
728x90

부트캠프 과제로 유튜브 api를 사용해보았다. 짜증나는 점은 기능이 매우 많은데 한번에 모두 불러올 수가 없었다. 

검색 페이지를 맡아서 리사이클러 뷰에 영상 목록을 불러오는데 영상을 검색할 때 api요청을 보내고, 해당 결과를 또 api 요청을 보내서 영상에 대한 정보를 받아와야 했다.  

part에다가 snippet,contentdetail 등 필요한 부분만 뽑아서 쓰는 방식이었다.

 

우선 api 통신을 하기위해 레트로핏2 라는 라이브러리를 사용한다. 

object RetrofitClient {

    private val BASE_URL = "https://www.googleapis.com/youtube/v3/"
    val apiService: YouTubeApi get() =
        instance.create(YouTubeApi::class.java)

    // Retrofit 인스턴스를 초기화하고 반환한다.

	//이 메소드는 통신로그를 볼 수 있게한다.
    private fun createOkHttpClient(): OkHttpClient {
        val interceptor = HttpLoggingInterceptor()

        interceptor.level = HttpLoggingInterceptor.Level.BODY

        return OkHttpClient.Builder()
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            .writeTimeout(20, TimeUnit.SECONDS)
            .addNetworkInterceptor(interceptor)
            .build()
    }


    private val instance: Retrofit
        private get() {
            // Gson 객체 생성. setLenient()는 JSON 파싱이 좀 더 유연하게 처리되도록 한다.
            val gson = GsonBuilder().setLenient().create()

            // Retrofit 빌더를 사용하여 Retrofit 인스턴스 생성
            return Retrofit.Builder()
                .baseUrl(BASE_URL)  // 기본 URL 설정
                .addConverterFactory(GsonConverterFactory.create(gson)).client(createOkHttpClient())  // JSON 파싱을 위한 컨버터 추가
                .build()
        }
}

 

이제 apiService를 호출하면 쓸 수 있다.

쿼리를 입력해줄 인터페이스도 만들자.

private const val API_KEY = "apikey 중요하니 보여줄 수 없다."


interface YouTubeApi {

  
    @GET("search")
    suspend fun searchVideo(
        @Query("key") apiKey: String,
        @Query("part") part: String,
        @Query("q") q: String? = null,
        @Query("channelId") channelId: String? = null,
        @Query("maxResults") maxResults: Int,
        @Query("order") order: String,
        @Query("regionCode") regionCode: String,
        @Query("type") type: String,
        @Query("videoDuration") videoDuration: String
    ): YoutubeSearchResponse?

    /**
     * YouTube Data API를 사용하여 비디오 아이디를 이용하여 해당 비디오의 통계 정보를 가져옵니다..
     *
     * @param id 비디오의 ID
     * @return 채널의 최신 비디오 목록에 대한 ApiResponse<TrendVideoModel>
     */
    @GET("videos")
    suspend fun getViewCountByVideoId(
        @Query("key") apiKey: String,
        @Query("part") part: String = "statics",
        @Query("id") id: String
    ): YoutubeVideoResponse?


    

    @GET("channels")
    suspend fun getChannelDetail(
        @Query("key") apiKey: String,
        @Query("id") id: String,
        @Query("part") part: String? = "snippet%2Cstatistics%2CbrandingSettings",
    ): YoutubeChannelResponse

    @GET("playlists")
    suspend fun getPlaylistsByChannelId(
        @Query("key") apiKey: String,
        @Query("channelId") channelId: String,
        @Query("part") part: String? = "snippet%2CcontentDetails",
    ): PlaylistsResponse

    @GET("search")
    suspend fun searchShortsByChannelId(
        @Query("key") apiKey: String,
        @Query("part") part: String,
        @Query("channelId") channelId: String,
        @Query("maxResults") maxResults: Int,
        @Query("order") order: String,
        @Query("regionCode") regionCode: String,
        @Query("type") type: String,
        @Query("videoDuration") videoDuration: String
    ): YoutubeSearchResponse?

    @GET("videos")
    suspend fun getDurationByChannelId(
        @Query("key") apiKey: String,
        @Query("id") id: String,
        @Query("part") part: String = "contentDetails",
    ): YoutubeVideoResponse
}

 

@Query("맵핑")으로 이어줄 수 있다. 사실 @Query를 안써도 변수의 이름만 같으면 불러와진다.

 

@GET에는 http 요청을 적어준다. 예시 : https://www.googleapis.com/youtube/v3/search

 

하지만 위에 인터페이스에는 videos 나 search만 써있다. 그 이유는 레트로핏 객체에서 baseurl로 이미 앞에꺼를 정해줬기 때문이다.

이렇게하면 일일히 전부 적어줄 필요가 없다.

 

이제 api요청을 받아줄  model을 만들자. 그릇이라고 이해하면 편하다.

// YouTube 검색 응답에 대한 모델
data class YoutubeSearchResponse(
    @SerializedName("kind") // 데이터의 종류
    val kind: String?,
    @SerializedName("etag") // 엔터티 태그
    val etag: String?,
    @SerializedName("nextPageToken") // 다음 페이지 토큰
    val nextPageToken: String?,
    @SerializedName("regionCode") // 지역 코드
    val regionCode: String?,
    @SerializedName("pageInfo") // 페이지 정보
    val pageInfo: PageInfo?,
    @SerializedName("items") // 비디오 항목 목록
    val items: List<YoutubeSearchItem>?
)

// YouTube 비디오 검색 결과 항목에 대한 모델
data class YoutubeSearchItem(
    @SerializedName("kind") // 항목의 종류
    val kind: String?,
    @SerializedName("etag") // 엔터티 태그
    val etag: String?,
    @SerializedName("id") // 비디오 ID
    val id: YoutubeVideoId?,
    @SerializedName("snippet") // 비디오 스니펫 정보
    val snippet: YoutubeVideoSnippet?
)

// YouTube 비디오 ID에 대한 모델
data class YoutubeVideoId(
    @SerializedName("kind") // ID의 종류
    val kind: String?,
    @SerializedName("videoId") // 비디오 ID
    val videoId: String?
)

// YouTube 비디오 스니펫 정보에 대한 모델
data class YoutubeVideoSnippet(
    @SerializedName("publishedAt") // 게시일
    val publishedAt: String?,
    @SerializedName("channelId") // 채널 ID
    val channelId: String?,
    @SerializedName("title") // 제목
    val title: String?,
    @SerializedName("description") // 설명
    val description: String?,
    @SerializedName("thumbnails") // 썸네일 이미지 정보
    val thumbnails: YoutubeThumbnails?,
    @SerializedName("channelTitle") // 채널 제목
    val channelTitle: String?,
    @SerializedName("liveBroadcastContent") // 라이브 방송 내용
    val liveBroadcastContent: String?,
    @SerializedName("publishTime") // 게시 시간
    val publishTime: String?
)


// YouTube 비디오 썸네일 정보에 대한 모델
data class YoutubeThumbnails(
    @SerializedName("default") // 기본 썸네일
    val default: Thumbnail?,
    @SerializedName("medium") // 중간 크기 썸네일
    val medium: Thumbnail?,
    @SerializedName("high") // 높은 해상도 썸네일
    val high: Thumbnail?
)

// 썸네일 이미지에 대한 모델
data class Thumbnail(
    @SerializedName("url") // 이미지 URL
    val url: String?,
    @SerializedName("width") // 이미지 너비
    val width: Int?,
    @SerializedName("height") // 이미지 높이
    val height: Int?
)


data class YoutubeVideoResponse(
    @SerializedName("etag")
    val etag: String,
    @SerializedName("items")
    val items: List<Item>,
    @SerializedName("kind")
    val kind: String,
    @SerializedName("nextPageToken")
    val nextPageToken: String,
    @SerializedName("pageInfo")
    val pageInfo: PageInfo
)

data class Item(
    @SerializedName("etag")
    val etag: String,
    @SerializedName("id")
    val id: String,
    @SerializedName("kind")
    val kind: String,
    @SerializedName("snippet")
    val snippet: VideoSnippet,
    val contentDetails: VideoContentDetails? = null,
    val statistics: VideoStatistics? = null,
)

data class VideoSnippet(
    @SerializedName("categoryId")
    val categoryId: String,
    @SerializedName("channelId")
    val channelId: String,
    @SerializedName("channelTitle")
    val channelTitle: String,
    @SerializedName("defaultAudioLanguage")
    val defaultAudioLanguage: String,
    @SerializedName("defaultLanguage")
    val defaultLanguage: String,
    @SerializedName("description")
    val description: String,
    @SerializedName("liveBroadcastContent")
    val liveBroadcastContent: String,
    @SerializedName("localized")
    val localized: Localized,
    @SerializedName("publishedAt")
    val publishedAt: String,
    @SerializedName("tags")
    val tags: List<String>,
    @SerializedName("thumbnails")
    val thumbnails: Thumbnails,
    @SerializedName("title")
    val title: String
)

data class Thumbnails(
    @SerializedName("default")
    val default: Default,
    @SerializedName("high")
    val high: High?,
    @SerializedName("maxres")
    val maxres: Maxres?,
    @SerializedName("medium")
    val medium: Medium?,
    @SerializedName("standard")
    val standard: Standard?
)

data class Standard(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class PageInfo(
    @SerializedName("resultsPerPage")
    val resultsPerPage: Int,
    @SerializedName("totalResults")
    val totalResults: Int
)

data class Medium(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class Maxres(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class Localized(
    @SerializedName("description")
    val description: String,
    @SerializedName("title")
    val title: String
)

data class High(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class Default(
    @SerializedName("height")
    val height: Int,
    @SerializedName("url")
    val url: String,
    @SerializedName("width")
    val width: Int
)

data class VideoContentDetails(
    val duration: String?,
    val dimension: String?,
    val definition: String?,
    val caption: String?,
    val licensedContent: Boolean?,
    val projection: String?
)


data class VideoStatistics(
    @SerializedName("viewCount")
    val viewCount: Int,
    @SerializedName("likeCount")
    val likeCount : Int,
    @SerializedName("favoriteCount")
    val favoriteCount : Int,
    @SerializedName("commentCount")
    val commentCount : Int
)

 

대형 사이트 아니랄까봐 받아올 값들이 엄청나게 많다... 

위 코드는 search와 videos에 대한 모델을 '일부' 구현한 것이다. 전부 다 적을라면 엄청나게 길어지므로 생략한다.

 

필자는 해당 모델을 그대로 쓰지 않고 아래 클래스를 따로 만들고  model에 있는 데이터를 넣어서 사용했다. 

지금 생각해보니 그대로 써도 됐을 거 같다....

class SearchListItem(
    var title : String?,
    val uploader : String?,
    var viewCount : Int?,
    var thumbnail : String?,
    var isInterset : Boolean,
    var videoCount : String?,
    var playTime : Long?
)

 

layout은 따로 올리진 않겠다. 이 글은 데이터를 받아오고 사용하는 것을 복습하기 위한 글이니까.

이제 활용할 차례다.

api요청은 얼마나 걸릴지 모르기 때문에 메인 스레드에서 실행하면 api 요청을 모두 받아올 때까지 화면이 멈춰있는 현상이 일어난다. 받아올 정보가 많지 않다면 잠깐 버벅이는 정도로 끝나겠지만 그 양이 방대하다면 매우매우 불편할 것이다.

그래서 코루틴을 활용한다. 놀고있는 프로세서에서 병렬적으로 작업을 처리하는 방식이다.

 

어떻게 풀어나갈까 생각하다가 문제에 봉착했었다.  search로 videoid를 받아오고 전역변수에 담아둔 다음에 해당 변수를 videos로 돌려서 영상 정보를 view에 뿌려줄 계획이였다.

메인스레드에서 이 작업을 실행한다면 생각한대로 작동할 것이다. 다만 이 작업이 코루틴영역에서 실행된다면 문제가 있다. 프로세스마다 작업을 끝내는 타이밍이 일정치 않을 것이고 그 상태로 다음 작업이 실행된다면 의도한 결과를 이루지 못할 것이기 떄문이다. 그래서 튜터님에게 상담한 결과 해결책을 찾았는데 그 해결책은 아래 코드에서 설명하겠다.

 

class SearchFragment : Fragment() {
    private lateinit var binding : SearchFragmentBinding
    private var resListItem = mutableListOf<SearchListItem>()
    private var resShortsItem = mutableListOf<SearchListItem>()
    private val listAdapter by lazy { SearchListAdapter(resListItem) }
    private val shortsAdapter by lazy { SearchShortsAdapter(resShortsItem) }

    private var listVideoIds = mutableListOf<String?>()
    private var shortsVideoIds = mutableListOf<String?>()
    private val apiKey = "소중한 apikey라 보여줄 수 없다."
  

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Timber.d("Create")

    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = SearchFragmentBinding.inflate(inflater,container,false)
        return binding.root
    }

    @SuppressLint("NotifyDataSetChanged")
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.rvSearchShorts.layoutManager =
            LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
        binding.rvSearchShorts.adapter = shortsAdapter

        binding.rvSearchList.layoutManager =
            LinearLayoutManager(context,LinearLayoutManager.VERTICAL,false)
        binding.rvSearchList.adapter = listAdapter

        binding.ivSearchSearchBtn.setOnClickListener {
            val query = binding.etSearchSearching.text.toString()
            
            //**펜드 메소드를 사용한다면 라이프사이클 스코프 안에서는 순차적으로 시행된다.
            lifecycleScope.launch { 
                val shortResult = getSearchShorts(query)
                if( shortResult?.pageInfo?.totalResults!! > 0 ) {
                    for(item in shortResult.items!!){
                        val title = item.snippet?.title
                        val uploader = item.snippet?.channelTitle
                        val url = item.snippet?.thumbnails?.default?.url

                        shortsVideoIds.add(item.id?.videoId)
                        resShortsItem.add(SearchListItem(title,uploader,0,url,false,"0",null))

                    }
                }
                val shortResult2 = getViewCount(shortsVideoIds)

                for(i in resShortsItem.indices){
                    val viewCount = shortResult2?.items?.get(i)?.statistics?.viewCount
                    if (viewCount != null) {
                        resShortsItem[i].viewCount = viewCount
                    }
                }

                val result = getSearchList(query)
                Timber.tag("test").d("result= %s", result)
                if( result?.pageInfo?.totalResults!! > 0 ) {
                    for(item in result.items!!){
                        val title = item.snippet?.title
                        val uploader = item.snippet?.channelTitle
                        val url = item.snippet?.thumbnails?.default?.url

                        listVideoIds.add(item.id?.videoId)
                        resListItem.add(SearchListItem(title,uploader,0,url!!,false,"0",null))


                    }
                }
                val result2 = getViewCount(listVideoIds)

                for(i in resListItem.indices){
                    val viewCount = result2?.items?.get(i)?.statistics?.viewCount
                    if (viewCount != null) {
                        resListItem[i].viewCount = viewCount
                    }
                }

                listAdapter.items = resListItem
                shortsAdapter.items = resShortsItem

                listAdapter.notifyDataSetChanged()
                shortsAdapter.notifyDataSetChanged()

            }
        }
    }
	//suspend 메소드
	  private suspend fun getSearchList(query: String)=
        withContext(Dispatchers.IO){
            apiService.searchVideo(
                apiKey = apiKey,
                part = "snippet",
                q = query,
                maxResults =5,
                order= "relevance",
                regionCode ="KR",
                type ="video",
                videoDuration = "any")
        }

    private suspend fun getSearchShorts(query: String) =
        withContext(Dispatchers.IO){
        apiService.searchVideo(
            apiKey = apiKey,
            part = "snippet",
            q = query,
            maxResults =5,
            order= "relevance",
            regionCode ="KR",
            type ="video",
            videoDuration = "short")
        }

    private suspend fun getViewCount(ids : MutableList<String?>)=
        withContext(Dispatchers.IO){
            val conrvertedId = ids.joinToString(",")
            apiService.getViewCountByVideoId(
                apiKey= apiKey,
                part="statistics",
                id= conrvertedId
            )
        }


}

 

suspend fun으로 해놓고 withContext(Dispatchers.IO) 로 네트워크 작업 용도에 맞게 io로 디스패쳐를 설정한 다음 코딩을 하면 된다.  

그럼 메인스레드서처럼 순서대로 작동한다. 

728x90
반응형

댓글