1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-11-09 11:42:16 +00:00

Watcher now handles cover art

Closes #92
This commit is contained in:
spl0k 2018-08-28 17:44:32 +02:00
parent 9736622ce1
commit 9c58b695ff
3 changed files with 235 additions and 42 deletions

View File

@ -14,7 +14,7 @@ import time
from pony.orm import db_session
from .covers import find_cover_in_folder
from .covers import find_cover_in_folder, CoverFile
from .db import Folder, Artist, Album, Track, User
from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack
from .db import RatingFolder, RatingTrack
@ -90,14 +90,7 @@ class Scanner:
f.delete() # Pony will cascade
continue
album_name = None
track = f.tracks.select().first()
if track is not None:
album_name = track.album.name
cover = find_cover_in_folder(f.path, album_name)
f.cover_art = cover.name if cover is not None else None
self.find_cover(f.path)
folders += f.children
folder.last_scan = int(time.time())
@ -212,6 +205,46 @@ class Scanner:
tr.folder = folder
tr.path = dst_path
@db_session
def find_cover(self, dirpath):
if not isinstance(dirpath, strtype): # pragma: nocover
raise TypeError('Expecting string, got ' + str(type(dirpath)))
folder = Folder.get(path = dirpath)
if folder is None:
return
album_name = None
track = folder.tracks.select().first()
if track is not None:
album_name = track.album.name
cover = find_cover_in_folder(folder.path, album_name)
folder.cover_art = cover.name if cover is not None else None
@db_session
def add_cover(self, path):
if not isinstance(path, strtype): # pragma: nocover
raise TypeError('Expecting string, got ' + str(type(path)))
folder = Folder.get(path = os.path.dirname(path))
if folder is None:
return
cover_name = os.path.basename(path)
if not folder.cover_art:
folder.cover_art = cover_name
else:
album_name = None
track = folder.tracks.select().first()
if track is not None:
album_name = track.album.name
current_cover = CoverFile(folder.cover_art, album_name)
new_cover = CoverFile(cover_name, album_name)
if new_cover.score > current_cover.score:
folder.cover_art = cover_name
def __find_album(self, artist, album):
ar = self.__find_artist(artist)
al = ar.albums.select(lambda a: a.name == album).first()

View File

@ -8,6 +8,7 @@
# Distributed under terms of the GNU AGPLv3 license.
import logging
import os.path
import time
from logging.handlers import TimedRotatingFileHandler
@ -17,6 +18,7 @@ from threading import Thread, Condition, Timer
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler
from . import covers
from .db import init_database, release_database, Folder
from .py23 import dict
from .scanner import Scanner
@ -25,10 +27,13 @@ OP_SCAN = 1
OP_REMOVE = 2
OP_MOVE = 4
FLAG_CREATE = 8
FLAG_COVER = 16
class SupysonicWatcherEventHandler(PatternMatchingEventHandler):
def __init__(self, extensions, queue, logger):
patterns = map(lambda e: "*." + e.lower(), extensions.split()) if extensions else None
patterns = None
if extensions:
patterns = list(map(lambda e: "*." + e.lower(), extensions.split())) + list(map(lambda e: "*" + e, covers.EXTENSIONS))
super(SupysonicWatcherEventHandler, self).__init__(patterns = patterns, ignore_directories = True)
self.__queue = queue
@ -37,29 +42,51 @@ class SupysonicWatcherEventHandler(PatternMatchingEventHandler):
def dispatch(self, event):
try:
super(SupysonicWatcherEventHandler, self).dispatch(event)
except Exception as e:
except Exception as e: # pragma: nocover
self.__logger.critical(e)
def on_created(self, event):
self.__logger.debug("File created: '%s'", event.src_path)
self.__queue.put(event.src_path, OP_SCAN | FLAG_CREATE)
op = OP_SCAN | FLAG_CREATE
if not covers.is_valid_cover(event.src_path):
self.__queue.put(event.src_path, op)
dirname = os.path.dirname(event.src_path)
with db_session:
folder = Folder.get(path = dirname)
if folder is None:
self.__queue.put(dirname, op | FLAG_COVER)
else:
self.__queue.put(event.src_path, op | FLAG_COVER)
def on_deleted(self, event):
self.__logger.debug("File deleted: '%s'", event.src_path)
self.__queue.put(event.src_path, OP_REMOVE)
op = OP_REMOVE
_, ext = os.path.splitext(event.src_path)
if ext in covers.EXTENSIONS:
op |= FLAG_COVER
self.__queue.put(event.src_path, op)
def on_modified(self, event):
self.__logger.debug("File modified: '%s'", event.src_path)
self.__queue.put(event.src_path, OP_SCAN)
if not covers.is_valid_cover(event.src_path):
self.__queue.put(event.src_path, OP_SCAN)
def on_moved(self, event):
self.__logger.debug("File moved: '%s' -> '%s'", event.src_path, event.dest_path)
self.__queue.put(event.dest_path, OP_MOVE, src_path = event.src_path)
op = OP_MOVE
_, ext = os.path.splitext(event.src_path)
if ext in covers.EXTENSIONS:
op |= FLAG_COVER
self.__queue.put(event.dest_path, op, src_path = event.src_path)
class Event(object):
def __init__(self, path, operation, **kwargs):
if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE):
raise Exception("Flags SCAN and REMOVE both set")
raise Exception("Flags SCAN and REMOVE both set") # pragma: nocover
self.__path = path
self.__time = time.time()
@ -68,7 +95,7 @@ class Event(object):
def set(self, operation, **kwargs):
if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE):
raise Exception("Flags SCAN and REMOVE both set")
raise Exception("Flags SCAN and REMOVE both set") # pragma: nocover
self.__time = time.time()
if operation & OP_SCAN:
@ -113,7 +140,7 @@ class ScannerProcessingQueue(Thread):
def run(self):
try:
self.__run()
except Exception as e:
except Exception as e: # pragma: nocover
self.__logger.critical(e)
raise e
@ -132,21 +159,48 @@ class ScannerProcessingQueue(Thread):
item = self.__next_item()
while item:
if item.operation & OP_MOVE:
self.__logger.info("Moving: '%s' -> '%s'", item.src_path, item.path)
scanner.move_file(item.src_path, item.path)
if item.operation & OP_SCAN:
self.__logger.info("Scanning: '%s'", item.path)
scanner.scan_file(item.path)
if item.operation & OP_REMOVE:
self.__logger.info("Removing: '%s'", item.path)
scanner.remove_file(item.path)
if item.operation & FLAG_COVER:
self.__process_cover_item(scanner, item)
else:
self.__process_regular_item(scanner, item)
item = self.__next_item()
scanner.finish()
self.__logger.debug("Freeing scanner")
del scanner
def __process_regular_item(self, scanner, item):
if item.operation & OP_MOVE:
self.__logger.info("Moving: '%s' -> '%s'", item.src_path, item.path)
scanner.move_file(item.src_path, item.path)
if item.operation & OP_SCAN:
self.__logger.info("Scanning: '%s'", item.path)
scanner.scan_file(item.path)
if item.operation & OP_REMOVE:
self.__logger.info("Removing: '%s'", item.path)
scanner.remove_file(item.path)
def __process_cover_item(self, scanner, item):
if item.operation & OP_SCAN:
if os.path.isdir(item.path):
self.__logger.info("Looking for covers: '%s'", item.path)
scanner.find_cover(item.path)
else:
self.__logger.info("Potentially adding cover: '%s'", item.path)
scanner.add_cover(item.path)
if item.operation & OP_REMOVE:
self.__logger.info("Removing cover: '%s'", item.path)
scanner.find_cover(os.path.dirname(item.path))
if item.operation & OP_MOVE:
self.__logger.info("Moving cover: '%s' -> '%s'", item.src_path, item.path)
scanner.find_cover(os.path.dirname(item.src_path))
scanner.add_cover(item.path)
def stop(self):
self.__running = False
with self.__cond:
@ -232,7 +286,7 @@ class SupysonicWatcher(object):
logger.info("Starting watcher for %s", folder.path)
observer.schedule(handler, folder.path, recursive = True)
try:
try: # pragma: nocover
signal(SIGTERM, self.__terminate)
signal(SIGINT, self.__terminate)
except ValueError:
@ -254,5 +308,5 @@ class SupysonicWatcher(object):
self.__running = False
def __terminate(self, signum, frame):
self.stop()
self.stop() # pragma: nocover

View File

@ -21,7 +21,7 @@ from hashlib import sha1
from pony.orm import db_session
from threading import Thread
from supysonic.db import init_database, release_database, Track, Artist
from supysonic.db import init_database, release_database, Track, Artist, Folder
from supysonic.managers.folder import FolderManager
from supysonic.watcher import SupysonicWatcher
@ -99,14 +99,26 @@ class WatcherTestCase(WatcherTestBase):
with tempfile.NamedTemporaryFile() as f:
return os.path.basename(f.name)
def _temppath(self):
return os.path.join(self.__dir, self._tempname() + '.mp3')
def _temppath(self, suffix, depth = 0):
if depth > 0:
dirpath = os.path.join(self.__dir, *(self._tempname() for _ in range(depth)))
os.makedirs(dirpath)
else:
dirpath = self.__dir
return os.path.join(dirpath, self._tempname() + suffix)
def _addfile(self):
path = self._temppath()
def _addfile(self, depth = 0):
path = self._temppath('.mp3', depth)
shutil.copyfile('tests/assets/folder/silence.mp3', path)
return path
def _addcover(self, suffix = None, depth = 0):
suffix = '.jpg' if suffix is None else (suffix + '.jpg')
path = self._temppath(suffix, depth)
shutil.copyfile('tests/assets/cover.jpg', path)
return path
class AudioWatcherTestCase(WatcherTestCase):
@db_session
def assertTrackCountEqual(self, expected):
self.assertEqual(Track.select().count(), expected)
@ -163,7 +175,7 @@ class WatcherTestCase(WatcherTestBase):
self.assertEqual(Track.select().count(), 1)
trackid = Track.select().first().id
newpath = self._temppath()
newpath = self._temppath('.mp3')
shutil.move(path, newpath)
self._sleep()
@ -179,7 +191,7 @@ class WatcherTestCase(WatcherTestBase):
filename = self._tempname() + '.mp3'
initialpath = os.path.join(tempfile.gettempdir(), filename)
shutil.copyfile('tests/assets/folder/silence.mp3', initialpath)
shutil.move(initialpath, os.path.join(self.__dir, filename))
shutil.move(initialpath, self._temppath('.mp3'))
self._sleep()
self.assertTrackCountEqual(1)
@ -212,7 +224,7 @@ class WatcherTestCase(WatcherTestBase):
def test_add_rename(self):
path = self._addfile()
shutil.move(path, self._temppath())
shutil.move(path, self._temppath('.mp3'))
self._sleep()
self.assertTrackCountEqual(1)
@ -221,7 +233,7 @@ class WatcherTestCase(WatcherTestBase):
self._sleep()
self.assertTrackCountEqual(1)
newpath = self._temppath()
newpath = self._temppath('.mp3')
shutil.move(path, newpath)
os.unlink(newpath)
self._sleep()
@ -229,7 +241,7 @@ class WatcherTestCase(WatcherTestBase):
def test_add_rename_delete(self):
path = self._addfile()
newpath = self._temppath()
newpath = self._temppath('.mp3')
shutil.move(path, newpath)
os.unlink(newpath)
self._sleep()
@ -240,18 +252,112 @@ class WatcherTestCase(WatcherTestBase):
self._sleep()
self.assertTrackCountEqual(1)
newpath = self._temppath()
finalpath = self._temppath()
newpath = self._temppath('.mp3')
finalpath = self._temppath('.mp3')
shutil.move(path, newpath)
shutil.move(newpath, finalpath)
self._sleep()
self.assertTrackCountEqual(1)
class CoverWatcherTestCase(WatcherTestCase):
def test_add_file_then_cover(self):
self._addfile()
path = self._addcover()
self._sleep()
with db_session:
self.assertEqual(Folder.select().first().cover_art, os.path.basename(path))
def test_add_cover_then_file(self):
path = self._addcover()
self._addfile()
self._sleep()
with db_session:
self.assertEqual(Folder.select().first().cover_art, os.path.basename(path))
def test_remove_cover(self):
self._addfile()
path = self._addcover()
self._sleep()
os.unlink(path)
self._sleep()
with db_session:
self.assertIsNone(Folder.select().first().cover_art)
def test_naming_add_good(self):
bad = os.path.basename(self._addcover())
self._sleep()
good = os.path.basename(self._addcover('cover'))
self._sleep()
with db_session:
self.assertEqual(Folder.select().first().cover_art, good)
def test_naming_add_bad(self):
good = os.path.basename(self._addcover('cover'))
self._sleep()
bad = os.path.basename(self._addcover())
self._sleep()
with db_session:
self.assertEqual(Folder.select().first().cover_art, good)
def test_naming_remove_good(self):
bad = self._addcover()
good = self._addcover('cover')
self._sleep()
os.unlink(good)
self._sleep()
with db_session:
self.assertEqual(Folder.select().first().cover_art, os.path.basename(bad))
def test_naming_remove_bad(self):
bad = self._addcover()
good = self._addcover('cover')
self._sleep()
os.unlink(bad)
self._sleep()
with db_session:
self.assertEqual(Folder.select().first().cover_art, os.path.basename(good))
def test_rename(self):
path = self._addcover()
self._sleep()
newpath = self._temppath('.jpg')
shutil.move(path, newpath)
self._sleep()
with db_session:
self.assertEqual(Folder.select().first().cover_art, os.path.basename(newpath))
def test_add_to_folder_without_track(self):
path = self._addcover(depth = 1)
self._sleep()
with db_session:
self.assertFalse(Folder.exists(cover_art = os.path.basename(path)))
def test_remove_from_folder_without_track(self):
path = self._addcover(depth = 1)
self._sleep()
os.unlink(path)
self._sleep()
def test_add_track_to_empty_folder(self):
self._addfile(1)
self._sleep()
def suite():
suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(NothingToWatchTestCase))
suite.addTest(unittest.makeSuite(WatcherTestCase))
suite.addTest(unittest.makeSuite(AudioWatcherTestCase))
suite.addTest(unittest.makeSuite(CoverWatcherTestCase))
return suite