1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-11-10 04:02:17 +00:00
supysonic/supysonic/db.py
2024-05-19 16:02:45 +02:00

723 lines
21 KiB
Python

# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2013-2024 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
import importlib
import mimetypes
import os.path
import sys
import time
from datetime import datetime
from hashlib import sha1
from peewee import (
AutoField,
BlobField,
BooleanField,
CharField,
DateTimeField,
FixedCharField,
ForeignKeyField,
IntegerField,
TextField,
UUIDField,
)
from peewee import CompositeKey, DatabaseProxy, Model, MySQLDatabase
from peewee import fn
from playhouse.db_url import parseresult_to_dict, schemes
from urllib.parse import urlparse
from uuid import UUID, uuid4
SCHEMA_VERSION = "20240318"
def now():
return datetime.now().replace(microsecond=0)
def random():
if isinstance(db.obj, MySQLDatabase):
return fn.rand()
return fn.random()
def PrimaryKeyField(**kwargs):
return UUIDField(primary_key=True, default=uuid4, **kwargs)
db = DatabaseProxy()
class _Model(Model):
class Meta:
database = db
legacy_table_names = False
class Meta(_Model):
key = CharField(32, primary_key=True)
value = CharField(256)
class PathMixin:
@classmethod
def get(cls, *args, **kwargs):
if kwargs:
path = kwargs.pop("path", None)
if path:
kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest()
return _Model.get.__func__(cls, *args, **kwargs)
def __init__(self, *args, **kwargs):
if "path" in kwargs:
path = kwargs["path"]
kwargs["_path_hash"] = sha1(path.encode("utf-8")).digest()
_Model.__init__(self, *args, **kwargs)
def __setattr__(self, attr, value):
_Model.__setattr__(self, attr, value)
if attr == "path":
_Model.__setattr__(self, "_path_hash", sha1(value.encode("utf-8")).digest())
class Folder(PathMixin, _Model):
id = AutoField()
root = BooleanField()
name = CharField()
path = CharField(4096) # unique
_path_hash = BlobField(column_name="path_hash", unique=True)
created = DateTimeField(default=now)
cover_art = CharField(null=True)
last_scan = IntegerField(default=0)
parent = ForeignKeyField("self", null=True, backref="children")
def as_subsonic_child(self, user):
info = {
"id": str(self.id),
"isDir": True,
"title": self.name,
"album": self.name,
"created": self.created.isoformat(),
}
if not self.root:
info["parent"] = str(self.parent.id)
info["artist"] = self.parent.name
if self.cover_art:
info["coverArt"] = str(self.id)
else:
for track in self.tracks:
if track.has_art:
info["coverArt"] = str(track.id)
break
try:
starred = StarredFolder[user.id, self.id]
info["starred"] = starred.date.isoformat()
except StarredFolder.DoesNotExist:
pass
try:
rating = RatingFolder[user.id, self.id]
info["userRating"] = rating.rating
except RatingFolder.DoesNotExist:
pass
avgRating = (
RatingFolder.select(fn.avg(RatingFolder.rating, coerce=False))
.where(RatingFolder.rated == self)
.scalar()
)
if avgRating:
info["averageRating"] = avgRating
return info
def as_subsonic_artist(self, user): # "Artist" type in XSD
info = {"id": str(self.id), "name": self.name}
try:
starred = StarredFolder[user.id, self.id]
info["starred"] = starred.date.isoformat()
except StarredFolder.DoesNotExist:
pass
return info
def as_subsonic_directory(self, user, client): # "Directory" type in XSD
info = {
"id": str(self.id),
"name": self.name,
"child": [
f.as_subsonic_child(user)
for f in self.children.order_by(fn.lower(Folder.name))
]
+ [
t.as_subsonic_child(user, client)
for t in sorted(self.tracks, key=lambda t: t.sort_key())
],
}
if not self.root:
info["parent"] = str(self.parent.id)
return info
@classmethod
def prune(cls):
alias = cls.alias()
query = cls.select(cls.id).where(
~cls.root,
Track.select(fn.count("*")).where(Track.folder == cls.id) == 0,
alias.select(fn.count("*")).where(alias.parent == cls.id) == 0,
)
total = 0
while True:
clone = query.clone() # peewee caches the results, clone to force a refetch
for f in clone:
f.delete_instance(recursive=True)
total += 1
if not len(clone):
return total
def delete_hierarchy(self):
if self.root:
cond = Track.root_folder == self
else:
cond = Track.path.startswith(self.path)
return self.__delete_hierarchy(cond)
def __delete_hierarchy(self, cond):
users = User.select(User.id).join(Track).where(cond)
User.update(last_play=None).where(User.id.in_(users)).execute()
tracks = Track.select(Track.id).where(cond)
RatingTrack.delete().where(RatingTrack.rated.in_(tracks)).execute()
StarredTrack.delete().where(StarredTrack.starred.in_(tracks)).execute()
path_cond = Folder.path.startswith(self.path)
folders = Folder.select(Folder.id).where(path_cond)
RatingFolder.delete().where(RatingFolder.rated.in_(folders)).execute()
StarredFolder.delete().where(StarredFolder.starred.in_(folders)).execute()
deleted_tracks = Track.delete().where(cond).execute()
query = Folder.delete().where(path_cond)
if isinstance(db.obj, MySQLDatabase):
# MySQL can't propery resolve deletion order when it has several to handle
query = query.order_by(Folder.path.desc())
query.execute()
return deleted_tracks
class Artist(_Model):
id = PrimaryKeyField()
name = CharField()
def as_subsonic_artist(self, user):
info = {
"id": str(self.id),
"name": self.name,
# coverArt
"albumCount": self.albums.count(),
}
try:
starred = StarredArtist[user.id, self.id]
info["starred"] = starred.date.isoformat()
except StarredArtist.DoesNotExist:
pass
return info
@classmethod
def prune(cls):
album_artists = Album.select(Album.artist)
track_artists = Track.select(Track.artist)
StarredArtist.delete().where(
StarredArtist.starred.not_in(album_artists),
StarredArtist.starred.not_in(track_artists),
).execute()
return (
cls.delete()
.where(
cls.id.not_in(album_artists),
cls.id.not_in(track_artists),
)
.execute()
)
class Album(_Model):
id = PrimaryKeyField()
name = CharField()
artist = ForeignKeyField(Artist, backref="albums")
def as_subsonic_album(self, user): # "AlbumID3" type in XSD
duration, created, year = self.tracks.select(
fn.sum(Track.duration), fn.min(Track.created), fn.min(Track.year)
).scalar(as_tuple=True)
info = {
"id": str(self.id),
"name": self.name,
"artist": self.artist.name,
"artistId": str(self.artist.id),
"songCount": self.tracks.count(),
"duration": duration,
"created": created.isoformat(),
}
track_with_cover = (
self.tracks.join(Folder).where(Folder.cover_art.is_null(False)).first()
)
if track_with_cover is not None:
info["coverArt"] = str(track_with_cover.folder.id)
else:
track_with_cover = self.tracks.where(Track.has_art).first()
if track_with_cover is not None:
info["coverArt"] = str(track_with_cover.id)
if year:
info["year"] = year
genre = ", ".join(
g
for (g,) in self.tracks.select(Track.genre)
.where(Track.genre.is_null(False))
.distinct()
.tuples()
)
if genre:
info["genre"] = genre
try:
starred = StarredAlbum[user.id, self.id]
info["starred"] = starred.date.isoformat()
except StarredAlbum.DoesNotExist:
pass
return info
def sort_key(self):
year = self.tracks.select(fn.min(Track.year)).scalar() or 9999
return f"{year}{self.name.lower()}"
@classmethod
def prune(cls):
albums = Track.select(Track.album)
StarredAlbum.delete().where(StarredAlbum.starred.not_in(albums)).execute()
return cls.delete().where(cls.id.not_in(albums)).execute()
class Track(PathMixin, _Model):
id = PrimaryKeyField()
disc = IntegerField()
number = IntegerField()
title = CharField()
year = IntegerField(null=True)
genre = CharField(null=True)
duration = IntegerField()
has_art = BooleanField(default=False)
album = ForeignKeyField(Album, backref="tracks")
artist = ForeignKeyField(Artist, backref="tracks")
bitrate = IntegerField()
path = CharField(4096) # unique
_path_hash = BlobField(column_name="path_hash", unique=True)
created = DateTimeField(default=now)
last_modification = IntegerField()
play_count = IntegerField(default=0)
last_play = DateTimeField(null=True)
root_folder = ForeignKeyField(Folder, backref="+")
folder = ForeignKeyField(Folder, backref="tracks")
def as_subsonic_child(self, user, prefs):
info = {
"id": str(self.id),
"parent": str(self.folder.id),
"isDir": False,
"title": self.title,
"album": self.album.name,
"artist": self.artist.name,
"track": self.number,
"size": os.path.getsize(self.path) if os.path.isfile(self.path) else -1,
"contentType": self.mimetype,
"suffix": self.suffix(),
"duration": self.duration,
"bitRate": self.bitrate,
"path": self.path[len(self.root_folder.path) + 1 :],
"isVideo": False,
"discNumber": self.disc,
"created": self.created.isoformat(),
"albumId": str(self.album.id),
"artistId": str(self.artist.id),
"type": "music",
}
if self.year:
info["year"] = self.year
if self.genre:
info["genre"] = self.genre
if self.has_art:
info["coverArt"] = str(self.id)
elif self.folder.cover_art:
info["coverArt"] = str(self.folder.id)
try:
starred = StarredTrack[user.id, self.id]
info["starred"] = starred.date.isoformat()
except StarredTrack.DoesNotExist:
pass
try:
rating = RatingTrack[user.id, self.id]
info["userRating"] = rating.rating
except RatingTrack.DoesNotExist:
pass
avgRating = (
RatingTrack.select(fn.avg(RatingTrack.rating, coerce=False))
.where(RatingTrack.rated == self)
.scalar()
)
if avgRating:
info["averageRating"] = avgRating
if (
prefs is not None
and prefs.format is not None
and prefs.format != self.suffix()
):
info["transcodedSuffix"] = prefs.format
info["transcodedContentType"] = (
mimetypes.guess_type("dummyname." + prefs.format, False)[0]
or "application/octet-stream"
)
return info
@property
def mimetype(self):
return mimetypes.guess_type(self.path, False)[0] or "application/octet-stream"
def duration_str(self):
ret = f"{(self.duration % 3600) / 60:02}:{self.duration % 60:02}"
if self.duration >= 3600:
ret = f"{self.duration / 3600:02}:{ret}"
return ret
def suffix(self):
return os.path.splitext(self.path)[1][1:].lower()
def sort_key(self):
return f"{self.album.artist.name}{self.album.name}{self.disc:02}{self.number:02}{self.title}".lower()
class User(_Model):
id = PrimaryKeyField()
name = CharField(64, unique=True)
mail = CharField(null=True)
password = FixedCharField(40)
salt = FixedCharField(6)
admin = BooleanField(default=False)
jukebox = BooleanField(default=False)
lastfm_session = FixedCharField(32, null=True)
lastfm_status = BooleanField(
default=True
) # True: ok/unlinked, False: invalid session
listenbrainz_session = FixedCharField(36, null=True)
listenbrainz_status = BooleanField(
default=True
) # True: ok/unlinked, False: invalid token
last_play = ForeignKeyField(Track, null=True, backref="+")
last_play_date = DateTimeField(null=True)
def as_subsonic_user(self):
return {
"username": self.name,
"email": self.mail or "",
"scrobblingEnabled": self.lastfm_session is not None and self.lastfm_status,
"adminRole": self.admin,
"settingsRole": True,
"downloadRole": True,
"uploadRole": False,
"playlistRole": True,
"coverArtRole": False,
"commentRole": False,
"podcastRole": False,
"streamRole": True,
"jukeboxRole": self.admin or self.jukebox,
"shareRole": False,
}
class ClientPrefs(_Model):
user = ForeignKeyField(User, backref="clients")
client_name = CharField(32)
format = CharField(8, null=True)
bitrate = IntegerField(null=True)
class Meta:
primary_key = CompositeKey("user", "client_name")
def _make_starred_model(target_model):
class Starred(_Model):
user = ForeignKeyField(User, backref="+")
starred = ForeignKeyField(target_model, backref="+")
date = DateTimeField(default=now)
class Meta:
primary_key = CompositeKey("user", "starred")
table_name = "starred_" + target_model._meta.table_name
return Starred
StarredFolder = _make_starred_model(Folder)
StarredArtist = _make_starred_model(Artist)
StarredAlbum = _make_starred_model(Album)
StarredTrack = _make_starred_model(Track)
def _make_rating_model(target_model):
class Rating(_Model):
user = ForeignKeyField(User, backref="+")
rated = ForeignKeyField(target_model, backref="+")
rating = IntegerField() # min=1, max=5
class Meta:
primary_key = CompositeKey("user", "rated")
table_name = "rating_" + target_model._meta.table_name
return Rating
RatingFolder = _make_rating_model(Folder)
RatingTrack = _make_rating_model(Track)
class ChatMessage(_Model):
id = PrimaryKeyField()
user = ForeignKeyField(User, backref="+")
time = IntegerField(default=lambda: int(time.time()))
message = CharField(512)
def responsize(self):
return {
"username": self.user.name,
"time": self.time * 1000,
"message": self.message,
}
class Playlist(_Model):
id = PrimaryKeyField()
user = ForeignKeyField(User, backref="playlists")
name = CharField()
comment = CharField(null=True)
public = BooleanField(default=False)
created = DateTimeField(default=now)
tracks = TextField(null=True)
def as_subsonic_playlist(self, user):
tracks = self.get_tracks()
info = {
"id": str(self.id),
"name": (
self.name
if self.user.id == user.id
else f"[{self.user.name}] {self.name}"
),
"owner": self.user.name,
"public": self.public,
"songCount": len(tracks),
"duration": sum(t.duration for t in tracks),
"created": self.created.isoformat(),
}
if self.comment:
info["comment"] = self.comment
return info
def get_tracks(self):
if not self.tracks:
return []
tracks = []
should_fix = False
for t in self.tracks.split(","):
try:
tid = UUID(t)
track = Track[tid]
tracks.append(track)
except (ValueError, Track.DoesNotExist):
should_fix = True
if should_fix:
self.tracks = ",".join(str(t.id) for t in tracks)
db.commit()
return tracks
def clear(self):
self.tracks = ""
def add(self, track):
if isinstance(track, UUID):
tid = track
elif isinstance(track, Track):
tid = track.id
elif isinstance(track, str):
tid = UUID(track)
if self.tracks and len(self.tracks) > 0:
self.tracks = f"{self.tracks},{tid}"
else:
self.tracks = str(tid)
def remove_at_indexes(self, indexes):
tracks = self.tracks.split(",")
for i in indexes:
if i < 0 or i >= len(tracks):
continue
tracks[i] = None
self.tracks = ",".join(t for t in tracks if t)
class RadioStation(_Model):
id = PrimaryKeyField()
stream_url = CharField()
name = CharField()
homepage_url = CharField(null=True)
created = DateTimeField(default=now)
def as_subsonic_station(self):
info = {
"id": str(self.id),
"streamUrl": self.stream_url,
"name": self.name,
"homePageUrl": self.homepage_url,
}
return info
if sys.version_info < (3, 9):
import pkg_resources
def get_resource_text(respath):
return pkg_resources.resource_string(__package__, respath).decode("utf-8")
def list_migrations(provider):
return pkg_resources.resource_listdir(
__package__, f"schema/migration/{provider}"
)
else:
import importlib.resources
def get_resource_text(respath):
return (
importlib.resources.files(__package__).joinpath(respath).read_text("utf-8")
)
def list_migrations(provider):
return (
e.name
for e in importlib.resources.files(__package__)
.joinpath(f"schema/migration/{provider}")
.iterdir()
)
def execute_sql_resource_script(respath):
sql = get_resource_text(respath)
for statement in sql.split(";"):
statement = statement.strip()
if statement and not statement.startswith("--"):
db.execute_sql(statement)
def init_database(database_uri):
uri = urlparse(database_uri)
args = parseresult_to_dict(uri)
if uri.scheme.startswith("mysql"):
args.setdefault("charset", "utf8mb4")
args.setdefault("binary_prefix", True)
if uri.scheme.startswith("mysql"):
provider = "mysql"
elif uri.scheme.startswith("postgres"):
provider = "postgres"
elif uri.scheme.startswith("sqlite"):
provider = "sqlite"
args["pragmas"] = {"foreign_keys": 1}
else:
raise RuntimeError(f"Unsupported database: {uri.scheme}")
db_class = schemes.get(uri.scheme)
db.initialize(db_class(**args))
db.connect()
# Check if we should create the tables
if not db.table_exists("meta"):
with db.atomic():
execute_sql_resource_script(f"schema/{provider}.sql")
Meta.create(key="schema_version", value=SCHEMA_VERSION)
# Check for schema changes
version = Meta["schema_version"]
if version.value < SCHEMA_VERSION:
args.pop("pragmas", ())
migrations = sorted(list_migrations(provider))
for migration in migrations:
if migration[0] in ("_", "."):
continue
date, ext = os.path.splitext(migration)
if date <= version.value:
continue
if ext == ".sql":
with db.atomic():
execute_sql_resource_script(
f"schema/migration/{provider}/{migration}"
)
elif ext == ".py":
m = importlib.import_module(
f".schema.migration.{provider}.{date}", __package__
)
m.apply(args.copy())
version.value = SCHEMA_VERSION
version.save()
def release_database():
db.close()
db.initialize(None)
def open_connection(reuse=False):
return db.connect(reuse)
def close_connection():
db.close()