diff --git a/README.md b/README.md new file mode 100644 index 0000000..b292089 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +## billboard + +Periodically pulls images and text from Reddit and displays them full-screen. +Inspired by + +http://imgur.com/a/Io3xi + + +### Note + +This requires PyQt4 to be installed on the system. It seems it currently cannot +be deployed with pip, so it's not mentioned in the dependencies. + + +### TODO + +- Currently a buch of stuff is hard-coded that should be configurable (e.g. + subpreddits to pull images/text from) +- Add more sources (currenltly, only Reddit) +- An ability to remotely send images/text to the billboard for immediate display +- Support Python 2 +- Switch to PyQt5 +- ... + + +### LICENSE + +3-clause BSD + + diff --git a/billboard/__init__.py b/billboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billboard/app.py b/billboard/app.py new file mode 100644 index 0000000..2513718 --- /dev/null +++ b/billboard/app.py @@ -0,0 +1,37 @@ +import sys +import argparse + +from PyQt4.QtGui import QApplication + +from billboard.billboard import Billboard +from billboard.display import BillboardDisplay +from billboard.sources.reddit import RedditSource + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('-p', '--period', type=int, default=60*15, + help=''' + Period for switching billboard display in seconds. + Defaults to 15 minutes. + ''') + return parser.parse_args(sys.argv[1:]) + + +def main(): + args = parse_args() + app = QApplication(sys.argv) + + display = BillboardDisplay() + sources = [RedditSource()] + + billboard = Billboard(display, sources, args.period) + + billboard.start() + display.show() + app.exec_() + + +if __name__ == '__main__': + main() + diff --git a/billboard/billboard.py b/billboard/billboard.py new file mode 100644 index 0000000..90bcb41 --- /dev/null +++ b/billboard/billboard.py @@ -0,0 +1,24 @@ +import time +from threading import Thread +from itertools import cycle + + +class Billboard(Thread): + + def __init__(self, display, sources, period): + super(Billboard, self).__init__() + self.display = display + self.sources = sources + self.period = period + self.daemon = True + + def run(self): + for source in cycle(self.sources): + image, text = source.next() + if image is None and text is None: + continue + if image is not None: + self.display.update_image(image) + if text is not None: + self.display.display_text(text) + time.sleep(self.period) diff --git a/billboard/display.py b/billboard/display.py new file mode 100644 index 0000000..e7fa028 --- /dev/null +++ b/billboard/display.py @@ -0,0 +1,59 @@ +import os + +from PyQt4.QtCore import Qt, QObject, SIGNAL +from PyQt4.QtGui import (QMainWindow, QWidget, QPixmap, QLabel, + QGraphicsDropShadowEffect, QColor, + QDesktopWidget) + + +class BillboardDisplay(QMainWindow): + + def __init__(self, parent=None, fontsize=42): + super(BillboardDisplay, self).__init__(parent) + desktop = QDesktopWidget() + self.display = QWidget(self) + size = desktop.availableGeometry(desktop.primaryScreen()); + self.display.resize(size.width(), size.height()) + self.display.setWindowTitle("Billboard") + + self.image_label = QLabel(self.display) + self.image_label.resize(size.width(), size.height()) + + self.text_label = QLabel(self.display) + self.text_label.resize(size.width(), size.height()) + self.text_label.setMargin(100) + self.text_label.setStyleSheet(''' + QLabel {{ + font-size: {}pt; + color: #eeeeee; + text-align: center; + }} + '''.format(fontsize)) + self.text_label.setWordWrap(True) + self.text_label.setAlignment(Qt.AlignCenter) + + dse = QGraphicsDropShadowEffect() + dse.setBlurRadius(10) + dse.setXOffset(0) + dse.setYOffset(0) + dse.setColor(QColor(0, 0, 0, 255)) + self.text_label.setGraphicsEffect(dse) + QObject.connect(self, SIGNAL("updateimage"), + self.display_image) + + def update_image(self, imagepath): + self.emit(SIGNAL("updateimage"), imagepath) + + def display(self, imagepath, text): + self.display_text(text) + self.display_image(imagepath) + self.showFullScreen() + + def display_image(self, imagepath): + pix = QPixmap(imagepath) + self.image_label.setPixmap(pix.scaled(self.display.size(), + Qt.KeepAspectRatioByExpanding)) + + def display_text(self, text): + self.text_label.setText('"{}"'.format(text)) + diff --git a/billboard/sources/__init__.py b/billboard/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bad-words.txt b/billboard/sources/bad-words.txt similarity index 100% rename from bad-words.txt rename to billboard/sources/bad-words.txt diff --git a/proof_of_concept.py b/billboard/sources/reddit.py similarity index 54% rename from proof_of_concept.py rename to billboard/sources/reddit.py index 54fb9e6..4fe82ce 100644 --- a/proof_of_concept.py +++ b/billboard/sources/reddit.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 import os import sys import shutil @@ -7,18 +6,17 @@ import praw import requests -from PyQt4.QtCore import Qt -from PyQt4.QtGui import QApplication, QWidget, QPixmap, QLabel -from PyQt4.QtGui import QGraphicsDropShadowEffect, QColor +from billboard.utils import DroppingSet -class ImageGetter(): + +class ImageGetter: def __init__(self, reddit, subreddit='earthporn', aspect_ratio=1.6): self.reddit = reddit self.subreddit = subreddit self.aspect_ratio = aspect_ratio - self._seen = set() + self._seen = DroppingSet(50) def get_image(self, path): try: @@ -46,7 +44,7 @@ def _get_image_url(self, subs): return source['url'] -class TextGetter(): +class TextGetter: def __init__(self, reddit, subreddit='showerthoughts', badlist='bad-words.txt'): self.reddit = reddit @@ -56,7 +54,7 @@ def __init__(self, reddit, subreddit='showerthoughts', badlist='bad-words.txt'): self.bad_words = fh.read().splitlines() else: self.bad_words = [] - self._seen = set() + self._seen = DroppingSet(50) def get_text(self): try: @@ -75,60 +73,20 @@ def get_text(self): return None -def show_billboard(imagepath, text, fontsize=42): - app = QApplication(sys.argv) - w = QWidget() - w.resize(1600, 1000) - w.setWindowTitle("Billboard") - - pix = QPixmap(imagepath) - - image_label = QLabel(w) - image_label.resize(1600, 1000) - image_label.setPixmap(pix.scaled(w.size(), Qt.KeepAspectRatioByExpanding)) - - text_label = QLabel(w) - text_label.resize(1600, 1000) - text_label.setMargin(100) - text_label.setText('"{}"'.format(text)) - text_label.setStyleSheet(''' - QLabel {{ - font-size: {}pt; - color: #eeeeee; - text-align: center; - }} - '''.format(fontsize)) - text_label.setWordWrap(True) - text_label.setAlignment(Qt.AlignCenter) - - dse = QGraphicsDropShadowEffect() - dse.setBlurRadius(10) - dse.setXOffset(0) - dse.setYOffset(0) - dse.setColor(QColor(0, 0, 0, 255)) - text_label.setGraphicsEffect(dse) - - w.show() - app.exec_() - - -def main(): - reddit = praw.Reddit(user_agent='billboard') - imagegetter = ImageGetter(reddit) - textgetter = TextGetter(reddit) - - image_path = tempfile.mktemp() - if not imagegetter.get_image(image_path): - logging.error("Did not find a suitable image.") - sys.exit(1) - text = textgetter.get_text() - if not text: - logging.error("Did not find a suitable text.") - sys.exit(1) - - show_billboard(image_path, text) - os.unlink(image_path) +class RedditSource: + def __init__(self): + self.reddit = praw.Reddit(user_agent='billboard') + self.imagegetter = ImageGetter(self.reddit) + badlist = os.path.join(os.path.dirname(__file__), 'bad-words.txt') + self.textgetter = TextGetter(self.reddit, badlist=badlist) + self.image_path = tempfile.mktemp() + self.logger = logging.getLogger('reddit') -if __name__ == '__main__': - main() + def next(self): + if not self.imagegetter.get_image(self.image_path): + self.logger.error("Did not find a suitable image.") + text = self.textgetter.get_text() + if not text: + self.logger.error("Did not find a suitable text.") + return self.image_path, text diff --git a/billboard/utils.py b/billboard/utils.py new file mode 100644 index 0000000..0fd9ddb --- /dev/null +++ b/billboard/utils.py @@ -0,0 +1,81 @@ +from collections import MutableSet + +# based on OrderedSet recipe: +# https://code.activestate.com/recipes/576694/ +class DroppingSet(MutableSet): + """ + A set with a maximum size. It keeps track of the order in which elements + are added. Once max_size is reached, oldest elements are dropped. + + + """ + + @property + def max_size(self): + return self._max_size + + @max_size.setter + def max_size(self, value): + self._max_size = value + while len(self) > self._max_size: + self.pop(last=False) + + def __init__(self, max_size, iterable=None): + self._max_size = max_size + self.end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # key --> [key, prev, next] + if iterable is not None: + self |= iterable + + def __len__(self): + return len(self.map) + + def __contains__(self, key): + return key in self.map + + def add(self, key): + while len(self) >= self.max_size: + self.pop(last=False) + if key not in self.map: + end = self.end + curr = end[1] + curr[2] = end[1] = self.map[key] = [key, curr, end] + + def discard(self, key): + if key in self.map: + key, prev, next = self.map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): + end = self.end + curr = end[1] + while curr is not end: + yield curr[0] + curr = curr[1] + + def pop(self, last=True): + if not self: + raise KeyError('set is empty') + key = self.end[1][0] if last else self.end[2][0] + self.discard(key) + return key + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self)) + + def __eq__(self, other): + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return set(self) == set(other) + diff --git a/scripts/billboard b/scripts/billboard new file mode 100644 index 0000000..d471722 --- /dev/null +++ b/scripts/billboard @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from billboard.app import main +main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4a355a4 --- /dev/null +++ b/setup.py @@ -0,0 +1,61 @@ +import os +import sys +import warnings +from setuptools import setup + + +# happends if falling back to distutils +warnings.filterwarnings('ignore', "Unknown distribution option: 'install_requires'") +warnings.filterwarnings('ignore', "Unknown distribution option: 'extras_require'") + +try: + os.remove('MANIFEST') +except OSError: + pass + +packages = [] +data_files = {} +source_dir = os.path.dirname(__file__) +billboard_dir = os.path.join(source_dir, 'billboard') +for root, dirs, files in os.walk(billboard_dir): + rel_dir = os.path.relpath(root, source_dir) + data = [] + if '__init__.py' in files: + for f in files: + if os.path.splitext(f)[1] not in ['.py', '.pyc', '.pyo']: + data.append(f) + package_name = rel_dir.replace(os.sep, '.') + package_dir = root + packages.append(package_name) + data_files[package_name] = data + else: + # use previous package name + filepaths = [os.path.join(root, f) for f in files] + data_files[package_name].extend([os.path.relpath(f, package_dir) for f in filepaths]) + +scripts = [os.path.join('scripts', s) for s in os.listdir('scripts')] + +params = dict( + name='billboard', + description='Periodically changing text and backgrounds', + version='0.0.1', + packages=packages, + package_data=data_files, + scripts=scripts, + url='N/A', + license='3-clause BSD', + maintainer='setrofim', + maintainer_email='setrofim@gmail.com', + install_requires=[ + #'PyQt4', + 'requests', + 'praw', + ], + # https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + 'Development Status :: 4 - Beta', + 'Programming Language :: Python :: 3.4', + ], +) + +setup(**params) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..1f14d09 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,41 @@ +import unittest + +from billboard.utils import DroppingSet + + +class TestDroppingSet(unittest.TestCase): + + def test_add_discard(self): + ds = DroppingSet(10) + ds.add(4) + ds.add(3) + self.assertTrue(4 in ds) + self.assertTrue(3 in ds) + ds.discard(4) + self.assertFalse(4 in ds) + self.assertTrue(3 in ds) + + def test_order(self): + ds = DroppingSet(10) + ds.add(4) + ds.add(3) + ds.add(42) + self.assertEqual(ds.pop(), 42) + self.assertEqual(ds.pop(), 3) + self.assertEqual(ds.pop(), 4) + + def test_max(self): + ds = DroppingSet(4) + ds.add(1) + ds.add(2) + ds.add(3) + ds.add(4) + self.assertTrue(1 in ds) + self.assertTrue(4 in ds) + ds.add(5) + self.assertFalse(1 in ds) + self.assertTrue(2 in ds) + self.assertTrue(4 in ds) + self.assertTrue(5 in ds) + ds.add(6) + self.assertFalse(2 in ds)