1 # Copyright (c) 2009-2013, 2019-2021 kaliko <kaliko@azylum.org>
3 # This file is part of sima
5 # sima is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
10 # sima is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with sima. If not, see <http://www.gnu.org/licenses/>.
19 """SQlite database library
24 #: Default history duration for both request and purge in hours
25 __HIST_DURATION__ = int(30 * 24)
29 from collections import deque
30 from datetime import (datetime, timedelta)
31 from datetime import timezone
34 from sima.lib.meta import Artist, Album
35 from sima.lib.track import Track
36 from sima.utils.utils import MPDSimaException
39 class SimaDBError(MPDSimaException):
48 def __init__(self, db_path=None):
49 self._db_path = db_path
51 def get_database_connection(self):
52 """get database reference"""
53 connection = sqlite3.connect(self._db_path, isolation_level=None)
57 connection = self.get_database_connection()
58 info = connection.execute("""SELECT * FROM db_info
59 WHERE name = "DB Version" LIMIT 1;""").fetchone()
66 connection = self.get_database_connection()
68 'CREATE TABLE IF NOT EXISTS db_info'
69 ' (name CHAR(50), value CHAR(50))')
70 connection.execute('''INSERT INTO db_info (name, value) SELECT ?, ?
72 ( SELECT 1 FROM db_info WHERE name = ? )''',
73 ('DB Version', __DB_VERSION__, 'DB Version'))
74 connection.execute( # ARTISTS
75 'CREATE TABLE IF NOT EXISTS artists (id INTEGER PRIMARY KEY, '
76 'name VARCHAR(100), mbid CHAR(36))')
77 connection.execute( # ALBUMS
78 'CREATE TABLE IF NOT EXISTS albums (id INTEGER PRIMARY KEY, '
79 'name VARCHAR(100), mbid CHAR(36))')
80 connection.execute( # ALBUMARTISTS
81 'CREATE TABLE IF NOT EXISTS albumartists (id INTEGER PRIMARY KEY, '
82 'name VARCHAR(100), mbid CHAR(36))')
83 connection.execute( # TRACKS
84 'CREATE TABLE IF NOT EXISTS tracks (id INTEGER PRIMARY KEY, '
85 'title VARCHAR(100), artist INTEGER, '
86 'album INTEGER, albumartist INTEGER, '
87 'file VARCHAR(500), mbid CHAR(36), '
88 'FOREIGN KEY(artist) REFERENCES artists(id), '
89 'FOREIGN KEY(album) REFERENCES albums(id), '
90 'FOREIGN KEY(albumartist) REFERENCES albumartists(id))')
91 connection.execute( # HISTORY
92 'CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, '
93 'last_play TIMESTAMP, track INTEGER, '
94 'FOREIGN KEY(track) REFERENCES tracks(id))')
95 connection.execute( # BLOCKLIST
96 'CREATE TABLE IF NOT EXISTS blocklist (id INTEGER PRIMARY KEY, '
97 'artist INTEGER, album INTEGER, track INTEGER, '
98 'FOREIGN KEY(artist) REFERENCES artists(id), '
99 'FOREIGN KEY(album) REFERENCES albums(id), '
100 'FOREIGN KEY(track) REFERENCES tracks(id))')
101 connection.execute( # Genres (Many-to-many)
102 'CREATE TABLE IF NOT EXISTS genres '
103 '(id INTEGER PRIMARY KEY, name VARCHAR(100))')
104 connection.execute( # Junction Genres Tracks
105 """CREATE TABLE IF NOT EXISTS tracks_genres
106 ( track INTEGER, genre INTEGER,
107 FOREIGN KEY(track) REFERENCES tracks(id)
108 FOREIGN KEY(genre) REFERENCES genres(id))""")
109 # Create cleanup triggers:
110 # DELETE history → Tracks / Tracks_genres tables
111 connection.execute('''
112 CREATE TRIGGER IF NOT EXISTS del_history_cleanup_tracks
113 AFTER DELETE ON history
114 WHEN ((SELECT count(*) FROM history WHERE track=old.track) = 0 AND
115 (SELECT count(*) FROM blocklist WHERE track=old.track) = 0)
117 DELETE FROM tracks WHERE id = old.track;
118 DELETE FROM tracks_genres WHERE track = old.track;
121 # DELETE Tracks_Genres → Genres table
122 connection.execute('''
123 CREATE TRIGGER IF NOT EXISTS del_tracks_genres_cleanup_genres
124 AFTER DELETE ON tracks_genres
125 WHEN ((SELECT count(*) FROM tracks_genres WHERE genre=old.genre) = 0)
127 DELETE FROM genres WHERE id = old.genre;
130 # DELETE Tracks → Artists table
131 connection.execute('''
132 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_artists
133 AFTER DELETE ON tracks
134 WHEN ((SELECT count(*) FROM tracks WHERE artist=old.artist) = 0 AND
135 (SELECT count(*) FROM blocklist WHERE artist=old.artist) = 0)
137 DELETE FROM artists WHERE id = old.artist;
140 # DELETE Tracks → Albums table
141 connection.execute('''
142 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_albums
143 AFTER DELETE ON tracks
144 WHEN ((SELECT count(*) FROM tracks WHERE album=old.album) = 0 AND
145 (SELECT count(*) FROM blocklist WHERE album=old.album) = 0)
147 DELETE FROM albums WHERE id = old.album;
150 # DELETE Tracks → cleanup AlbumArtists table
151 connection.execute('''
152 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_albumartists
153 AFTER DELETE ON tracks
154 WHEN ((SELECT count(*) FROM tracks WHERE albumartist=old.albumartist) = 0)
156 DELETE FROM albumartists WHERE id = old.albumartist;
159 # DELETE blocklist → Tracks table
160 connection.execute('''
161 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_tracks
162 AFTER DELETE ON blocklist
163 WHEN ((SELECT count(*) FROM history WHERE track=old.track) = 0 AND
164 (SELECT count(*) FROM blocklist WHERE track=old.track) = 0)
166 DELETE FROM tracks WHERE id = old.track;
169 # DELETE blocklist → Artists table
170 # The "SELECT count(*) FROM blocklist" is useless,
171 # there can be only one blocklist.artist
172 connection.execute('''
173 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_artists
174 AFTER DELETE ON blocklist
175 WHEN ((SELECT count(*) FROM tracks WHERE artist=old.artist) = 0 AND
176 (SELECT count(*) FROM blocklist WHERE artist=old.artist) = 0)
178 DELETE FROM artists WHERE id = old.artist;
181 # DELETE Tracks → Albums table
182 # The "SELECT count(*) FROM blocklist" is useless,
183 # there can be only one blocklist.album
184 connection.execute('''
185 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_albums
186 AFTER DELETE ON blocklist
187 WHEN ((SELECT count(*) FROM tracks WHERE album=old.album) = 0 AND
188 (SELECT count(*) FROM blocklist WHERE album=old.album) = 0)
190 DELETE FROM albums WHERE id = old.album;
196 connection = self.get_database_connection()
197 rows = connection.execute(
198 "SELECT name FROM sqlite_master WHERE type='table'")
199 for row in rows.fetchall():
200 connection.execute(f'DROP TABLE IF EXISTS {row[0]}')
203 def _remove_blocklist_id(self, blid, with_connection=None):
204 """Remove a blocklist id"""
206 connection = with_connection
208 connection = self.get_database_connection()
209 connection = self.get_database_connection()
210 connection.execute('DELETE FROM blocklist'
211 ' WHERE blocklist.id = ?', (blid,))
213 if not with_connection:
216 def _get_album(self, album, connection):
218 return connection.execute(
219 "SELECT id FROM albums WHERE mbid = ?",
221 return connection.execute(
222 "SELECT id FROM albums WHERE name = ? AND mbid IS NULL",
225 def get_album(self, album, with_connection=None, add=True):
226 """get album information from the database.
227 if not in database insert new entry.
229 :param sima.lib.meta.Album album: album objet
230 :param sqlite3.Connection with_connection: SQLite connection
233 connection = with_connection
235 connection = self.get_database_connection()
236 rows = self._get_album(album, connection)
238 if not with_connection:
242 if not with_connection:
246 "INSERT INTO albums (name, mbid) VALUES (?, ?)",
247 (album.name, album.mbid))
249 rows = self._get_album(album, connection)
251 if not with_connection:
254 if not with_connection:
258 def _get_albumartist(self, artist, connection):
260 return connection.execute(
261 "SELECT id FROM albumartists WHERE mbid = ?",
263 return connection.execute(
264 "SELECT id FROM albumartists WHERE name = ? AND mbid IS NULL",
267 def get_albumartist(self, artist, with_connection=None, add=True):
268 """get albumartist information from the database.
269 if not in database insert new entry.
271 :param sima.lib.meta.Artist artist: artist
272 :param sqlite3.Connection with_connection: SQLite connection
275 connection = with_connection
277 connection = self.get_database_connection()
278 rows = self._get_albumartist(artist, connection)
280 if not with_connection:
284 if not with_connection:
288 "INSERT INTO albumartists (name, mbid) VALUES (?, ?)",
289 (artist.name, artist.mbid))
291 rows = self._get_albumartist(artist, connection)
293 if not with_connection:
296 if not with_connection:
299 def _get_artist(self, artist, connection):
301 return connection.execute(
302 "SELECT id FROM artists WHERE mbid = ?",
304 return connection.execute(
305 "SELECT id FROM artists WHERE name = ? AND mbid IS NULL", (artist.name,))
307 def get_artist(self, artist, with_connection=None, add=True):
308 """get artist information from the database.
309 if not in database insert new entry.
311 :param sima.lib.meta.Artist artist: artist
312 :param sqlite3.Connection with_connection: SQLite connection
315 connection = with_connection
317 connection = self.get_database_connection()
318 rows = self._get_artist(artist, connection)
320 if not with_connection:
324 if not with_connection:
328 "INSERT INTO artists (name, mbid) VALUES (?, ?)",
329 (artist.name, artist.mbid))
331 rows = self._get_artist(artist, connection)
333 if not with_connection:
336 if not with_connection:
339 def get_genre(self, genre, with_connection=None, add=True):
340 """get genre from the database.
341 if not in database insert new entry.
343 :param str genre: genre as a string
344 :param sqlite3.Connection with_connection: SQLite connection
347 connection = with_connection
349 connection = self.get_database_connection()
350 rows = connection.execute(
351 "SELECT id FROM genres WHERE name = ?", (genre,))
353 if not with_connection:
357 if not with_connection:
361 "INSERT INTO genres (name) VALUES (?)", (genre,))
363 rows = connection.execute(
364 "SELECT id FROM genres WHERE name = ?", (genre,))
366 if not with_connection:
370 def get_track(self, track, with_connection=None, add=True):
371 """Get a track id from Tracks table, add if not existing,
373 :param sima.lib.track.Track track: track to use
374 :param bool add: add non existing track to database"""
376 raise SimaDBError(f'Got a track with no file attribute: {track}')
378 connection = with_connection
380 connection = self.get_database_connection()
381 rows = connection.execute(
382 "SELECT * FROM tracks WHERE file = ?", (track.file,))
384 if not with_connection:
387 if not add: # Not adding non existing track
388 if not with_connection:
391 # Get an artist record or None
393 art = Artist(name=track.artist, mbid=track.musicbrainz_artistid)
394 art_id = self.get_artist(art, with_connection=connection)
397 # Get an albumartist record or None
398 if track.albumartist:
399 albart = Artist(name=track.albumartist,
400 mbid=track.musicbrainz_albumartistid)
401 albart_id = self.get_albumartist(albart, with_connection=connection)
404 # Get an album record or None
406 alb = Album(name=track.album, mbid=track.musicbrainz_albumid)
407 alb_id = self.get_album(alb, with_connection=connection)
411 """INSERT INTO tracks (artist, albumartist, album, title, mbid, file)
412 VALUES (?, ?, ?, ?, ?, ?)""",
413 (art_id, albart_id, alb_id, track.title, track.musicbrainz_trackid,
416 # Add track id to junction tables
417 self._add_tracks_genres(track, connection)
418 rows = connection.execute(
419 "SELECT id FROM tracks WHERE file = ?", (track.file,))
421 if not with_connection:
424 if not with_connection:
428 def _add_tracks_genres(self, track, connection):
431 rows = connection.execute(
432 "SELECT id FROM tracks WHERE file = ?", (track.file,))
433 trk_id = rows.fetchone()[0]
434 for genre in track.genres:
436 gen_id = self.get_genre(genre)
437 connection.execute("""INSERT INTO tracks_genres (track, genre)
438 VALUES (?, ?)""", (trk_id, gen_id))
440 def add_history(self, track, date=None):
441 """Record last play date of track (ie. not a real play history).
443 :param sima.lib.track.Track track: track to add to history
444 :param datetime.datetime date: UTC datetime object (use "datetime.now(timezone.utc)" is not set)"""
446 date = datetime.now(timezone.utc)
447 connection = self.get_database_connection()
448 track_id = self.get_track(track, with_connection=connection)
449 rows = connection.execute("SELECT * FROM history WHERE track = ? ",
451 if not rows.fetchone():
452 connection.execute("INSERT INTO history (track) VALUES (?)",
454 connection.execute("UPDATE history SET last_play = ? "
455 " WHERE track = ?", (date, track_id,))
459 def purge_history(self, duration=__HIST_DURATION__):
460 """Remove old entries in history
462 :param int duration: Purge history record older than duration in hours"""
463 connection = self.get_database_connection()
464 connection.execute("DELETE FROM history WHERE last_play"
465 " < datetime('now', '-%i hours')" % duration)
466 connection.execute('VACUUM')
470 def fetch_albums_history(self, needle=None, duration=__HIST_DURATION__):
472 :param sima.lib.meta.Artist needle: When specified, returns albums history for this artist.
473 :param int duration: How long ago to fetch history from (in hours)
475 date = datetime.now(timezone.utc) - timedelta(hours=duration)
476 connection = self.get_database_connection()
477 connection.row_factory = sqlite3.Row
478 rows = connection.execute("""
479 SELECT albums.name AS name,
481 artists.name as artist,
482 artists.mbid as artist_mbib,
483 albumartists.name as albumartist,
484 albumartists.mbid as albumartist_mbib
486 JOIN tracks ON history.track = tracks.id
487 LEFT OUTER JOIN albums ON tracks.album = albums.id
488 LEFT OUTER JOIN artists ON tracks.artist = artists.id
489 LEFT OUTER JOIN albumartists ON tracks.albumartist = albumartists.id
490 WHERE history.last_play > ? AND albums.name NOT NULL AND artists.name NOT NULL
491 ORDER BY history.last_play DESC""", (date.isoformat(' '),))
495 if needle: # Here use artist instead of albumartist
496 if needle != Artist(name=vals.get('artist'),
497 mbid=vals.get('artist_mbib')):
499 # Use albumartist / MBIDs if possible to build album artist
500 if not vals.get('albumartist'):
501 vals['albumartist'] = vals.get('artist')
502 if not vals.get('albumartist_mbib'):
503 vals['albumartist_mbib'] = vals.get('artist_mbib')
504 artist = Artist(name=vals.get('albumartist'),
505 mbid=vals.pop('albumartist_mbib'))
506 album = Album(**vals, Artist=artist)
507 if hist and hist[-1] == album:
508 # remove consecutive dupes
514 def fetch_artists_history(self, needle=None, duration=__HIST_DURATION__):
515 """Returns a list of Artist objects
517 :param sima.lib.meta.MetaContainer needle: When specified, returns history for these artists only
518 :param int duration: How long ago to fetch history from (in hours)
519 :type needle: sima.lib.meta.Artist or sima.lib.meta.MetaContainer
521 date = datetime.now(timezone.utc) - timedelta(hours=duration)
522 connection = self.get_database_connection()
523 connection.row_factory = sqlite3.Row
524 rows = connection.execute("""
525 SELECT artists.name AS name,
528 JOIN tracks ON history.track = tracks.id
529 LEFT OUTER JOIN artists ON tracks.artist = artists.id
530 WHERE history.last_play > ? AND artists.name NOT NULL
531 ORDER BY history.last_play DESC""", (date.isoformat(' '),))
532 last = deque(maxlen=1)
535 artist = Artist(**row)
536 if last and last[0] == artist: # remove consecutive dupes
539 if needle and isinstance(needle, (Artist, str)):
541 hist.append(artist) # No need to go further
544 if needle and getattr(needle, '__contains__'):
546 hist.append(artist) # No need to go further
552 def fetch_genres_history(self, duration=__HIST_DURATION__, limit=20):
553 """Returns genre history
555 :param int duration: How long ago to fetch history from (in hours)
556 :param int limit: number of genre to fetch
558 date = datetime.now(timezone.utc) - timedelta(hours=duration)
559 connection = self.get_database_connection()
560 rows = connection.execute("""
561 SELECT genres.name, artists.name
563 JOIN tracks ON history.track = tracks.id
564 LEFT OUTER JOIN tracks_genres ON tracks_genres.track = tracks.id
565 LEFT OUTER JOIN artists ON tracks.artist = artists.id
566 LEFT OUTER JOIN genres ON genres.id = tracks_genres.genre
567 WHERE history.last_play > ? AND genres.name NOT NULL
568 ORDER BY history.last_play DESC
569 """, (date.isoformat(' '),))
573 if len({g[0] for g in genres}) >= limit:
578 def fetch_history(self, artist=None, duration=__HIST_DURATION__):
579 """Fetches tracks history, more recent first
581 :param sima.lib.meta.Artist artist: limit history to this artist
582 :param int duration: How long ago to fetch history from (in hours)
584 date = datetime.now(timezone.utc) - timedelta(hours=duration)
585 connection = self.get_database_connection()
586 connection.row_factory = sqlite3.Row
588 SELECT tracks.title, tracks.file, artists.name AS artist,
589 albumartists.name AS albumartist,
590 artists.mbid as musicbrainz_artistid,
591 albums.name AS album,
592 albums.mbid AS musicbrainz_albumid,
593 tracks.mbid as musicbrainz_trackid
595 JOIN tracks ON history.track = tracks.id
596 LEFT OUTER JOIN artists ON tracks.artist = artists.id
597 LEFT OUTER JOIN albumartists ON tracks.albumartist = albumartists.id
598 LEFT OUTER JOIN albums ON tracks.album = albums.id
599 WHERE history.last_play > ?
603 rows = connection.execute(sql+"""
605 ORDER BY history.last_play DESC""",
606 (date.isoformat(' '), artist.mbid))
608 rows = connection.execute(sql+"""
610 ORDER BY history.last_play DESC""",
611 (date.isoformat(' '), artist.name))
613 rows = connection.execute(sql+'ORDER BY history.last_play DESC',
614 (date.isoformat(' '),))
617 hist.append(Track(**row))
621 def get_bl_track(self, track, with_connection=None, add=True):
622 """Add a track to blocklist
624 :param sima.lib.track.Track track: Track object to add to blocklist
625 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
626 :param bool add: Default is to add a new record, set to False to fetch associated record"""
628 connection = with_connection
630 connection = self.get_database_connection()
631 track_id = self.get_track(track, with_connection=connection, add=add)
632 rows = connection.execute(
633 "SELECT id FROM blocklist WHERE track = ?", (track_id,))
634 if not rows.fetchone():
636 if not with_connection:
639 connection.execute('INSERT INTO blocklist (track) VALUES (?)',
642 rows = connection.execute(
643 "SELECT id FROM blocklist WHERE track = ?", (track_id,))
644 blt = rows.fetchone()[0]
645 if not with_connection:
649 def get_bl_album(self, album, with_connection=None, add=True):
650 """Add an album to blocklist
652 :param sima.lib.meta.Album: Album object to add to blocklist
653 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
654 :param bool add: Default is to add a new record, set to False to fetch associated record"""
656 connection = with_connection
658 connection = self.get_database_connection()
659 album_id = self.get_album(album, with_connection=connection, add=add)
660 rows = connection.execute(
661 "SELECT id FROM blocklist WHERE album = ?", (album_id,))
662 if not rows.fetchone():
664 if not with_connection:
667 connection.execute('INSERT INTO blocklist (album) VALUES (?)',
670 rows = connection.execute(
671 "SELECT id FROM blocklist WHERE album = ?", (album_id,))
672 blitem = rows.fetchone()[0]
673 if not with_connection:
677 def get_bl_artist(self, artist, with_connection=None, add=True):
678 """Add an artist to blocklist
680 :param sima.lib.meta.Artist: Artist object to add to blocklist
681 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
682 :param bool add: Default is to add a new record, set to False to fetch associated record"""
684 connection = with_connection
686 connection = self.get_database_connection()
687 artist_id = self.get_artist(artist, with_connection=connection, add=add)
688 rows = connection.execute(
689 "SELECT id FROM blocklist WHERE artist = ?", (artist_id,))
690 if not rows.fetchone():
693 connection.execute('INSERT INTO blocklist (artist) VALUES (?)',
696 rows = connection.execute(
697 "SELECT id FROM blocklist WHERE artist = ?", (artist_id,))
698 blitem = rows.fetchone()[0]
699 if not with_connection:
704 connection = self.get_database_connection()
705 connection.row_factory = sqlite3.Row
706 rows = connection.execute("""SELECT artists.name AS artist,
707 artists.mbid AS musicbrainz_artist,
708 albums.name AS album,
709 albums.mbid AS musicbrainz_album,
710 tracks.title AS title,
711 tracks.mbid AS musicbrainz_title,
715 LEFT OUTER JOIN artists ON blocklist.artist = artists.id
716 LEFT OUTER JOIN albums ON blocklist.album = albums.id
717 LEFT OUTER JOIN tracks ON blocklist.track = tracks.id""")
718 res = [dict(row) for row in rows.fetchall()]
722 def delete_bl(self, track=None, album=None, artist=None):
723 if not (track or album or artist):
725 connection = self.get_database_connection()
728 blid = self.get_bl_track(track, with_connection=connection)
730 blid = self.get_bl_album(album, with_connection=connection)
732 blid = self.get_bl_artist(artist, with_connection=connection)
735 self._remove_blocklist_id(blid, with_connection=connection)
740 # vim: ai ts=4 sw=4 sts=4 expandtab fileencoding=utf8