mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
fix: folder endpoint returning same track for different files of the same trackhash
+ fix: chunked streaming. return instead of yield chunks
This commit is contained in:
@@ -49,4 +49,4 @@
|
||||
- Duplicates on search
|
||||
- Audio stops on ending
|
||||
- Show users on account settings when logged in as admin and show users on login is disabled.
|
||||
-
|
||||
- Save both filepath and trackhash in favorites and playlists
|
||||
+3
-3
@@ -108,14 +108,14 @@ class PairDeviceQuery(BaseModel):
|
||||
code: str = Field("", description="The code")
|
||||
|
||||
|
||||
@api.post("/pair")
|
||||
@api.get("/pair")
|
||||
@jwt_required(optional=True)
|
||||
def pair_with_code(body: PairDeviceQuery):
|
||||
def pair_with_code(query: PairDeviceQuery):
|
||||
"""
|
||||
Get an access token by sending a pair code. NOTE: A code can only be used once!
|
||||
"""
|
||||
global pair_token
|
||||
token = pair_token.get(body.code, None)
|
||||
token = pair_token.get(query.code, None)
|
||||
|
||||
if token:
|
||||
pair_token = {}
|
||||
|
||||
+171
-15
@@ -3,12 +3,16 @@ Contains all the track routes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
from flask import send_file, request, Response
|
||||
from flask_openapi3 import APIBlueprint, Tag
|
||||
from pydantic import BaseModel, Field
|
||||
from app.api.apischemas import TrackHashSchema
|
||||
from app.lib.trackslib import get_silence_paddings
|
||||
from app.lib.transcoder import start_transcoding
|
||||
|
||||
from app.store.tracks import TrackStore
|
||||
from app.utils.files import guess_mime_type
|
||||
@@ -17,10 +21,36 @@ bp_tag = Tag(name="File", description="Audio files")
|
||||
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
|
||||
|
||||
|
||||
class TransCodeStore:
|
||||
map: dict[str, str] = {}
|
||||
|
||||
@classmethod
|
||||
def add_file(cls, trackhash: str, filepath: str):
|
||||
cls.map[trackhash] = filepath
|
||||
|
||||
@classmethod
|
||||
def remove_file(cls, trackhash: str):
|
||||
del cls.map[trackhash]
|
||||
|
||||
@classmethod
|
||||
def find(cls, trackhash: str):
|
||||
return cls.map.get(trackhash)
|
||||
|
||||
|
||||
class SendTrackFileQuery(BaseModel):
|
||||
filepath: str = Field(
|
||||
description="The filepath to play (if available)", default=None
|
||||
)
|
||||
quality: Literal["original", "1411", "800", "600", "320", "256", "128", "96"] = (
|
||||
Field(
|
||||
"320",
|
||||
description="The quality of the audio file. Options: original, 1411, 1024, 512, 320, 256, 128, 96",
|
||||
)
|
||||
)
|
||||
container: Literal["mp3", "aac", "flac", "webm", "ogg"] = Field(
|
||||
"flac",
|
||||
description="The container format of the audio file. Options: mp3, aac, flac, webm, ogg",
|
||||
)
|
||||
|
||||
|
||||
@api.get("/<trackhash>/legacy")
|
||||
@@ -29,6 +59,8 @@ def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
Get a playable audio file without Range support
|
||||
|
||||
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
|
||||
|
||||
NOTE: Does not support range requests or transcoding.
|
||||
"""
|
||||
trackhash = path.trackhash
|
||||
filepath = query.filepath
|
||||
@@ -37,7 +69,6 @@ def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
track = None
|
||||
tracks = TrackStore.get_tracks_by_filepaths([filepath])
|
||||
|
||||
|
||||
if len(tracks) > 0 and os.path.exists(filepath):
|
||||
track = tracks[0]
|
||||
else:
|
||||
@@ -66,10 +97,17 @@ def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
Get a playable audio file with Range headers support
|
||||
|
||||
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
|
||||
|
||||
Transcoding can be done by sending the quality and container query parameters.
|
||||
|
||||
**NOTES:**
|
||||
- Transcoded streams report incorrect duration during playback (idk why! FFMPEG gurus we need your help here).
|
||||
- The quality parameter is the desired bitrate in kbps.
|
||||
- The mp3 container is the best container for upto 320kbps (and has better duration reporting). The flac container allows for higher bitrates but it produces dramatically larger files (when transcoding from lossy formats).
|
||||
- You can get the transcoded bitrate by checking the X-Transcoded-Bitrate header on the first request's response.
|
||||
"""
|
||||
trackhash = path.trackhash
|
||||
filepath = query.filepath
|
||||
msg = {"msg": "File Not Found"}
|
||||
|
||||
# If filepath is provided, try to send that
|
||||
track = None
|
||||
@@ -91,13 +129,87 @@ def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
|
||||
break
|
||||
|
||||
if track is not None:
|
||||
audio_type = guess_mime_type(filepath)
|
||||
return send_file_as_chunks(track.filepath, audio_type)
|
||||
if query.quality == "original":
|
||||
return send_file_as_chunks(track.filepath)
|
||||
|
||||
return msg, 404
|
||||
# prevent requesting over transcoding
|
||||
max_bitrate = track.bitrate
|
||||
requested_bitrate = int(query.quality)
|
||||
|
||||
if query.container != "flac":
|
||||
# drop to 320 for non-flac containers
|
||||
requested_bitrate = min(320, requested_bitrate)
|
||||
|
||||
quality = f"{min(max_bitrate, requested_bitrate)}k"
|
||||
return transcode_and_stream(trackhash, track.filepath, quality, query.container)
|
||||
|
||||
return {"msg": "File Not Found"}, 404
|
||||
|
||||
|
||||
def send_file_as_chunks(filepath: str, audio_type: str) -> Response:
|
||||
def transcode_and_stream(trackhash: str, filepath: str, bitrate: str, container: str):
|
||||
"""
|
||||
Initiates transcoding and returns the first chunk of the transcoded file.
|
||||
|
||||
The other chunks are streamed on subsequent requests and are rerouted to `send_file_as_chunks`.
|
||||
"""
|
||||
temp_file = TransCodeStore.find(trackhash)
|
||||
if temp_file is not None:
|
||||
return send_file_as_chunks(temp_file)
|
||||
|
||||
format_params = {
|
||||
"mp3": ["-c:a", "libmp3lame"],
|
||||
"aac": ["-c:a", "aac"],
|
||||
"webm": ["-c:a", "libopus"],
|
||||
"ogg": ["-c:a", "libvorbis"],
|
||||
"flac": ["-c:a", "flac"],
|
||||
"wav": ["-c:a", "pcm_s16le"],
|
||||
}
|
||||
|
||||
# Create a temporary file
|
||||
format = f".{container}" if container in format_params.keys() else ".flac"
|
||||
container_args = (
|
||||
format_params[container]
|
||||
if container in format_params.keys()
|
||||
else format_params["flac"]
|
||||
)
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=format)
|
||||
temp_filename = temp_file.name
|
||||
temp_file.close()
|
||||
|
||||
TransCodeStore.add_file(trackhash, temp_filename)
|
||||
start_transcoding(filepath, temp_filename, bitrate, container_args)
|
||||
|
||||
chunk_size = 1024 * 512 # 0.5MB
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
def generate():
|
||||
# Poll for the output file
|
||||
while (
|
||||
not os.path.exists(temp_filename)
|
||||
or os.path.getsize(temp_filename) < chunk_size
|
||||
):
|
||||
print(f"Waiting for transcoding to complete... filename: {temp_filename}")
|
||||
time.sleep(0.1) # Wait for 100ms before checking again
|
||||
|
||||
with open(temp_filename, "rb") as file:
|
||||
file.seek(0)
|
||||
return file.read(chunk_size)
|
||||
|
||||
audio_type = guess_mime_type(temp_filename)
|
||||
response = Response(
|
||||
generate(),
|
||||
206,
|
||||
mimetype=audio_type,
|
||||
content_type=audio_type,
|
||||
direct_passthrough=True,
|
||||
)
|
||||
response.headers.add("Content-Range", f"bytes {0}-{chunk_size}/{file_size}")
|
||||
response.headers.add("Accept-Ranges", "bytes")
|
||||
response.headers.add("X-Transcoded-Bitrate", bitrate)
|
||||
return response
|
||||
|
||||
|
||||
def send_file_as_chunks(filepath: str) -> Response:
|
||||
"""
|
||||
Returns a Response object that streams the file in chunks.
|
||||
"""
|
||||
@@ -129,25 +241,69 @@ def send_file_as_chunks(filepath: str, audio_type: str) -> Response:
|
||||
file.seek(start)
|
||||
remaining_bytes = end - start + 1
|
||||
|
||||
while remaining_bytes > 0:
|
||||
retry_count = 0
|
||||
max_retries = 10 # 5 * 100ms = 500ms total wait time
|
||||
|
||||
while remaining_bytes > 0 or retry_count < max_retries:
|
||||
if retry_count == max_retries:
|
||||
print("💚 sending final chunk! ...")
|
||||
return (
|
||||
file.read(os.path.getsize(filepath) - file.tell()),
|
||||
file.tell(),
|
||||
True,
|
||||
)
|
||||
print("\n\n")
|
||||
print(f"file: {filepath}")
|
||||
print(f"start: {start}")
|
||||
print(f"end: {end}")
|
||||
print(f"filesize: {os.path.getsize(filepath)}")
|
||||
print(f"⭐ (O) Remaining bytes: {remaining_bytes}")
|
||||
print(f"⭐ Remaining bytes: {remaining_bytes}")
|
||||
print(f"⭐ Cursor position: {file.tell()}")
|
||||
# Read the chunk size or all the remaining bytes
|
||||
|
||||
print(f"💚 remaining_bytes: {remaining_bytes}")
|
||||
print(f"💚 retry_count: {retry_count}")
|
||||
|
||||
if remaining_bytes < chunk_size:
|
||||
time.sleep(0.25)
|
||||
retry_count += 1
|
||||
remaining_bytes = os.path.getsize(filepath) - file.tell()
|
||||
continue
|
||||
|
||||
chunk = file.read(min(chunk_size, remaining_bytes))
|
||||
yield chunk
|
||||
|
||||
# Update the remaining bytes
|
||||
if chunk:
|
||||
remaining_bytes -= len(chunk)
|
||||
return chunk, file.tell(), False
|
||||
else:
|
||||
# If no data is read, wait for 100ms before retrying
|
||||
time.sleep(0.25)
|
||||
retry_count += 1
|
||||
|
||||
# update remaining bytes
|
||||
remaining_bytes = os.path.getsize(filepath) - file.tell()
|
||||
print(f"▶ Remaining bytes: {remaining_bytes}")
|
||||
|
||||
return None, 0, True
|
||||
|
||||
data, position, is_final = generate_chunks()
|
||||
|
||||
audio_type = guess_mime_type(filepath)
|
||||
response = Response(
|
||||
generate_chunks(),
|
||||
206, # Partial Content status code
|
||||
response=data,
|
||||
status=206, # Partial Content status code
|
||||
mimetype=audio_type,
|
||||
content_type=audio_type,
|
||||
direct_passthrough=True,
|
||||
)
|
||||
response.headers.add("Content-Range", f"bytes {start}-{end}/{file_size}")
|
||||
response.headers.add("Accept-Ranges", "bytes")
|
||||
response.headers.add("Content-Length", str(end - start + 1))
|
||||
|
||||
bytes_to_add = chunk_size if not is_final else 0
|
||||
response.headers.add(
|
||||
"Content-Range",
|
||||
f"bytes {start}-{position}/{os.path.getsize(filepath) + bytes_to_add}",
|
||||
)
|
||||
response.headers.add("Accept-Ranges", "bytes")
|
||||
response.headers.add("Content-Length", str(len(data or [])))
|
||||
return response
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
@@ -409,7 +409,7 @@ class LibDataTable(Base):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_one(cls, hash: str, type: str):
|
||||
def find_one(cls, hash: str, type: Literal["album", "artist"]):
|
||||
result = cls.execute(
|
||||
select(cls).where((cls.itemhash == hash) & (cls.itemtype == type))
|
||||
)
|
||||
|
||||
+45
-5
@@ -8,11 +8,14 @@ from app.models.album import Album
|
||||
from app.models.artist import Artist
|
||||
from app.models.track import Track
|
||||
from app.store.folder import FolderStore
|
||||
from app.store.tracks import TrackStore
|
||||
from app.utils import flatten
|
||||
from app.utils.filesystem import run_fast_scandir
|
||||
from app.utils.parsers import get_base_album_title
|
||||
from app.utils.progressbar import tqdm
|
||||
|
||||
from app.logger import log
|
||||
from app.utils.remove_duplicates import remove_duplicates
|
||||
|
||||
POPULATE_KEY: float = 0
|
||||
|
||||
@@ -136,9 +139,27 @@ class IndexTracks:
|
||||
print("Done")
|
||||
|
||||
|
||||
def create_albums():
|
||||
def create_albums(_trackhashes: list[str] = []) -> list[tuple[Album, set[str]]]:
|
||||
"""
|
||||
Creates album objects using the indexed tracks. Takes in an optional
|
||||
list of trackhashes to create the albums from. If no list is provided,
|
||||
all tracks are used.
|
||||
|
||||
The trackhashes are passed when creating albums from the watchdogg module.
|
||||
|
||||
Returns a list of tuples containing the album and the trackhashes in the album.
|
||||
ie:
|
||||
|
||||
>>> list[tuple[Album, set[str]]]
|
||||
"""
|
||||
albums = dict()
|
||||
all_tracks: list[Track] = TrackTable.get_all()
|
||||
|
||||
if _trackhashes:
|
||||
all_tracks: list[Track] = TrackStore.get_tracks_by_trackhashes(_trackhashes)
|
||||
else:
|
||||
all_tracks: list[Track] = TrackStore.get_flat_list()
|
||||
|
||||
all_tracks = remove_duplicates(all_tracks)
|
||||
|
||||
for track in all_tracks:
|
||||
if track.albumhash not in albums:
|
||||
@@ -192,9 +213,28 @@ def create_albums():
|
||||
return list(albums.values())
|
||||
|
||||
|
||||
# class IndexArtists:
|
||||
def create_artists():
|
||||
all_tracks: list[Track] = TrackTable.get_all()
|
||||
def create_artists(
|
||||
artisthashes: list[str] = [],
|
||||
) -> list[tuple[Artist, set[str], set[str]]]:
|
||||
"""
|
||||
Creates artist objects using the indexed tracks. Takes in an optional
|
||||
list of artisthashes to create the artists from. If no list is provided,
|
||||
all tracks are used.
|
||||
|
||||
Returns a list of tuples containing the artist, the trackhashes for the artist
|
||||
and the albumhashes for the artist.
|
||||
ie:
|
||||
|
||||
>>> list[tuple[Artist, set[str], set[str]]]
|
||||
"""
|
||||
if artisthashes:
|
||||
all_tracks: list[Track] = flatten(
|
||||
[TrackStore.get_tracks_by_artisthash(hash) for hash in artisthashes]
|
||||
)
|
||||
else:
|
||||
all_tracks: list[Track] = TrackStore.get_flat_list()
|
||||
|
||||
all_tracks = remove_duplicates(all_tracks)
|
||||
artists = dict()
|
||||
|
||||
for track in all_tracks:
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -206,7 +207,7 @@ def get_tags(filepath: str, config: UserConfig):
|
||||
to_round = ["bitrate", "duration"]
|
||||
for prop in to_round:
|
||||
try:
|
||||
setattr(tags, prop, round(getattr(tags, prop)))
|
||||
setattr(tags, prop, math.floor(getattr(tags, prop)))
|
||||
except TypeError:
|
||||
setattr(tags, prop, 0)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from app.utils.threading import background
|
||||
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
@background
|
||||
def start_transcoding(
|
||||
input_path: str, output_path: str, bitrate: str, container_args: list[str], compression_level: int = 12
|
||||
):
|
||||
"""
|
||||
Starts a background transcoding process for an audio file.
|
||||
|
||||
This function uses FFmpeg to transcode an audio file from one format to another,
|
||||
with specified bitrate and container format. It runs as a background task.
|
||||
|
||||
Args:
|
||||
input_path (str): The path to the input audio file.
|
||||
output_path (str): The path where the transcoded file will be saved.
|
||||
bitrate (str): The desired bitrate for the output file (e.g., "128k").
|
||||
container_args (list[str]): FFmpeg arguments specific to the output container format.
|
||||
compression_level (int): Compression level (0-9, default: 6).
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Note:
|
||||
This function is decorated with @background, which means it runs asynchronously.
|
||||
The actual transcoding process is handled by FFmpeg in a subprocess.
|
||||
The function will print status messages about the transcoding process.
|
||||
"""
|
||||
# Base command
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
input_path,
|
||||
"-map_metadata", "0", # Add this line to copy metadata
|
||||
"-b:a",
|
||||
bitrate,
|
||||
"-vn",
|
||||
"-compression_level",
|
||||
str(compression_level),
|
||||
# REVIEW: Idk what any flag below this point does!
|
||||
"-movflags",
|
||||
"faststart+frag_keyframe+empty_moov",
|
||||
"-write_xing",
|
||||
"0",
|
||||
"-fflags",
|
||||
"+bitexact",
|
||||
]
|
||||
|
||||
# Add format-specific parameters
|
||||
command.extend(container_args)
|
||||
|
||||
# Add output path and overwrite flag
|
||||
command.extend([output_path, "-y"])
|
||||
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
print(f"Started transcoding process with PID: {process.pid}")
|
||||
|
||||
try:
|
||||
# Wait for the process to complete
|
||||
process.wait()
|
||||
print(f"Transcoding process (PID: {process.pid}) completed")
|
||||
except KeyboardInterrupt:
|
||||
print(f"Transcoding interrupted. Terminating process (PID: {process.pid})")
|
||||
finally:
|
||||
# Ensure the process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=5) # Wait up to 5 seconds for graceful termination
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
f"Process (PID: {process.pid}) did not terminate gracefully. Killing..."
|
||||
)
|
||||
process.kill()
|
||||
+62
-33
@@ -8,17 +8,20 @@ import sqlite3
|
||||
import time
|
||||
|
||||
from watchdog.events import PatternMatchingEventHandler
|
||||
from watchdog.observers.api import BaseObserverSubclassCallable
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from app import settings
|
||||
from app.config import UserConfig
|
||||
from app.db.sqlite.tracks import SQLiteManager
|
||||
from app.db.libdata import TrackTable
|
||||
from app.db.userdata import LibDataTable
|
||||
from app.lib.colorlib import process_color
|
||||
from app.lib.tagger import create_albums, create_artists
|
||||
from app.lib.taglib import extract_thumb, get_tags
|
||||
from app.logger import log
|
||||
from app.models import Artist, Track
|
||||
from app.store.albums import AlbumStore
|
||||
from app.store.artists import ArtistStore
|
||||
from app.store.artists import ArtistMapEntry, ArtistStore
|
||||
from app.store.tracks import TrackStore
|
||||
|
||||
|
||||
@@ -27,7 +30,7 @@ class Watcher:
|
||||
Contains the methods for initializing and starting watchdog.
|
||||
"""
|
||||
|
||||
observers: list[Observer] = []
|
||||
observers: list[BaseObserverSubclassCallable] = []
|
||||
|
||||
def __init__(self):
|
||||
self.observer = Observer()
|
||||
@@ -126,10 +129,10 @@ class Watcher:
|
||||
self.run()
|
||||
|
||||
|
||||
def handle_colors(cur: sqlite3.Cursor, albumhash: str):
|
||||
exists = aldb.exists(albumhash, cur)
|
||||
def handle_color(albumhash: str):
|
||||
entry = LibDataTable.find_one(albumhash, "album")
|
||||
|
||||
if exists:
|
||||
if entry and entry.color:
|
||||
return
|
||||
|
||||
colors = process_color(albumhash, is_album=True)
|
||||
@@ -137,7 +140,12 @@ def handle_colors(cur: sqlite3.Cursor, albumhash: str):
|
||||
if colors is None:
|
||||
return
|
||||
|
||||
aldb.insert_one_album(cur=cur, albumhash=albumhash, colors=json.dumps(colors))
|
||||
if entry is None:
|
||||
LibDataTable.insert_one(
|
||||
{"itemhash": albumhash, "color": colors[0], "itemtype": "album"}
|
||||
)
|
||||
else:
|
||||
LibDataTable.update_one(albumhash, {"color": colors[0]})
|
||||
|
||||
return colors
|
||||
|
||||
@@ -152,38 +160,42 @@ def add_track(filepath: str) -> None:
|
||||
TrackStore.remove_track_by_filepath(filepath)
|
||||
|
||||
config = UserConfig()
|
||||
tags = get_tags(filepath, artist_separators=config.artistSeparators)
|
||||
tags = get_tags(filepath, config)
|
||||
|
||||
# if the track is somehow invalid, return
|
||||
if tags is None or tags["bitrate"] == 0 or tags["duration"] == 0:
|
||||
return
|
||||
|
||||
colors = None
|
||||
|
||||
with SQLiteManager() as cur:
|
||||
db.insert_one_track(tags, cur)
|
||||
extracted = extract_thumb(filepath, tags["albumhash"] + ".webp")
|
||||
|
||||
if not extracted:
|
||||
return
|
||||
|
||||
colors = handle_colors(cur, tags["albumhash"])
|
||||
TrackTable.insert_one(tags)
|
||||
extract_thumb(filepath, tags["albumhash"] + ".webp", overwrite=True)
|
||||
|
||||
colors = handle_color(tags["albumhash"])
|
||||
track = Track(**tags)
|
||||
TrackStore.add_track(track)
|
||||
|
||||
if not AlbumStore.album_exists(track.albumhash):
|
||||
album = AlbumStore.create_album(track)
|
||||
album.set_colors(colors)
|
||||
AlbumStore.add_album(album)
|
||||
# SECTION: Index album
|
||||
albumentry = AlbumStore.albummap.get(track.albumhash)
|
||||
|
||||
artists: list[Artist] = track.artists + track.albumartists # type: ignore
|
||||
if albumentry is None:
|
||||
album, trackhashes = create_albums([track.trackhash])[0]
|
||||
AlbumStore.index_new_album(album, trackhashes)
|
||||
else:
|
||||
trackhash_exists = track.trackhash in albumentry.trackhashes
|
||||
|
||||
if not trackhash_exists:
|
||||
albumentry.trackhashes.add(track.trackhash)
|
||||
albumentry.album.trackcount += 1
|
||||
albumentry.set_color(colors[0]) if colors else None
|
||||
|
||||
# SECTION: Index artist
|
||||
artists = create_artists(track.artisthashes)
|
||||
|
||||
for artist in artists:
|
||||
if not ArtistStore.artist_exists(artist.artisthash):
|
||||
ArtistStore.add_artist(Artist(artist.name))
|
||||
|
||||
extract_thumb(filepath, track.image, overwrite=True)
|
||||
ArtistStore.artistmap[artist[0].artisthash] = ArtistMapEntry(
|
||||
artist=artist[0],
|
||||
albumhashes=artist[1],
|
||||
trackhashes=artist[2],
|
||||
)
|
||||
|
||||
|
||||
def remove_track(filepath: str) -> None:
|
||||
@@ -287,16 +299,33 @@ class Handler(PatternMatchingEventHandler):
|
||||
NOT FIRED IN WINDOWS
|
||||
"""
|
||||
try:
|
||||
self.files_to_process.remove(event.src_path)
|
||||
if os.path.getsize(event.src_path) > 0:
|
||||
# Get initial file size
|
||||
initial_size = os.path.getsize(event.src_path)
|
||||
|
||||
# Wait for 10 seconds
|
||||
time.sleep(10)
|
||||
|
||||
# Check if file size has changed
|
||||
current_size = os.path.getsize(event.src_path)
|
||||
|
||||
if current_size > 0 and current_size == initial_size:
|
||||
path = self.get_abs_path(event.src_path)
|
||||
add_track(path)
|
||||
# Remove from processing list only after successful processing
|
||||
self.files_to_process.remove(event.src_path)
|
||||
else:
|
||||
# File is still being modified or has been deleted
|
||||
log.info(
|
||||
f"File {event.src_path} is still being modified. Skipping processing for now."
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# file was closed and deleted.
|
||||
pass
|
||||
log.info(f"File {event.src_path} was closed and deleted before processing.")
|
||||
except ValueError:
|
||||
# file was removed from the list by another event handler.
|
||||
pass
|
||||
# file was already removed from the list by another event handler.
|
||||
log.info(
|
||||
f"File {event.src_path} was already removed from the processing list."
|
||||
)
|
||||
|
||||
def on_modified(self, event):
|
||||
# this event handler is triggered twice on windows
|
||||
@@ -317,7 +346,7 @@ class Handler(PatternMatchingEventHandler):
|
||||
|
||||
if current_size == previous_size:
|
||||
# Wait for a short duration to ensure the file write operation is complete
|
||||
time.sleep(5)
|
||||
time.sleep(10)
|
||||
|
||||
# Check the file size again
|
||||
try:
|
||||
|
||||
+27
-3
@@ -57,8 +57,10 @@ class Track:
|
||||
|
||||
def toggle_favorite_user(self, userid: int):
|
||||
"""
|
||||
Adds or removes the given user from the list of users
|
||||
who have favorited the track.
|
||||
Toggles the favorite status of the track for a given user.
|
||||
|
||||
Args:
|
||||
userid (int): The ID of the user toggling the favorite status.
|
||||
"""
|
||||
if userid in self.fav_userids:
|
||||
self.fav_userids.remove(userid)
|
||||
@@ -66,6 +68,11 @@ class Track:
|
||||
self.fav_userids.append(userid)
|
||||
|
||||
def __post_init__(self):
|
||||
"""
|
||||
Performs post-initialization processing on the track object.
|
||||
This includes setting original values, processing artists and genres,
|
||||
and removing duplicate artists.
|
||||
"""
|
||||
self.og_title = self.title
|
||||
self.og_album = self.album
|
||||
|
||||
@@ -97,11 +104,13 @@ class Track:
|
||||
and not seen_albumartists.add(tuple(d.items()))
|
||||
]
|
||||
|
||||
self.recreate_trackhash()
|
||||
self.config = None
|
||||
|
||||
def split_artists(self):
|
||||
"""
|
||||
Splits the artists and albumartists based on the given separators, and updates the artisthashes.
|
||||
Splits the artists and albumartists based on the given separators,
|
||||
and updates the artisthashes.
|
||||
"""
|
||||
|
||||
def split(artists: str):
|
||||
@@ -115,6 +124,10 @@ class Track:
|
||||
self.artisthashes = [a["artisthash"] for a in self.artists]
|
||||
|
||||
def map_with_config(self):
|
||||
"""
|
||||
Applies various transformations to the track's title and album
|
||||
based on the user's configuration settings.
|
||||
"""
|
||||
new_title = self.title
|
||||
|
||||
# Extract featured artists
|
||||
@@ -156,6 +169,9 @@ class Track:
|
||||
)
|
||||
|
||||
def process_genres(self):
|
||||
"""
|
||||
Processes and standardizes the genre information for the track.
|
||||
"""
|
||||
if self.genres:
|
||||
src_genres: str = self.genres
|
||||
|
||||
@@ -181,3 +197,11 @@ class Track:
|
||||
for g in genres_list
|
||||
]
|
||||
self.genrehashes = [g["genrehash"] for g in self.genres]
|
||||
|
||||
def recreate_trackhash(self):
|
||||
"""
|
||||
Recreates the trackhash based on the current title, album, and artist information.
|
||||
"""
|
||||
self.trackhash = create_hash(
|
||||
self.title, self.album, *(artist["name"] for artist in self.artists)
|
||||
)
|
||||
|
||||
+23
-17
@@ -44,19 +44,19 @@ class AlbumMapEntry:
|
||||
|
||||
|
||||
class AlbumStore:
|
||||
albums: list[Album] = CustomList()
|
||||
# albums: list[Album] = CustomList()
|
||||
albummap: dict[str, AlbumMapEntry] = {}
|
||||
|
||||
@staticmethod
|
||||
def create_album(track: Track):
|
||||
"""
|
||||
Creates album object from a track
|
||||
"""
|
||||
return Album(
|
||||
albumhash=track.albumhash,
|
||||
albumartists=track.albumartists, # type: ignore
|
||||
title=track.og_album,
|
||||
)
|
||||
# @staticmethod
|
||||
# def create_album(track: Track):
|
||||
# """
|
||||
# Creates album object from a track
|
||||
# """
|
||||
# return Album(
|
||||
# albumhash=track.albumhash,
|
||||
# albumartists=track.albumartists, # type: ignore
|
||||
# title=track.og_album,
|
||||
# )
|
||||
|
||||
@classmethod
|
||||
def load_albums(cls, instance_key: str):
|
||||
@@ -74,6 +74,12 @@ class AlbumStore:
|
||||
}
|
||||
print("Done!")
|
||||
|
||||
@classmethod
|
||||
def index_new_album(cls, album: Album, trackhashes: set[str]):
|
||||
cls.albummap[album.albumhash] = AlbumMapEntry(
|
||||
album=album, trackhashes=trackhashes
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_flat_list(cls):
|
||||
"""
|
||||
@@ -141,12 +147,12 @@ class AlbumStore:
|
||||
master_string = "-".join(a.albumartists_hashes for a in cls.albums)
|
||||
return master_string.count(artisthash)
|
||||
|
||||
@classmethod
|
||||
def album_exists(cls, albumhash: str) -> bool:
|
||||
"""
|
||||
Checks if an album exists.
|
||||
"""
|
||||
return albumhash in "-".join([a.albumhash for a in cls.albums])
|
||||
# @classmethod
|
||||
# def album_exists(cls, albumhash: str) -> bool:
|
||||
# """
|
||||
# Checks if an album exists.
|
||||
# """
|
||||
# return albumhash in "-".join([a.albumhash for a in cls.albums])
|
||||
|
||||
@classmethod
|
||||
def remove_album(cls, album: Album):
|
||||
|
||||
+51
-52
@@ -34,11 +34,10 @@ class ArtistMapEntry:
|
||||
|
||||
|
||||
class ArtistStore:
|
||||
artists: list[Artist] = CustomList()
|
||||
artistmap: dict[str, ArtistMapEntry] = {}
|
||||
|
||||
@classmethod
|
||||
def load_artists(cls, instance_key: str):
|
||||
def load_artists(cls, instance_key: str, _trackhashes: list[str] = []):
|
||||
"""
|
||||
Loads all artists from the database into the store.
|
||||
"""
|
||||
@@ -52,7 +51,7 @@ class ArtistStore:
|
||||
artist.artisthash: ArtistMapEntry(
|
||||
artist=artist, albumhashes=albumhashes, trackhashes=trackhashes
|
||||
)
|
||||
for artist, trackhashes, albumhashes in create_artists()
|
||||
for artist, trackhashes, albumhashes in create_artists(_trackhashes)
|
||||
}
|
||||
|
||||
# for track in TrackStore.get_flat_list():
|
||||
@@ -77,35 +76,35 @@ class ArtistStore:
|
||||
"""
|
||||
return [a.artist for a in cls.artistmap.values()]
|
||||
|
||||
@classmethod
|
||||
def map_artist_color(cls, artist_tuple: tuple):
|
||||
"""
|
||||
Maps a color to the corresponding artist.
|
||||
"""
|
||||
# @classmethod
|
||||
# def map_artist_color(cls, artist_tuple: tuple):
|
||||
# """
|
||||
# Maps a color to the corresponding artist.
|
||||
# """
|
||||
|
||||
artisthash = artist_tuple[1]
|
||||
color = json.loads(artist_tuple[2])
|
||||
# artisthash = artist_tuple[1]
|
||||
# color = json.loads(artist_tuple[2])
|
||||
|
||||
for artist in cls.artists:
|
||||
if artist.artisthash == artisthash:
|
||||
artist.set_colors(color)
|
||||
break
|
||||
# for artist in cls.artists:
|
||||
# if artist.artisthash == artisthash:
|
||||
# artist.set_colors(color)
|
||||
# break
|
||||
|
||||
@classmethod
|
||||
def add_artist(cls, artist: Artist):
|
||||
"""
|
||||
Adds an artist to the store.
|
||||
"""
|
||||
cls.artists.append(artist)
|
||||
# @classmethod
|
||||
# def add_artist(cls, artist: Artist):
|
||||
# """
|
||||
# Adds an artist to the store.
|
||||
# """
|
||||
# cls.artists.append(artist)
|
||||
|
||||
@classmethod
|
||||
def add_artists(cls, artists: list[Artist]):
|
||||
"""
|
||||
Adds multiple artists to the store.
|
||||
"""
|
||||
for artist in artists:
|
||||
if artist not in cls.artists:
|
||||
cls.artists.append(artist)
|
||||
# @classmethod
|
||||
# def add_artists(cls, artists: list[Artist]):
|
||||
# """
|
||||
# Adds multiple artists to the store.
|
||||
# """
|
||||
# for artist in artists:
|
||||
# if artist not in cls.artists:
|
||||
# cls.artists.append(artist)
|
||||
|
||||
@classmethod
|
||||
def get_artist_by_hash(cls, artisthash: str):
|
||||
@@ -124,34 +123,34 @@ class ArtistStore:
|
||||
artists = [cls.get_artist_by_hash(hash) for hash in artisthashes]
|
||||
return [a for a in artists if a is not None]
|
||||
|
||||
@classmethod
|
||||
def artist_exists(cls, artisthash: str) -> bool:
|
||||
"""
|
||||
Checks if an artist exists.
|
||||
"""
|
||||
return artisthash in "-".join([a.artisthash for a in cls.artists])
|
||||
# @classmethod
|
||||
# def artist_exists(cls, artisthash: str) -> bool:
|
||||
# """
|
||||
# Checks if an artist exists.
|
||||
# """
|
||||
# return artisthash in "-".join([a.artisthash for a in cls.artists])
|
||||
|
||||
@classmethod
|
||||
def artist_has_tracks(cls, artisthash: str) -> bool:
|
||||
"""
|
||||
Checks if an artist has tracks.
|
||||
"""
|
||||
artists: set[str] = set()
|
||||
# @classmethod
|
||||
# def artist_has_tracks(cls, artisthash: str) -> bool:
|
||||
# """
|
||||
# Checks if an artist has tracks.
|
||||
# """
|
||||
# artists: set[str] = set()
|
||||
|
||||
for track in TrackStore.tracks:
|
||||
artists.update(track.artist_hashes)
|
||||
album_artists: list[str] = [a.artisthash for a in track.albumartists]
|
||||
artists.update(album_artists)
|
||||
# for track in TrackStore.tracks:
|
||||
# artists.update(track.artist_hashes)
|
||||
# album_artists: list[str] = [a.artisthash for a in track.albumartists]
|
||||
# artists.update(album_artists)
|
||||
|
||||
master_hash = "-".join(artists)
|
||||
return artisthash in master_hash
|
||||
# master_hash = "-".join(artists)
|
||||
# return artisthash in master_hash
|
||||
|
||||
@classmethod
|
||||
def remove_artist_by_hash(cls, artisthash: str):
|
||||
"""
|
||||
Removes an artist from the store.
|
||||
"""
|
||||
cls.artists = CustomList(a for a in cls.artists if a.artisthash != artisthash)
|
||||
# @classmethod
|
||||
# def remove_artist_by_hash(cls, artisthash: str):
|
||||
# """
|
||||
# Removes an artist from the store.
|
||||
# """
|
||||
# cls.artists = CustomList(a for a in cls.artists if a.artisthash != artisthash)
|
||||
|
||||
@classmethod
|
||||
def get_artist_tracks(cls, artisthash: str):
|
||||
|
||||
+7
-3
@@ -39,10 +39,14 @@ class FolderStore:
|
||||
trackhash = cls.map.get(filepath)
|
||||
|
||||
if trackhash:
|
||||
track = TrackStore.trackhashmap.get(trackhash)
|
||||
trackgroup = TrackStore.trackhashmap.get(trackhash)
|
||||
|
||||
if track:
|
||||
yield track.tracks[0]
|
||||
if trackgroup is None:
|
||||
continue
|
||||
|
||||
for track in trackgroup.tracks:
|
||||
if track.filepath == filepath:
|
||||
yield track
|
||||
|
||||
@classmethod
|
||||
def count_tracks_containing_paths(cls, paths: list[str]):
|
||||
|
||||
@@ -14,7 +14,8 @@ def format_number(number: float) -> str:
|
||||
return locale.format_string("%d", number, grouping=True)
|
||||
|
||||
|
||||
|
||||
|
||||
def flatten(list_: Iterable[list[T]]) -> list[T]:
|
||||
"""
|
||||
Flattens a list of lists into a single list.
|
||||
"""
|
||||
return [item for sublist in list_ for item in sublist]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
## Streaming
|
||||
|
||||
## Transcoding
|
||||
Generated
+29
-1
@@ -491,6 +491,23 @@ files = [
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-python"
|
||||
version = "0.2.0"
|
||||
description = "Python bindings for FFmpeg - with complex filtering support"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127"},
|
||||
{file = "ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
future = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["Sphinx (==2.1.0)", "future (==0.17.1)", "numpy (==1.16.4)", "pytest (==4.6.1)", "pytest-mock (==1.10.4)", "tox (==3.12.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "2.3.3"
|
||||
@@ -597,6 +614,17 @@ dotenv = ["python-dotenv"]
|
||||
email = ["email-validator"]
|
||||
yaml = ["pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "future"
|
||||
version = "1.0.0"
|
||||
description = "Clean single-source support for Python 3 and 2"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
files = [
|
||||
{file = "future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216"},
|
||||
{file = "future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gevent"
|
||||
version = "23.9.1"
|
||||
@@ -2733,4 +2761,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.10,<3.12"
|
||||
content-hash = "184c1c56051131473212b74de5719e2629c630555b13cd4cd2de371b3a2fb195"
|
||||
content-hash = "43972b6ffadd14e5047f067a0258f2428ebe351df8bd032dc0bf05df379678a6"
|
||||
|
||||
@@ -31,6 +31,7 @@ sqlalchemy = "^2.0.31"
|
||||
memory-profiler = "^0.61.0"
|
||||
sortedcontainers = "^2.4.0"
|
||||
xxhash = "^3.4.1"
|
||||
ffmpeg-python = "^0.2.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pylint = "^2.15.5"
|
||||
|
||||
Reference in New Issue
Block a user