개발 예제 : Podcast RSS Maker 플러그인

개발 예제 : Podcast RSS Maker 플러그인

M 소주6잔 8 487 1 0

개발 예제 : Podcast RSS Maker 플러그인


개발 문서를 위해 아주 간단한 플러그인 하나 만들었습니다.


_WbsyAV255yHdyA4eaWNq8QDND-P0FC33MTA1bDmFKibaDlE9PMWBYI3c5qlEEpWVFFhQOqdxXlC8W_Pa6mBOmlhT8oWi3AV8ByJonsm7U-D0sAdjPbqkwpEVR5jM7aQ4HAYyMuV


이 플러그인은 팟빵 채널ID 로 요청하면 해당 채널 정보를 가져와서 팟캐스트 RSS 를 만들어서 리턴하는 기능만 있습니다.

이 기능을 이걸 어떻게 구현하는지는 논외고 이 걸 어떻게 SJVA 에 붙였는지에 대한 것만 코드레벨로 설명드리겠습니다.

노란색 글씨가 부연 설명입니다.


  1. __init__.py


# plugin.py 에서 framework 에서 호출하는 요소 들을 import 합니다.
from plugin import blueprint, menu, plugin_load, plugin_unload, plugin_info
  


  1. plugin.py


 

# -*- coding: utf-8 -*-

#########################################################

# python

import os

import traceback

 

# third-party

from flask import Blueprint, request, render_template, redirect, jsonify 

from flask_login import login_required

 

# sjva 공용

from framework.logger import get_logger

from framework import app, db, scheduler, path_data, socketio, check_api

from system.model import ModelSetting as SystemModelSetting

 

# 패키지

package_name = __name__.split('.')[0]

logger = get_logger(package_name)

from .logic import Logic

from .model import ModelSetting

#########################################################

 

## Framework 에서 사용하는 것들

blueprint = Blueprint(package_name, package_name, url_prefix='/%s' %  package_name, template_folder=os.path.join(os.path.dirname(__file__), 'templates'))

## blueprint는 고정입니다. 변경하지 마세요

 

## SJVA 메뉴에서 어떻게 나타날것인가에 대한 dict입니다.

menu = {

    'main' : [package_name, 'Podcast RSS Maker'],

    'sub' : [

        ['setting', '설정'], ['log', '로그']

    ],

    'category' : 'service'

}

 

## 설정 - 플러그인 에서 보여줄 정보를 포함하고 있습니다.

plugin_info = {

    'version' : '0.1.0.0',

    'name' : 'Podcast RSS Maker',

    'category_name' : 'service',

    'developer' : 'soju6jan',

    'description' : 'Podcast 지원',

    'home' : 'https://github.com/soju6jan/podcast_feed_maker',

    'more' : '',

}

 

## 보통 Logic 에서 일처리를 합니다.

def plugin_load():

    Logic.plugin_load()

 

def plugin_unload():

    Logic.plugin_unload()

 

#########################################################

# WEB Menu   

#########################################################

## /package_name 으로만 접속할 때 어느 sub 화면으로 보낼지 설정합니다. 

@blueprint.route('/')

def home():

    return redirect('/%s/setting' % package_name)

 

## 메뉴가 클릭되었을때 어떤 정보를 가공하여 어떤 html 페이지를 보여줄지 선택합니다.

@blueprint.route('/<sub>')

@login_required

def first_menu(sub): 

    ## setting 메뉴가 호출되면

    if sub == 'setting':

        ## 화면에 보여줄 모든 정보를 arg 에 넣습니다.

        arg = ModelSetting.to_dict()

        arg['package_name']  = package_name

        arg['tmp_pb_api'] = '%s/%s/api/podbbang/%s' % (SystemModelSetting.get('ddns'), package_name, '12548')

        if SystemModelSetting.get_bool('auth_use_apikey'):

            arg['tmp_pb_api'] += '?apikey=%s' % SystemModelSetting.get('auth_apikey')

        ## 타 플러그인와 겹치면 안되기 때문에서 패키지명_메뉴명.html 을 사용해야합니다. 

        return render_template('{package_name}_{sub}.html'.format(package_name=package_name, sub=sub), arg=arg)

    ## 아래 로그와 샘플을 그대로 사용

    elif sub == 'log':

        return render_template('log.html', package=package_name)

    return render_template('sample.html', title='%s - %s' % (package_name, sub))

 

#########################################################

# For UI                                                          

#########################################################

## 웹에서 사용자가 무언가를 요청하였을 경우 이 곳을 통해 받아서 처리하고 그 결과를 리턴합니다.

## 보통 jsonify 를 통해 json 형태로 리턴합니다.

@blueprint.route('/ajax/<sub>', methods=['GET', 'POST'])

## 이 데코레이터 필수

@login_required

def ajax(sub):

    try:

        ## 사용자가 설정 저장버튼을 눌렀을 경우 

        if sub == 'setting_save':

            ret = ModelSetting.setting_save(request)

            return jsonify(ret)

    except Exception as e: 

        logger.error('Exception:%s', e)

        logger.error(traceback.format_exc())  

 

#########################################################

# API - 외부

#########################################################

## 필요한 경우 api 기능을 넣습니다. route 형식은 필요하따라 변경합니다.

@blueprint.route('/api/<sub>/<sub2>', methods=['GET', 'POST'])

## check_api 데코레이터 필수

@check_api

def api(sub, sub2):

    try:

        if sub == 'podbbang':

            ## 이 플러그인만의 기능은 logic_normal 구현되어 있고 이를 호출합니다.
            from .logic_normal import LogicNormal

            return LogicNormal.make_podbbang(sub2)

    except Exception as e:

        logger.debug('Exception:%s', e)

        logger.debug(traceback.format_exc())

 


  1. logic.py


# -*- coding: utf-8 -*-

#########################################################

# python

import os

import traceback

import time

import threading

 

# third-party

 

# sjva 공용

from framework import db, scheduler, path_app_root

from framework.job import Job

from framework.util import Util

 

# 패키지

from .plugin import logger, package_name

from .model import ModelSetting

#########################################################

 

 

class Logic(object):

 

    ## 보통 설정값 항목과 초기값을 넣어줍니다.

    db_default = { 

        'db_version' : '1',

        'pb_feed_count' : '30'

    }

 

    @staticmethod

    def db_init():

        try:

            for key, value in Logic.db_default.items():

                if db.session.query(ModelSetting).filter_by(key=key).count() == 0:

                    db.session.add(ModelSetting(key, value))

            db.session.commit()

            

            #Logic.migration()

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

 

    @staticmethod

    def plugin_load():

        try:

            ## 로딩시 호출됩니다. db초기화를 하고 자동실행 기능이 포함되면 이 곳에서 scheduler_start 호출

            logger.debug('%s plugin_load', package_name)

            Logic.db_init()

            #if ModelSetting.query.filter_by(key='auto_start').first().value == 'True':

            #    Logic.scheduler_start()

            # 편의를 위해 json 파일 생성

            from plugin import plugin_info

            Util.save_from_dict_to_json(plugin_info, os.path.join(os.path.dirname(__file__), 'info.json'))

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

 

    ## 플러그인 종료시 실행됩니다. 

    @staticmethod

    def plugin_unload():

        try:

            logger.debug('%s plugin_unload', package_name)

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

 

    ## 아래는 이 플러그인에서 사용되지 않아서 주석처리 되어 있습니다. 간단히 의미만 설명합니다.
    ## 플러그인 로딩시 자동실행이 있으면 이 함수를 호출합니다. 작업주기와 작업 함수를 스케쥴러에 등록합니다.

    def scheduler_start():

        try:

            interval = ModelSetting.query.filter_by(key='interval').first().value

            job = Job(package_name, package_name, interval, Logic.scheduler_function, u"%s 설명" % package_name, False)

            scheduler.add_job_instance(job)

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

 

    ## 작업중지

    @staticmethod

    def scheduler_stop():

        try:

            scheduler.remove_job(package_name)

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

 

    ## 스케쥴러에서 호출할 함수

    @staticmethod

    def scheduler_function():

        #LogicNormal.scheduler_function()

 

    ## DB 모두 삭제

    @staticmethod

    def reset_db():

        try:

            db.session.query(ModelItem).delete()

            db.session.commit()

            return True

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

            return False

 

    ## 1회 실행

    @staticmethod

    def one_execute():

        try:

            if scheduler.is_include(package_name):

                if scheduler.is_running(package_name):

                    ret = 'is_running'

                else:

                    scheduler.execute_job(package_name)

                    ret = 'scheduler'

            else:

                def func():

                    time.sleep(2)

                    Logic.scheduler_function()

                threading.Thread(target=func, args=()).start()

                ret = 'thread'

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

            ret = 'fail'

        return ret

 

    ## 텔레그램 봇 데이터 처리

    @staticmethod

    def process_telegram_data(data):

        try:

            logger.debug(data)

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

    ## DB 마이그레이션

    @staticmethod

    def migration():

        try:

            pass

        except Exception as e:

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

 

    """

 


 

  1. model.py


일반적으로 ModelSetting 은 코드 그대로 사용하고, 추가할 테이블이 있을 경우 새로 클래스를 추가합니다.


# -*- coding: utf-8 -*-

#########################################################

# python

import traceback

from datetime import datetime

import json

import os

 

# third-party

from sqlalchemy import or_, and_, func, not_, desc

from sqlalchemy.orm import backref

 

# sjva 공용

from framework import app, db, path_app_root

from framework.util import Util

 

# 패키지

from .plugin import logger, package_name

 

app.config['SQLALCHEMY_BINDS'][package_name] = 'sqlite:///%s' % (os.path.join(path_app_root, 'data', 'db', '%s.db' % package_name))

#########################################################

        

class ModelSetting(db.Model):

    __tablename__ = '%s_setting' % package_name

    __table_args__ = {'mysql_collate': 'utf8_general_ci'}

    __bind_key__ = package_name

 

    id = db.Column(db.Integer, primary_key=True)

    key = db.Column(db.String(100), unique=True, nullable=False)

    value = db.Column(db.String, nullable=False)

 

    def __init__(self, key, value):

        self.key = key

        self.value = value

 

    def __repr__(self):

        return repr(self.as_dict())

 

    def as_dict(self):

        return {x.name: getattr(self, x.name) for x in self.__table__.columns}

 

    @staticmethod

    def get(key):

        try:

            return db.session.query(ModelSetting).filter_by(key=key).first().value.strip()

        except Exception as e:

            logger.error('Exception:%s %s', e, key)

            logger.error(traceback.format_exc())

            

    

    @staticmethod

    def get_int(key):

        try:

            return int(ModelSetting.get(key))

        except Exception as e:

            logger.error('Exception:%s %s', e, key)

            logger.error(traceback.format_exc())

    

    @staticmethod

    def get_bool(key):

        try:

            return (ModelSetting.get(key) == 'True')

        except Exception as e:

            logger.error('Exception:%s %s', e, key)

            logger.error(traceback.format_exc())

 

    @staticmethod

    def set(key, value):

        try:

            item = db.session.query(ModelSetting).filter_by(key=key).with_for_update().first()

            if item is not None:

                item.value = value.strip()

                db.session.commit()

            else:

                db.session.add(ModelSetting(key, value.strip()))

        except Exception as e:

            logger.error('Exception:%s %s', e, key)

            logger.error(traceback.format_exc())

 

    @staticmethod

    def to_dict():

        try:

            from framework.util import Util

            return Util.db_list_to_dict(db.session.query(ModelSetting).all())

        except Exception as e:

            logger.error('Exception:%s %s', e, key)

            logger.error(traceback.format_exc())

 

 

    @staticmethod

    def setting_save(req):

        try:

            for key, value in req.form.items():

                if key in ['scheduler', 'is_running']:

                    continue

                if key.startswith('tmp_'):

                    continue

                logger.debug('Key:%s Value:%s', key, value)

                entity = db.session.query(ModelSetting).filter_by(key=key).with_for_update().first()

                entity.value = value

            db.session.commit()

            return True                  

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

            logger.debug('Error Key:%s Value:%s', key, value)

            return False

 

    @staticmethod

    def get_list(key):

        try:

            value = ModelSetting.get(key)

            values = [x.strip().strip() for x in value.replace('\n', '|').split('|')]

            values = Util.get_list_except_empty(values)

            return values

        except Exception as e: 

            logger.error('Exception:%s', e)

            logger.error(traceback.format_exc())

            logger.error('Error Key:%s Value:%s', key, value)

 

 




 

  1. setting.html


{% extends "base.html" %}

{% block content %}

<div>

  {{ macros.m_button_group([['global_setting_save_btn', '설정 저장']])}}

  {{ macros.m_row_start('5') }}

  {{ macros.m_row_end() }}

  <nav>

    {{ macros.m_tab_head_start() }}

      {{ macros.m_tab_head2('podbbang', '팟빵', true) }}

    {{ macros.m_tab_head_end() }}

  </nav>

  <form id='setting' name='setting'>

  <div class="tab-content" id="nav-tabContent">

    {{ macros.m_tab_content_start('podbbang', true) }}

      {{ macros.setting_input_int('pb_feed_count', '피드 수', min='30', value=arg['pb_feed_count'], desc=['최소 : 30']) }}

      {{ macros.m_hr() }}

      {{ macros.info_text_go('tmp_pb_api', 'API', value=arg['tmp_pb_api'], desc=['', '예) 뉴스공장. 채널ID : 12548', '채널ID 부분만 변경하여 사용']) }}

      {{ macros.setting_button([['go_btn', 'Go 팟빵']]) }}

    {{ macros.m_tab_content_end() }}

  </div><!--tab-content-->

  </form>

</div> <!--전체-->

 

<script type="text/javascript">

var package_name = "{{arg['package_name'] }}";

 

$(document).ready(function(){

});

 

$("body").on('click', '#go_btn', function(e){

  e.preventDefault();

  window.open("http://www.podbbang.com/", "_blank");

});

</script>    

{% endblock %}


html 은 다른섹션에서 설명


 

참고로 실제 팟빵데이터를 RSS로 만드는 기능 관련 코드는 이 정도 밖에 되지 않습니다.


class LogicNormal(object):

    @staticmethod

    def make_podbbang(channel_id):

        try:

            url = 'http://www.podbbang.com/ch/%s' % channel_id

            tree = html.fromstring(requests.get(url).content)

            tmp = builder.ElementMaker(nsmap={'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'})

            root = tmp.rss(version="2.0")

            EE = builder.ElementMaker(namespace="http://www.itunes.com/dtds/podcast-1.0.dtd", nsmap={'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'})

            channel_tag = (

                E.channel(

                    E.title(tree.xpath('//*[@id="all_title"]/dt/div/p/text()')[0].strip()),

                    E.link(url),

                    E.description(tree.xpath('//*[@id="podcast_summary"]/text()')[0].strip()),

                    E.language('ko-kr'),

                    E.copyright(''),

                    EE.subtitle(),

                    EE.author(),

                    EE.summary(tree.xpath('//*[@id="podcast_summary"]/text()')[0].strip()),

                    EE.category(text=tree.xpath('//*[@id="header_wrap"]/div/div[1]/div[2]/span/a/text()')[0].strip()),

                    EE.image(href=tree.xpath('//*[@id="podcast_thumb"]/img')[0].attrib['src']),

                    EE.explicit('no'),

                    EE.keywords(tree.xpath('//*[@id="header_wrap"]/div/div[1]/div[2]/span/a/text()')[0].strip()),

                )

            )

            root.append(channel_tag)

            data = requests.get('http://app-api4.podbbang.com/channel?channel=%s&order=desc&count=%s&page=1' % (channel_id, ModelSetting.get('pb_feed_count')), headers=pb_headers).json()

            for item in data['list']:

                channel_tag.append(E.item (

                    E.title(item['title']),

                    EE.subtitle(item['summary']),

                    EE.summary(item['summary']),

                    E.guid(item['episode']),

                    E.pubDate(datetime.strptime(item['date'], "%Y-%m-%d %H:%M:%S").strftime('%a, %d %b %Y %H:%M:%S') + ' +0900'),

                    EE.duration(item['duration']),

                    E.enclosure(url=item['file_url'], length=item['file_size'], type='audio/mp3'),

                    E.description(item['summary'])

                ))

            return app.response_class(ET.tostring(root, pretty_print=True, xml_declaration=True, encoding="utf-8"), mimetype='application/xml')

        except Exception as e: 

                logger.error('Exception:%s', e)

                logger.error(traceback.format_exc())


 

     

 

8 Comments
7 arkx 03.12 17:32  
와 이거 설치해야겠습니다.
즐겨듣는 팟캐스트가 있었는데.. 해놔야겠네요.
게다가 좋은 설명까지 감사합니다.
9 이치로 03.12 19:45  
이게 json으로 튀어나왔군요. 구현보다 주소따는게 더 어려울 것 같네요 ㅎㅎ

전 그냥 xml 바로 릴레이 해주는 식으로 쓰는데 다른용도로쓰면 민형사상 고소한다고 경고문이 찍혀 있어서 혼자쓰기는 합니다.

base_url = 'http://ch.podbbang.com/xml/ch/'
base_ref = 'http://www.podbbang.com/ch/'
res = requests.get(base_url + chnum, headers={'User-Agent': ua, 'Referer': base_ref + chnum})
res = res.text.replace("/x150/", "/x500/")      # 앨범 커버이미지 500px
resp = Response(res.encode('iso-8859-1'))
resp.headers['Content-Type'] = 'application/rss+xml; charset=utf-8'
return resp
M 소주6잔 03.13 01:09  
어 이건 먼가요?
주소는 아주 예전에 plex 팟캐스트 플러그인 만들때 찾았을텐데 어떻게 했는지 기억이 안납니다 ㅋ
9 오리알 03.12 21:40  
감사합니다. 이걸 기초로 도움이 될만한걸 만들어봐야겠네요
8 에레이 03.13 03:05  
알찬 설명 감사합니다.
플러그인 개발에 많은 도움이 되고 있습니다 ^^
7 기뚜리 03.16 10:10  
와....소름....부연설명까지 해주실줄이야...
8 유리선율 03.16 10:41  
이제부터는 SJVA 멀티미디어 플랫폼이라고 호칭해야 할듯!! 대단 합니다. ^^
3 브루스홍 03.18 11:56  
감사합니다.
Category
State
  • 현재 접속자 18(9) 명
  • 오늘 방문자 742 명
  • 어제 방문자 995 명
  • 최대 방문자 1,331 명
  • 전체 방문자 85,444 명
  • 전체 게시물 46,512 개
  • 전체 댓글수 5,954 개
  • 전체 회원수 2,798 명
Facebook Twitter GooglePlus KakaoStory NaverBand