1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 17:06:17 +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 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 Folder, Artist, Album, Track, User
from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack
from .db import RatingFolder, RatingTrack from .db import RatingFolder, RatingTrack
@ -90,14 +90,7 @@ class Scanner:
f.delete() # Pony will cascade f.delete() # Pony will cascade
continue continue
album_name = None self.find_cover(f.path)
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
folders += f.children folders += f.children
folder.last_scan = int(time.time()) folder.last_scan = int(time.time())
@ -212,6 +205,46 @@ class Scanner:
tr.folder = folder tr.folder = folder
tr.path = dst_path 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): def __find_album(self, artist, album):
ar = self.__find_artist(artist) ar = self.__find_artist(artist)
al = ar.albums.select(lambda a: a.name == album).first() al = ar.albums.select(lambda a: a.name == album).first()

View File

@ -8,6 +8,7 @@
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
import logging import logging
import os.path
import time import time
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@ -17,6 +18,7 @@ from threading import Thread, Condition, Timer
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler from watchdog.events import PatternMatchingEventHandler
from . import covers
from .db import init_database, release_database, Folder from .db import init_database, release_database, Folder
from .py23 import dict from .py23 import dict
from .scanner import Scanner from .scanner import Scanner
@ -25,10 +27,13 @@ OP_SCAN = 1
OP_REMOVE = 2 OP_REMOVE = 2
OP_MOVE = 4 OP_MOVE = 4
FLAG_CREATE = 8 FLAG_CREATE = 8
FLAG_COVER = 16
class SupysonicWatcherEventHandler(PatternMatchingEventHandler): class SupysonicWatcherEventHandler(PatternMatchingEventHandler):
def __init__(self, extensions, queue, logger): 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) super(SupysonicWatcherEventHandler, self).__init__(patterns = patterns, ignore_directories = True)
self.__queue = queue self.__queue = queue
@ -37,29 +42,51 @@ class SupysonicWatcherEventHandler(PatternMatchingEventHandler):
def dispatch(self, event): def dispatch(self, event):
try: try:
super(SupysonicWatcherEventHandler, self).dispatch(event) super(SupysonicWatcherEventHandler, self).dispatch(event)
except Exception as e: except Exception as e: # pragma: nocover
self.__logger.critical(e) self.__logger.critical(e)
def on_created(self, event): def on_created(self, event):
self.__logger.debug("File created: '%s'", event.src_path) 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): def on_deleted(self, event):
self.__logger.debug("File deleted: '%s'", event.src_path) 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): def on_modified(self, event):
self.__logger.debug("File modified: '%s'", event.src_path) 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): def on_moved(self, event):
self.__logger.debug("File moved: '%s' -> '%s'", event.src_path, event.dest_path) 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): class Event(object):
def __init__(self, path, operation, **kwargs): def __init__(self, path, operation, **kwargs):
if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE): 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.__path = path
self.__time = time.time() self.__time = time.time()
@ -68,7 +95,7 @@ class Event(object):
def set(self, operation, **kwargs): def set(self, operation, **kwargs):
if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE): 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() self.__time = time.time()
if operation & OP_SCAN: if operation & OP_SCAN:
@ -113,7 +140,7 @@ class ScannerProcessingQueue(Thread):
def run(self): def run(self):
try: try:
self.__run() self.__run()
except Exception as e: except Exception as e: # pragma: nocover
self.__logger.critical(e) self.__logger.critical(e)
raise e raise e
@ -132,21 +159,48 @@ class ScannerProcessingQueue(Thread):
item = self.__next_item() item = self.__next_item()
while item: while item:
if item.operation & OP_MOVE: if item.operation & FLAG_COVER:
self.__logger.info("Moving: '%s' -> '%s'", item.src_path, item.path) self.__process_cover_item(scanner, item)
scanner.move_file(item.src_path, item.path) else:
if item.operation & OP_SCAN: self.__process_regular_item(scanner, item)
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)
item = self.__next_item() item = self.__next_item()
scanner.finish() scanner.finish()
self.__logger.debug("Freeing scanner") self.__logger.debug("Freeing scanner")
del 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): def stop(self):
self.__running = False self.__running = False
with self.__cond: with self.__cond:
@ -232,7 +286,7 @@ class SupysonicWatcher(object):
logger.info("Starting watcher for %s", folder.path) logger.info("Starting watcher for %s", folder.path)
observer.schedule(handler, folder.path, recursive = True) observer.schedule(handler, folder.path, recursive = True)
try: try: # pragma: nocover
signal(SIGTERM, self.__terminate) signal(SIGTERM, self.__terminate)
signal(SIGINT, self.__terminate) signal(SIGINT, self.__terminate)
except ValueError: except ValueError:
@ -254,5 +308,5 @@ class SupysonicWatcher(object):
self.__running = False self.__running = False
def __terminate(self, signum, frame): 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 pony.orm import db_session
from threading import Thread 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.managers.folder import FolderManager
from supysonic.watcher import SupysonicWatcher from supysonic.watcher import SupysonicWatcher
@ -99,14 +99,26 @@ class WatcherTestCase(WatcherTestBase):
with tempfile.NamedTemporaryFile() as f: with tempfile.NamedTemporaryFile() as f:
return os.path.basename(f.name) return os.path.basename(f.name)
def _temppath(self): def _temppath(self, suffix, depth = 0):
return os.path.join(self.__dir, self._tempname() + '.mp3') 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): def _addfile(self, depth = 0):
path = self._temppath() path = self._temppath('.mp3', depth)
shutil.copyfile('tests/assets/folder/silence.mp3', path) shutil.copyfile('tests/assets/folder/silence.mp3', path)
return 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 @db_session
def assertTrackCountEqual(self, expected): def assertTrackCountEqual(self, expected):
self.assertEqual(Track.select().count(), expected) self.assertEqual(Track.select().count(), expected)
@ -163,7 +175,7 @@ class WatcherTestCase(WatcherTestBase):
self.assertEqual(Track.select().count(), 1) self.assertEqual(Track.select().count(), 1)
trackid = Track.select().first().id trackid = Track.select().first().id
newpath = self._temppath() newpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
self._sleep() self._sleep()
@ -179,7 +191,7 @@ class WatcherTestCase(WatcherTestBase):
filename = self._tempname() + '.mp3' filename = self._tempname() + '.mp3'
initialpath = os.path.join(tempfile.gettempdir(), filename) initialpath = os.path.join(tempfile.gettempdir(), filename)
shutil.copyfile('tests/assets/folder/silence.mp3', initialpath) 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._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
@ -212,7 +224,7 @@ class WatcherTestCase(WatcherTestBase):
def test_add_rename(self): def test_add_rename(self):
path = self._addfile() path = self._addfile()
shutil.move(path, self._temppath()) shutil.move(path, self._temppath('.mp3'))
self._sleep() self._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
@ -221,7 +233,7 @@ class WatcherTestCase(WatcherTestBase):
self._sleep() self._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
newpath = self._temppath() newpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
os.unlink(newpath) os.unlink(newpath)
self._sleep() self._sleep()
@ -229,7 +241,7 @@ class WatcherTestCase(WatcherTestBase):
def test_add_rename_delete(self): def test_add_rename_delete(self):
path = self._addfile() path = self._addfile()
newpath = self._temppath() newpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
os.unlink(newpath) os.unlink(newpath)
self._sleep() self._sleep()
@ -240,18 +252,112 @@ class WatcherTestCase(WatcherTestBase):
self._sleep() self._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
newpath = self._temppath() newpath = self._temppath('.mp3')
finalpath = self._temppath() finalpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
shutil.move(newpath, finalpath) shutil.move(newpath, finalpath)
self._sleep() self._sleep()
self.assertTrackCountEqual(1) 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(): def suite():
suite = unittest.TestSuite() suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(NothingToWatchTestCase)) suite.addTest(unittest.makeSuite(NothingToWatchTestCase))
suite.addTest(unittest.makeSuite(WatcherTestCase)) suite.addTest(unittest.makeSuite(AudioWatcherTestCase))
suite.addTest(unittest.makeSuite(CoverWatcherTestCase))
return suite return suite