우리나라의 인터넷 라디오 청취 환경은 방송사별로 심하게 파편화되어 있는 경향이 강하다.
'TuneIn'과 같은 하나의 통합된 포털 안에서 원하는 방송사의 채널을 검색해 들을 수 있는 것이 아니라, 어느 한 방송사의 채널을 듣고 싶다면 해당 방송사가 운영하는 자체 서비스(앱)에 직접 접근해야 할 필요가 있는 다소 불편한 환경으로 이루어져 있는 것이다.
어차피 인터넷상에서 똑같은 프로토콜로 스트리밍 데이터를 내보내는 건 매한가지인데, 왜 굳이 자사 앱으로 접근해야만 하는 이런 불편한 청취 환경을 구축한 것일까?
여기에는 보이는 라디오 서비스, 청취자 참여 환경의 개선과 같은 여러 이유를 들 수 있지만 가장 핵심적인 이유로는 '광고 수익의 극대화'다. 청취자의 사용 환경을 자사 앱에 묶어 둠으로써 그 속에서 자사 수익과 직결되는 다양한 광고를 접하게 하는 것이 방송사 입장에서는 여러모로 이득이다.
나는 평소 라디오를 자주 청취하는 사람으로서, 가끔씩 FM 수신기 대신에 스마트폰으로 라디오 방송을 청취할 때마다 이런 불편함으로 골머리를 앓았던 적이 한두번이 아니었다.
이러다가 결국 못 참겠다 싶어서 나의 얄팍한 개발 지식을 총동원하여, 개발 공부도 할 겸 여러 방송사들의 인터넷 라디오 서비스에 사용되는 스트리밍 URL들을 '하나의 웹페이지' 안에 정리해 보는 토이 프로젝트를 벌려 보기로 했다.
따라서 이 글에서는 상기한 '웹라디오' 프로젝트를 기획하고 실질적인 결과물(radio.bsod.kr)을 도출해 내기까지 있었던 작업 과정에서 떠올린 아이디어를 정리해 보았다.
스트리밍 URL을 손에 넣기 위한 과정
KBS, MBC, SBS에 걸친 주요 지상파 방송사들의 인터넷 라디오 서비스에서 볼 수 있는 공통적인 특징은 다음과 같다. 스트리밍 URL를 그냥 순순히 주지 않는다는 것.
이들 방송사는 상대적으로 많은 청취자 수를 감안하여 스트리밍 데이터를 자사 서버에서 직접 송출하는 것이 아니라 외부의 CDN을 경유해서 송출하는데, 사용자가 실제로 쓰는 앱의 입장에서 스트리밍 데이터를 어떻게 획득하는지에 대한 과정을 간략하게 요약하면 다음과 같다.
① 앱에서 전용 API에 호출을 날려 CDN에 대한 스트리밍 URL을 요청
② API에서는 요청을 받아들여 일시적으로 접근 가능한 스트리밍 URL를 생성 후 전달
③ 앱에서는 전달받은 스트리밍 URL을 통해 CDN에서 데이터를 받아옴 (이때 CDN에서는 Key Pair을 통하여 URI의 유효성을 판별)
세 방송사 모두 위와 유사한 과정으로 스트리밍 URL의 획득과 활용이 이루어지는데, 아래부터는 방송사별로 조금씩 상이한 디테일을 정리한 것이다.
⚠️ 아래부터 작성된 정보는 각 방송사의 공식 온에어 사이트 등지에서 웹브라우저 개발자 도구를 통해 적법한 방법으로 취득이 가능한 정보입니다.
KBS
API 호출을 위한 URI
본사 채널의 스트림을 얻고자 하는 경우 다음과 같은 형식을 사용한다.
https://cfpwwwapi.kbs.co.kr/api/v1/landing/live/channel_code/[채널 코드]
지역국 채널의 스트림을 얻고자 하는 경우 다음과 같은 형식을 사용한다.
https://cfpwwwapi.kbs.co.kr/api/v1/landing/live/channel_code/[지역 코드]_[채널 코드]
채널 코드 일람:
21 | 제1라디오 |
22 | 제2라디오 (해피FM) |
23 | 제3라디오 (사랑의 소리) |
24 | 1FM (클래식FM) |
25 | 2FM (쿨FM) |
26 | 한민족방송 |
지역 코드 일람:
10 | 부산 |
20 | 창원 |
21 | 진주 |
30 | 대구 |
31 | 안동 |
32 | 포항 |
40 | 광주 |
41 | 목포 |
43 | 순천 |
50 | 전주 |
60 | 대전 |
70 | 청주 |
80 | 춘천 |
81 | 강릉 |
82 | 원주 |
90 | 제주 |
API 호출 결과값 해석
위의 이미지는 본사 1라디오의 CDN 스트림에 대한 정보를 가져오기 위한 목적으로, 아래의 URI를 사용하여 API 호출을 했을 시 나오는 결과값을 나타낸 것이다.
https://cfpwwwapi.kbs.co.kr/api/v1/landing/live/channel_code/21
결과값은 기본적으로 JSON 포맷으로 구성되어 있는데, 여기에서 필요로 하는 정보는 channel.item의 첫 번째 항목에 대한 service_url이 되시겠다. (channel.item[0].service_url)
아래는 이렇게 얻어낸 실제 스트림 URL를 나타낸 것인데, 참고로 이러한 URL은 만료 시점이 정해져 있기 때문에 일정한 시간이 지나면 작동하지 않는다.
https://1radio.gscdn.kbs.co.kr/1radio_192_4.m3u8?Expires=1690031068&Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiaHR0cHM6Ly8xcmFkaW8uZ3NjZG4ua2JzLmNvLmtyLzFyYWRpb18xOTJfNC5tM3U4IiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjkwMDMxMDY4fX19XX0_&Signature=Cmaevpd5MLzWwvZIYASF~bMG9BmcHWtLCAHUrQNbG1Q2gzTdFwCouQydDt36wLE1WLe1LGrgLTrcqHtX0Ha~XkmbaCIiiAtXSNY0YvVER0CqYpKG22U76syskah1JNq0jw0CfOKf5164MmaL0A2Cf626SjwhjUPwhiKpigGhU5TS5jLNBMo47RwPnOmK7M1VtYguMV0qoDmuCOSGkxesucROrplr2x509AueSZ3wobTB-MJcjZYjpxlU8qhjKJ0HESnuj15xj~3ustw-nekr6F-3y56L1ZiRo4MQIfJM-J5btIUtrVw~78BfM1yubBxuqPkYohIprF3l9RLuAXOI3A__&Key-Pair-Id=APKAICDSGT3Y7IXGJ3TA
첨언하여, 본사 1라디오의 스트림 URL 형식을 아주 쉽게 일반화하면 아래와 같다.
https://1radio.gscdn.kbs.co.kr/1radio_192_4.m3u8?[토큰 키]
결과적으로 이러한 구조를 이용해, 실행 시 본사 1라디오의 스트림 URL을 받아와 result 변수에 저장하는 JS 코드를 구성할 수 있다.
const promise = await fetch("https://cfpwwwapi.kbs.co.kr/api/v1/landing/live/channel_code/21");
const json = await promise.json();
const result = json.channel_item[0].service_url;
MBC (본사)
API 호출을 위한 URI
표준FM의 스트림을 얻고자 하는 경우 다음과 같은 형식을 사용한다.
https://sminiplay.imbc.com/aacplay.ashx?agent=webapp&channel=sfm
FM4U의 스트림을 얻고자 하는 경우 다음과 같은 형식을 사용한다.
https://sminiplay.imbc.com/aacplay.ashx?agent=webapp&channel=mfm
API 호출 결과값 해석
KBS에 비해 매우 간단하다. 그저 다른 아무 것도 없이 스트리밍 URL만 HTML 상에 떡하니 박혀있을 뿐이다. 물론 이곳의 URL도 만료 시점이 정해져 있기 때문에 일정한 시간이 지나면 작동하지 않는다.
실제 결과값 예시:
https://minimw.imbc.com/dmfm/_definst_/mfm.stream/playlist.m3u8?_lsu_sa_=6341E91293D332246544A5DE3511D44F45AE31256609F234382021a0D6D33D06D7aB238E37926A4390E03091E5b2F1D5AD5836277FE08BCC4899A7C147A3B6AA10DDDF09E5AB11A914CA5CAF5C8F227CEC1F4695DF8FED3D9952E11458973669A85B7FC28743E35A0D915D8A8EDCE15D
이렇게 얻어낼 수 있는 스트리밍 URL의 형식을 일반화해 보면 다음과 같다.
표준FM의 스트리밍 URL 형식:
https://minisw.imbc.com/dsfm/_definst_/sfm.stream/playlist.m3u8?[토큰 키]
FM4U의 스트림 URL 형식:
https://minimw.imbc.com/dmfm/_definst_/mfm.stream/playlist.m3u8?[토큰 키]
스트리밍 URL을 얻어오는 JS 코드도 간단하게 구성할 수 있다. JSON 객체로 가져와서 이것저것 뜯어볼 필요도 없이 그냥 TEXT(HTML) 한 번만 딱 가져오면 끝난다.
아래의 코드는 MBC FM4U의 스트리밍 URL을 받아와 result 변수에 저장한다.
const promise = await fetch("https://sminiplay.imbc.com/aacplay.ashx?agent=webapp&channel=mfm");
const result = await promise.text();
SBS (본사)
API 호출을 위한 URI
러브FM의 스트림을 얻고자 하는 경우 다음과 같은 형식을 사용한다.
https://apis.sbs.co.kr/play-api/1.0/livestream/lovepc/lovefm?protocol=hls&ssl=Y
파워FM의 스트림을 얻고자 하는 경우 다음과 같은 형식을 사용한다.
https://apis.sbs.co.kr/play-api/1.0/livestream/powerpc/powerfm?protocol=hls&ssl=Y
API 호출 결과값 해석
SBS도 MBC와 마찬가지로 군더더기 없이 스트리밍 URL 딱 하나만 준다. 이런 케이스가 매우 고맙다. 당연히 이곳의 URL도 만료 시점이 정해져 있기 때문에 일정한 시간이 지나면 작동하지 않는다.
실제 결과값 예시:
https://radiolive.sbs.co.kr/powerpc/powerfm.stream/playlist.m3u8?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2ODk5MDM1NTUsInBhdGgiOiIvcG93ZXJmbS5zdHJlYW0iLCJkdXJhdGlvbiI6LTEsInVubyI6Ijg2Yzk2YTIyLWY4NjctNDA0YS05NzlmLWQ1NjJlY2Y4OTU2NCIsImlhdCI6MTY4OTg2MDM1NX0.pRRoKiM9AONFJAsz_mcWOByGXkkJ16baiAz9cdPmkxI
이렇게 얻어낼 수 있는 스트리밍 URL의 형식을 일반화해 보면 다음과 같다.
러브FM의 스트리밍 URL 형식:
https://radiolive.sbs.co.kr/lovepc/lovefm.stream/playlist.m3u8?[토큰 키]
파워FM의 스트리밍 URL 형식:
https://radiolive.sbs.co.kr/powerpc/powerfm.stream/playlist.m3u8?[토큰 키]
MBC와 구조가 동일하기 때문에 JS 코드에 대한 별다른 설명은 생략한다. 아래의 코드는 SBS 파워FM의 스트리밍 URL을 받아와 result 변수에 저장한다.
const promise = await fetch("https://apis.sbs.co.kr/play-api/1.0/livestream/powerpc/powerfm?protocol=hls&ssl=Y");
const result = await promise.text();
다른 방송국
위에서 열거한 방송국을 제외한 대다수 방송국(MBC, SBS의 지역국 포함)은 별도의 CDN과 API를 갖추지 않고 자체 서버에서 영구적인 URL로 직접 스트림을 송출하는 경우가 일반적이다.
그렇기 때문에 대충 웹브라우저 개발자 도구의 '네트워크' 탭에서 'playlist.m3u8'이라는 이름으로 검출되는 항목만 잘 봐도 스트리밍 URL을 쉽게 얻을 수 있다.
결과물
이렇게 정리한 아이디어를 바탕으로 Cloudflare Workers를 이용해 그럴듯한 서버리스 앱을 하나 짜고,
변수 및 객체 정의
- url: Request URL 전체를 담는 오브젝트
- params: Request URL의 파라미터 부분('?' 이후)에 있는 키-값 쌍을 저장하는 오브젝트
- title: 이어지는 코드가 케이스가 맞게 실행되며 도출될 채널 이름을 저장하는 변수
- result: 이어지는 코드가 케이스에 맞게 실행되며 도출될 스트림 URL을 저장하는 변수
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const params = url.searchParams;
let title;
let result;
if (params.get('stn') == 'kbs') {
...
}
}
'KBS 라디오'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=kbs'일 경우에 작동한다. 선행 조건이 맞는다면 'city'와 'ch'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'kbs') {
let code;
switch(params.get('city')) {
case 'busan':
code = '10_';
title = 'KBS부산 ';
break;
case 'changwon':
code = '20_';
title = 'KBS창원 ';
break;
case 'jinju':
code = '21_';
title = 'KBS진주 ';
break;
case 'daegu':
code = '30_';
title = 'KBS대구 ';
break;
case 'andong':
code = '31_';
title = 'KBS안동 ';
break;
case 'pohang':
code = '32_';
title = 'KBS포항 ';
break;
case 'gwangju':
code = '40_';
title = 'KBS광주 ';
break;
case 'mokpo':
code = '41_';
title = 'KBS목포 ';
break;
case 'suncheon':
code = '43_';
title = 'KBS순천 ';
break;
case 'jeonju':
code = '50_';
title = 'KBS전주 ';
break;
case 'daejeon':
code = '60_';
title = 'KBS대전 ';
break;
case 'cheongju':
code = '70_';
title = 'KBS청주 ';
break;
case 'chuncheon':
code = '80_';
title = 'KBS춘천 ';
break;
case 'gangneung':
code = '81_';
title = 'KBS강릉 ';
break;
case 'wonju':
code = '82_';
title = 'KBS원주 ';
break;
case 'jeju':
code = '90_';
title = 'KBS제주 ';
break;
default:
code = '';
title = 'KBS ';
break;
}
switch(params.get('ch')) {
case '1radio':
code += '21';
title += '1라디오';
break;
case '2radio':
code += '22';
title += '2라디오';
break;
case '3radio':
code += '23';
title += '3라디오';
break;
case '1fm':
code += '24';
if (title == 'KBS ') { title += '1FM'; }
else { title += '음악FM'; }
break;
case '2fm':
code += '25';
title += '2FM';
break;
case 'hanminjok':
code += '26';
title += '한민족방송';
break;
}
const promise = await fetch("https://cfpwwwapi.kbs.co.kr/api/v1/landing/live/channel_code/" + code);
try {
const json = await promise.json();
result = json.channel_item[0].service_url;
} catch (e) {
result = undefined;
}
}
'MBC 라디오'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=mbc'일 경우에 작동한다. 선행 조건이 맞는다면 'ch'와 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'mbc') {
switch(params.get('ch')) {
case 'sfm': // MBC 표준FM
switch(params.get('city')) {
case 'busan':
result = "https://stream.bsmbc.com/live/mp4:BusanMBC-LiveStream-AM/playlist.m3u8";
title = '부산MBC';
break;
case 'ulsan':
result = "https://5ddfd163bd00d.streamlock.net/STDFM/STDFM/playlist.m3u8";
title = '울산MBC';
break;
case 'changwon':
result = "https://624a79c87201d.streamlock.net/MBCFM/TV2.stream/playlist.m3u8";
title = 'MBC경남';
break;
case 'daegu':
result = "https://5ee1ec6f32118.streamlock.net/amradio/am/playlist.m3u8";
title = '대구MBC';
break;
case 'andong':
result = "https://live.andongmbc.co.kr/live/amlive/playlist.m3u8";
title = '안동MBC';
break;
case 'pohang':
result = "http://stream.yubinet.com:1935/live/_definst_/Radio_Am/playlist.m3u8";
title = '포항MBC';
break;
case 'gwangju':
result = "https://media.kjmbc.co.kr/hls/amlive/GWANGJU-MBC-AM/playlist.m3u8";
title = '광주MBC';
break;
case 'mokpo':
result = "https://vod.mpmbc.co.kr/live/encoder-am/playlist.m3u8";
title = '목포MBC';
break;
case 'yeosu':
result = "https://5c3639aa99149.streamlock.net/표준FM/표준FM/playlist.m3u8";
title = '여수MBC';
break;
case 'jeonju':
result = "https://5ee9633b25727.streamlock.net/jmbc_sfm/myStream/playlist.m3u8";
title = '전주MBC';
break;
case 'daejeon':
result = "https://ns1.tjmbc.co.kr/live_am/live_am.stream/playlist.m3u8";
title = '대전MBC';
break;
case 'cheongju':
result = "https://mbccbp.coreit.co.kr/radio_stfm/myStream.sdp/playlist.m3u8";
title = 'MBC충북';
break;
case 'chuncheon':
result = "https://stream.chmbc.co.kr/live_radio/fm2/playlist.m3u8";
title = '춘천MBC';
break;
case 'wonju':
result = "mms://live.wjmbc.co.kr/fm2";
title = '원주MBC';
break;
case 'gangneung':
result = "http://123.254.72.24:1935/amlive/livestream/playlist.m3u8";
title = 'MBC강원영동';
break;
case 'jeju':
result = "https://wowza.jejumbc.com/live/_definst_/mp3:radio1/playlist.m3u8";
title = '제주MBC';
break;
default:
const promise = await fetch("https://sminiplay.imbc.com/aacplay.ashx?agent=webapp&channel=sfm");
const text = await promise.text();
result = text;
title = 'MBC';
break;
}
title += ' 표준FM';
break;
case 'fm4u': // MBC FM4U
switch(params.get('city')) {
case 'busan':
result = "https://stream.bsmbc.com/live/mp4:BusanMBC-LiveStream-FM/playlist.m3u8";
title = '부산MBC';
break;
case 'ulsan':
result = "https://5ddfd163bd00d.streamlock.net/FM4U/FM4U/playlist.m3u8";
title = '울산MBC';
break;
case 'changwon':
result = "https://624a79c87201d.streamlock.net/MBCFM4U/TV3.stream/playlist.m3u8";
title = 'MBC경남';
break;
case 'daegu':
result = "https://5ee1ec6f32118.streamlock.net/fmradio/fm/playlist.m3u8";
title = '대구MBC';
break;
case 'andong':
result = "https://live.andongmbc.co.kr/live/fmlive/playlist.m3u8";
title = '안동MBC';
break;
case 'pohang':
result = "http://stream.yubinet.com:1935/live/_definst_/Radio_Fm/playlist.m3u8";
title = '포항MBC';
break;
case 'gwangju':
result = "https://media.kjmbc.co.kr/hls/fmlive/GWANGJU-MBC-FM/playlist.m3u8";
title = '광주MBC';
break;
case 'mokpo':
result = "https://vod.mpmbc.co.kr/live/encoder-fm/playlist.m3u8";
title = '목포MBC';
break;
case 'yeosu':
result = "https://5c3639aa99149.streamlock.net/FM4U/FM4U/playlist.m3u8";
title = '여수MBC';
break;
case 'jeonju':
result = "https://5ee9633b25727.streamlock.net/jmbc_fm4u/myStream/playlist.m3u8";
title = '전주MBC';
break;
case 'daejeon':
result = "https://ns1.tjmbc.co.kr/live_fm/live_fm.stream/playlist.m3u8";
title = '대전MBC';
break;
case 'cheongju':
result = "https://mbccbp.coreit.co.kr/radio_fm/myStream.sdp/playlist.m3u8";
title = 'MBC충북';
break;
case 'chuncheon':
result = "https://stream.chmbc.co.kr/live_radio2/fm1/playlist.m3u8";
title = '춘천MBC';
break;
case 'wonju':
result = "mms://live.wjmbc.co.kr/fm989";
title = '원주MBC';
break;
case 'gangneung':
result = "http://123.254.72.24:1935/fmlive/livestream/playlist.m3u8";
title = 'MBC강원영동';
break;
case 'jeju':
result = "https://wowza.jejumbc.com/live/_definst_/mp3:radio2/playlist.m3u8";
title = '제주MBC';
break;
default:
const promise = await fetch("https://sminiplay.imbc.com/aacplay.ashx?agent=webapp&channel=mfm");
const text = await promise.text();
result = text;
title = 'MBC';
break;
}
title += ' FM4U';
break;
}
}
'SBS 라디오'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=sbs'일 경우에 작동한다.선행 조건이 맞는다면 'ch'와 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'sbs') {
let text;
switch(params.get('ch')) {
case 'lovefm': // SBS 러브FM
switch(params.get('city')) {
case 'busan':
result = "https://stream1.knn.co.kr/hls/b3y26uu6471k8tes9w7h_lfm/index.m3u8";
title = 'KNN 러브FM';
break;
default:
const promise = await fetch("https://apis.sbs.co.kr/play-api/1.0/livestream/lovepc/lovefm?protocol=hls&ssl=Y");
text = await promise.text();
result = text;
title = 'SBS 러브FM';
break;
}
break;
case 'powerfm': // SBS 파워FM
switch(params.get('city')) {
case 'busan':
result = "https://stream1.knn.co.kr/hls/lb9ezl87d37uu0vy65bb_pfm/index.m3u8";
title = 'KNN 파워FM';
break;
case 'ulsan':
result = "http://59.23.231.102:1935/live/mp3:UBCfmstream/playlist.m3u8";
title = 'UBC 그린FM';
break;
case 'daegu':
result = "http://203.251.91.122:1935/on-air-Backup/fm/playlist.m3u8";
title = 'TBC 드림FM';
break;
case 'gwangju':
result = "https://vod.ikbc.co.kr/KBCFM/kbcra_aac/playlist.m3u8";
title = 'KBC 마이FM';
break;
case 'jeonju':
result = "http://61.85.197.53:1935/jtv_radio/myStream/playlist.m3u8";
title = 'JTV 매직FM';
break;
case 'daejeon':
result = "http://1.245.74.5/radiolive/radio_64k/playlist.m3u8";
title = 'TJB 파워FM';
break;
case 'cheongju':
result = "https://wowza1.cjb.co.kr/live/cjbradio/playlist.m3u8";
title = 'CJB 조이FM';
break;
case 'chuncheon':
result = "http://61.82.49.4:1935/fm/_definst_/myStream/playlist.m3u8";
title = 'G1 프레쉬FM';
break;
case 'jeju':
result = "http://123.140.197.22/stream/2/play.m3u8";
title = 'JIBS 뉴파워FM';
break;
default:
const promise = await fetch("https://apis.sbs.co.kr/play-api/1.0/livestream/powerpc/powerfm?protocol=hls&ssl=Y");
text = await promise.text();
result = text;
title = 'SBS 파워FM';
break;
}
break;
}
}
'TBN 교통방송'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=tbn'일 경우에 작동한다.선행 조건이 맞는다면 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'tbn') {
title = 'TBN ';
switch(params.get('city')) {
case 'busan':
result = "http://radio2.tbn.or.kr:1935/busan/myStream/playlist.m3u8";
title += '부산교통방송';
break;
case 'ulsan':
result = "http://radio2.tbn.or.kr:1935/ulsan/myStream/playlist.m3u8";
title += '울산교통방송';
break;
case 'gyeongnam':
result = "http://radio2.tbn.or.kr:1935/gyeongnam/myStream/playlist.m3u8";
title += '경남교통방송';
break;
case 'daegu':
result = "http://radio2.tbn.or.kr:1935/daegu/myStream/playlist.m3u8";
title += '대구교통방송';
break;
case 'gyeongbuk':
result = "http://radio2.tbn.or.kr:1935/kyungbuk/myStream/playlist.m3u8";
title += '경북교통방송';
break;
case 'gwangju':
result = "http://radio2.tbn.or.kr:1935/gwangju/myStream/playlist.m3u8";
title += '광주교통방송';
break;
case 'jeonbuk':
result = "http://radio2.tbn.or.kr:1935/jeonbuk/myStream/playlist.m3u8";
title += '전북교통방송';
break;
case 'daejeon':
result = "http://radio2.tbn.or.kr:1935/daejeon/myStream/playlist.m3u8";
title += '대전교통방송';
break;
case 'chungbuk':
result = "http://radio2.tbn.or.kr:1935/chungbuk/myStream/playlist.m3u8";
title += '충북교통방송';
break;
case 'gangwon':
result = "http://radio2.tbn.or.kr:1935/gangwon/myStream/playlist.m3u8";
title += '강원교통방송';
break;
case 'jeju':
result = "http://radio2.tbn.or.kr:1935/jeju/myStream/playlist.m3u8";
title += '제주교통방송';
break;
default:
result = "http://radio2.tbn.or.kr:1935/gyeongin/myStream/playlist.m3u8";
title += '경인교통방송';
break;
}
}
'CBS 기독교방송'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=cbs'일 경우에 작동한다.선행 조건이 맞는다면 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'cbs') {
switch(params.get('ch')) {
case 'sfm': // CBS 표준FM
switch(params.get('city')) {
case 'busan':
result = "https://aac.cbs.co.kr/busan981/_definst_/busan981.stream/playlist.m3u8";
title = '부산';
break;
case 'ulsan':
result = "https://aac.cbs.co.kr/ulsan/_definst_/ulsan.stream/playlist.m3u8";
title = '울산';
break;
case 'gyeongnam':
result = "https://aac.cbs.co.kr/gyeongnam/_definst_/gyeongnam.stream/playlist.m3u8";
title = '경남';
break;
case 'daegu':
result = "https://aac.cbs.co.kr/daegu/_definst_/daegu.stream/playlist.m3u8";
title = '대구';
break;
case 'pohang':
result = "https://aac.cbs.co.kr/pohang/_definst_/pohang.stream/playlist.m3u8";
title = '포항';
break;
case 'gwangju':
result = "https://aac.cbs.co.kr/gwangju/_definst_/gwangju.stream/playlist.m3u8";
title = '광주';
break;
case 'jeonnam':
result = "https://aac.cbs.co.kr/jeonnam/_definst_/jeonnam.stream/playlist.m3u8";
title = '전남';
break;
case 'jeonbuk':
result = "https://aac.cbs.co.kr/jeonbuk/_definst_/jeonbuk.stream/playlist.m3u8";
title = '전북';
break;
case 'daejeon':
result = "https://aac.cbs.co.kr/daejeon/_definst_/daejeon.stream/playlist.m3u8";
title = '대전';
break;
case 'cheongju':
result = "https://aac.cbs.co.kr/cheongju/_definst_/cheongju.stream/playlist.m3u8";
title = '청주';
break;
case 'chuncheon':
result = "https://aac.cbs.co.kr/chuncheon/_definst_/chuncheon.stream/playlist.m3u8";
title = '춘천';
break;
case 'jeju':
result = "https://aac.cbs.co.kr/jeju/_definst_/jeju.stream/playlist.m3u8";
title = '제주';
break;
default:
result = "https://aac.cbs.co.kr/cbs981/_definst_/cbs981.stream/playlist.m3u8";
title = '';
break;
}
title += 'CBS 표준FM';
break;
case 'mfm': // CBS 음악FM
switch(params.get('city')) {
case 'busan':
result = "https://aac.cbs.co.kr/busan939/_definst_/busan939.stream/playlist.m3u8";
title = '부산';
break;
case 'daegu':
result = "https://aac.cbs.co.kr/daegu939/_definst_/daegu939.stream/playlist.m3u8";
title = '대구';
break;
default:
result = "https://aac.cbs.co.kr/cbs939/_definst_/cbs939.stream/playlist.m3u8";
title = '';
break;
}
title += 'CBS 음악FM';
break;
}
}
'FEBC 극동방송'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=febc'일 경우에 작동한다.선행 조건이 맞는다면 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'febc') {
switch(params.get('city')) {
case 'busan':
result = "http://mlive2.febc.net:1935/live/bsfebc/playlist.m3u8";
title = 'FEBC 부산극동방송';
break;
case 'ulsan':
result = "http://mlive2.febc.net:1935/live/uslive/playlist.m3u8";
title = 'FEBC 울산극동방송';
break;
case 'changwon':
result = "http://mlive2.febc.net:1935/live/cwlive/playlist.m3u8";
title = 'FEBC 창원극동방송';
break;
case 'daegu':
result = "http://220.73.173.216:1935/live/daegulive/playlist.m3u8";
title = 'FEBC 대구극동방송';
break;
case 'pohang':
result = "http://mlive2.febc.net:1935/live/phlive/playlist.m3u8";
title = 'FEBC 포항극동방송';
break;
case 'gwangju':
result = "http://mlive2.febc.net:1935/live/gjlive/playlist.m3u8";
title = 'FEBC 광주극동방송';
break;
case 'mokpo':
result = "http://mlive2.febc.net:1935/live/mplive/playlist.m3u8";
title = 'FEBC 목포극동방송';
break;
case 'jeonnam':
result = "http://mlive2.febc.net:1935/live/jndblive/playlist.m3u8";
title = 'FEBC 전남동부극동방송';
break;
case 'jeonbuk':
result = "http://mlive2.febc.net:1935/live/jblive/playlist.m3u8";
title = 'FEBC 전북극동방송';
break;
case 'daejeon':
result = "http://mlive2.febc.net:1935/live/djlive/playlist.m3u8";
title = 'FEBC 대전극동방송';
break;
case 'gangwon':
result = "http://mlive2.febc.net:1935/live/ydlive/playlist.m3u8";
title = 'FEBC 영동극동방송';
break;
case 'jeju':
result = "http://mlive2.febc.net:1935/live/jejufm/playlist.m3u8";
title = 'FEBC 제주극동방송FM';
break;
default:
result = "http://mlive2.febc.net:1935/live/seoulfm/playlist.m3u8";
title = 'FEBC 서울극동방송FM';
break;
}
}
'BBS 불교방송'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=bbs'일 경우에 작동한다.선행 조건이 맞는다면 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'bbs') {
switch(params.get('city')) {
case 'gwangju':
result = "http://live.cdn.smilecdn.com:1935/kjbbs1_live/live/playlist.m3u8";
title = 'BBS 광주불교방송';
break;
case 'daegu':
result = "https://bbslive.goldenday.kr:446/hls/dgbbs.m3u8";
title = 'BBS 대구불교방송';
break;
default:
result = "https://bbslive.clouducs.com/bbsradio-live/livestream/playlist.m3u8";
title = 'BBS 서울불교방송';
break;
}
}
'CPBC 가톨릭평화방송'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=cpbc'일 경우에 작동한다.선행 조건이 맞는다면 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'cpbc') {
switch(params.get('city')) {
case 'busan':
result = "http://pbcradio.dynamicsmart.com:1935/radio/bscpbc-radio/index.m3u8";
title = 'CPBC 부산가톨릭평화방송';
break;
case 'daegu':
result = "http://live.dgcpbc.co.kr/dgcpbclive/livestream/playlist.m3u8";
title = 'CPBC 대구가톨릭평화방송';
break;
case 'gwangju':
result = "http://pbcradio.dynamicsmart.com:1935/radio/kjpbc2/index.m3u8";
title = 'CPBC 광주가톨릭평화방송';
break;
default:
const promise = await fetch("https://apis.cpbc.co.kr/play-api/2.0/onair/channel/radio");
const json = await promise.json();
result = await json.onair.source.mediasource.mediaurl;
title = 'CPBC 가톨릭평화방송';
break;
}
}
'WBS 원음방송'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=wbs'일 경우에 작동한다.선행 조건이 맞는다면 'city'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'wbs') {
switch(params.get('city')) {
case 'busan':
result = "http://141.164.60.206:8000/wbs-b";
title = 'WBS 부산원음방송';
break;
case 'daegu':
result = "http://141.164.60.206:8000/wbs-d";
title = 'WBS 대구원음방송';
break;
case 'gwangju':
result = "http://141.164.60.206:8000/wbs-g";
title = 'WBS 광주원음방송';
break;
case 'jeonbuk':
result = "http://141.164.60.206:8000/wbs-j";
title = 'WBS 전북원음방송';
break;
default:
result = "http://45.76.71.4:8000/wbs-smok";
title = 'WBS 서울원음방송';
break;
}
}
'TBS 라디오'의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn=tbs'일 경우에 작동한다.선행 조건이 맞는다면 'ch'의 값에 따라서 올바른 유형의 스트림 URL을 가져와 result 변수에 저장한다.
if (params.get('stn') == 'tbs') {
switch(params.get('ch')) {
// TBS FM
case 'fm':
result = "https://cdnfm.tbs.seoul.kr/tbs/_definst_/tbs_fm_web_360.smil/playlist.m3u8";
title = 'TBS FM';
break;
// TBS eFM
case 'efm':
result = "https://cdnefm.tbs.seoul.kr/tbs/_definst_/tbs_efm_web_360.smil/playlist.m3u8";
title = 'TBS eFM';
break;
}
}
기타 채널의 스트림 URL 받아오기
Request URL의 파라미터 부분에서 'stn' 값이 어떤 지에 따라서만 작동한다. 그 이외에 필요한 다른 조건은 없다.
/*----- EBS 라디오 -----*/
if (params.get('stn') == 'ebs') {
result = "https://ebsonair.ebs.co.kr/fmradiofamilypc/familypc1m/playlist.m3u8";
title = 'EBS FM';
}
/*----- YTN 라디오 -----*/
if (params.get('stn') == 'ytn') {
result = "https://radiolive.ytn.co.kr/radio/_definst_/20211118_fmlive/playlist.m3u8";
title = 'YTN 라디오';
}
/*----- iFM 경인방송 -----*/
if (params.get('stn') == 'ifm') {
result = "http://180.131.1.27:1935/live/aod1/playlist.m3u8";
title = 'iFM 경인방송';
}
/*----- OBS 라디오 -----*/
if (params.get('stn') == 'obs') {
result = "https://vod3.obs.co.kr:444/live/obsstream1/radio.stream/playlist.m3u8";
title = 'OBS 라디오';
}
/*----- 국방FM -----*/
if (params.get('stn') == 'kookbang') {
result = "https://mediaworks.dema.mil.kr/live_edge/audio.sdp/playlist.m3u8";
title = '국방FM';
}
/*----- 국악방송 -----*/
if (params.get('stn') == 'kugak') {
result = "https://mgugaklive.nowcdn.co.kr/gugakradio/gugakradio.stream/playlist.m3u8";
title = '국악방송';
}
결과값 반환
선행 코드가 실행된 후 result 변수에 유효한 값이 정의되어 있는(undefined가 아닌) 상태에서만 작동한다.
- PLS 파일로 제공: Request URL의 pathname이 '/stream/playback.pls'인 경우 작동한다. result 변수에 담긴 값을 토대로 PLS Playlist 형식에 맞게 작성된 문자열을 'audio/x-scpls' 타입의 웹 페이지로 반환한다.
- 웹 플레이어: Request URL의 pathname이 '/stream/player.html'인 경우 작동한다. result 변수에 담긴 값을 토대로 HTML 형식에 맞게 작성된 (video.js 태그 포함) 웹페이지 코드의 문자열을 'text/html' 타입의 웹 페이지로 반환한다.
- M3U8 파일로 제공: Request URL의 pathname이 '/stream/'인 경우 작동한다. result 변수에 담긴 값(URL)으로 리다이렉트를 시전한다.
if (result != undefined) {
if (url.pathname == "/stream/playback.pls") { // pls 요청 시 동작
const pls = `[playlist]\nFile1=` + result + `\nTitle1=` + title + `\nLength1=-1`;
return new Response(pls, {headers: {'content-type': 'audio/x-scpls'}});
} else if (url.pathname == "/stream/player.html") {
const html = `
<head>
<title>` + title + `</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://vjs.zencdn.net/8.3.0/video-js.css" rel="stylesheet"/>
<script src="https://vjs.zencdn.net/8.3.0/video.min.js"></script>
</head>
<body>
<video-js style="width: 100%; height: 100%;" class="video-js vjs-default-skin" controls preload="auto" data-setup="{}">
<source src="` + result + `" type="application/x-mpegURL"/>
</video-js>
</body>`
return new Response(html, {headers: {'content-type': 'text/html'}});
} else if (url.pathname == "/stream/") {
return Response.redirect(result); // m3u8 요청 시 동작
}
}
선행 코드가 실행된 후에도 result 변수에 유효한 값이 정의되지 않았을 경우, 무조건 400 Error를 출력한다.
return new Response('Bad Request', {status: 400, headers: { 'content-type': 'text/plain'}}) // 400 Bad Request
여기에 간단한 index.html을 하나 덧붙여 완성한 것이 radio.bsod.kr 되시겠다.
이 웹페이지에서는 세 가지 유형의 스트림 청취 환경을 제공한다.
• M3U8: M3U8 형식의 Playlist 파일을 반환하는 원본 스트리밍 URL로 그대로 리다이렉트된다. 보통은 .m3u8로 끝나는 파일 한 개가 다운로드되지만, iOS Safari에서는 직접 재생이 가능하다.
• PLS: 원본 스트리밍 URL을 PLS Playlist 파일 형식에 맞게 가공하여 반환한다. PC용 VLC media player와 같은 곳에서는 m3u8 대신 pls가 상성이 좋은 편이어서 따로 추가했다.
[playlist]
File1=[스트리밍 URL]
Title1=[채널 이름]
Length1=-1
• WEB: 원본 스트리밍 URL을 소스로 둔 웹 플레이어(Video.js)가 있는 별도 페이지로 연결된다.
<head>
<title>[채널 이름]</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://vjs.zencdn.net/8.3.0/video-js.css" rel="stylesheet"/>
<script src="https://vjs.zencdn.net/8.3.0/video.min.js"></script>
</head>
<body>
<video-js style="width: 100%; height: 100%;" class="video-js vjs-default-skin" controls preload="auto" data-setup="{}">
<source src="[스트리밍 URL]" type="application/x-mpegURL"/>
</video-js>
</body>
'웹 페이지'의 소스는 현재 깃허브에도 올려 둔 상태다. 지금까지는 기본적인 기능 구현에 집중하느라 웹페이지의 디자인적인 면에는 신경 쓰지 못했었는데, 나중에 시간이 된다면 여기에 차차 깔끔한 디자인을 입혀 볼 계획이다.
(8/22 추가) 드디어 웹사이트 CSS 디자인까지 다듬으면서 초기 개발 작업을 마쳤다!
감사의 글
KBS 라디오의 API 호출 경로를 알아내는 데 결정적인 도움을 주었을 뿐만 아니라, Workers 코드를 간소화할 수 있는 여러 아이디어를 제공하여 최초 코딩 후 첨삭 과정에서도 도움을 준 '미확인샐러드' 님에게 감사를 표한다.
'방송덕후' 카테고리의 다른 글
지상파TV 전 채널 방송개시·종료 시퀀스 모음 (2024년 버전) (0) | 2024.02.13 |
---|---|
공습대비 민방위 훈련 시의 훈련용 경보 전파체계 탐구 (0) | 2023.08.23 |
OBS 라디오(FM 99.9MHz) 개국 관련 녹음 기록들 (0) | 2023.03.30 |
KBS 1TV의 지진속보 시스템 탐구 (0) | 2023.01.09 |
2023년 새해맞이 방송 녹화 결산 (0) | 2023.01.01 |
댓글