1 Commits
Author SHA1 Message Date
chrisandClaude Opus 5.5 094f116b10 CI: test builds' publish step works under plain sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-22 12:01:20 -04:00
10 changed files with 17 additions and 117 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ jobs:
echo "Release: $SERVER/$REPO/releases/tag/$REF_NAME" echo "Release: $SERVER/$REPO/releases/tag/$REF_NAME"
else else
owner=${REPO%%/*} owner=${REPO%%/*}
version="$(echo "$REF_NAME" | tr -c 'A-Za-z0-9._-\n' '-')-${SHA::8}" version="$(printf '%s' "$REF_NAME" | tr -c 'A-Za-z0-9._-' '-')-$(printf '%s' "$SHA" | cut -c1-8)"
for f in *.apk SHA256SUMS; do for f in *.apk SHA256SUMS; do
curl -fsS -X PUT -H "$auth" --upload-file "$f" "$SERVER/api/packages/$owner/generic/musicshare-android/$version/$f" curl -fsS -X PUT -H "$auth" --upload-file "$f" "$SERVER/api/packages/$owner/generic/musicshare-android/$version/$f"
echo "Uploaded $f" echo "Uploaded $f"
-2
View File
@@ -10,5 +10,3 @@ next-env.d.ts
# Production secrets and database backups # Production secrets and database backups
deploy/production.env deploy/production.env
backups/ backups/
.aider*
deploy/gitea-runner/data/
+5 -17
View File
@@ -103,27 +103,15 @@ export async function toNotificationDTOs(rows: Row[]): Promise<NotificationDTO[]
} }
/** Newest first, 30 at a time; the cursor is the last one's time. */ /** Newest first, 30 at a time; the cursor is the last one's time. */
export async function listNotifications( export async function listNotifications(userId: string, cursor: string | null) {
userId: string,
cursor: string | null,
actorId?: string // Added optional actorId filter
): Promise<{
notifications: NotificationDTO[];
unread: number;
nextCursor: string | null;
}> {
const n = schema.notifications; const n = schema.notifications;
const before = cursor ? new Date(cursor) : null; const before = cursor ? new Date(cursor) : null;
const query = db const rows = await db
.select() .select()
.from(n) .from(n)
.where(and( .where(and(eq(n.userId, userId), before && !isNaN(before.getTime()) ? lt(n.createdAt, before) : undefined))
eq(n.userId, userId), .orderBy(desc(n.createdAt))
before && !isNaN(before.getTime()) ? lt(n.createdAt, before) : undefined, .limit(31);
actorId ? eq(n.actorId, actorId) : undefined // Apply actor filter if provided
));
const rows = await query.orderBy(desc(n.createdAt)).limit(31);
const page = rows.slice(0, 30); const page = rows.slice(0, 30);
return { return {
notifications: await toNotificationDTOs(page), notifications: await toNotificationDTOs(page),
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "musicshare", "name": "musicshare",
"slug": "musicshare", "slug": "musicshare",
"version": "1.5.0", "version": "1.4.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "musicshare", "scheme": "musicshare",
@@ -23,7 +23,7 @@
}, },
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"package": "tech.chrisedwards.musicshare", "package": "tech.chrisedwards.musicshare",
"versionCode": 6 "versionCode": 5
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
@@ -121,11 +121,9 @@ class MediaListenerService : NotificationListenerService() {
private fun snapshot(controller: MediaController): Playback? { private fun snapshot(controller: MediaController): Playback? {
val metadata = controller.metadata val metadata = controller.metadata
val title = metadata?.text(MediaMetadata.METADATA_KEY_TITLE) ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) val title = metadata?.text(MediaMetadata.METADATA_KEY_TITLE) ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
val realArtist = metadata?.text(MediaMetadata.METADATA_KEY_ARTIST) val artist = metadata?.text(MediaMetadata.METADATA_KEY_ARTIST)
?: metadata?.text(MediaMetadata.METADATA_KEY_ALBUM_ARTIST) ?: metadata?.text(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)
val artist = realArtist ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE) ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
// A phone video or other non-song: treat the session as if it weren't there.
if (NonMusicFilter.isNonMusic(controller.packageName, title, realArtist)) return null
val playbackState = controller.playbackState val playbackState = controller.playbackState
val state = when (playbackState?.state) { val state = when (playbackState?.state) {
PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING, PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING,
@@ -1,46 +0,0 @@
package tech.chrisedwards.musicshare.nowplaying
/**
* Keeps things that aren't songs (phone videos, screen recordings) out of what we share.
* General-purpose players report a local file's name as its title, so a video played
* in VLC would otherwise show up as "VID_20261025_181502.mp4".
*/
internal object NonMusicFilter {
/** Players that also open videos and arbitrary files: only share from them when there's a real artist. */
private val GENERAL_PLAYERS = setOf(
"org.videolan.vlc",
"org.videolan.vlc.debug",
"com.mxtech.videoplayer.ad",
"com.mxtech.videoplayer.pro",
"is.xyz.mpv",
"com.brouken.player", // Just Player
"com.google.android.apps.photos",
"com.sec.android.gallery3d",
"com.android.gallery3d",
"com.miui.gallery",
"com.google.android.apps.nbu.files",
"com.sec.android.app.myfiles",
)
private val FILE_NAME = Regex(
""".*\.(mp4|m4v|mkv|mov|webm|avi|3gp|3g2|wmv|flv|ts|mts|m2ts|mpg|mpeg)$""",
RegexOption.IGNORE_CASE,
)
/** Camera, screen-recorder and messaging-app naming schemes. */
private val RECORDING_NAME = Regex(
"""^(VID|PXL|IMG|MVIMG|MOV|DSC|GOPR|GX\d{2}|Screen[_ ]?Recording|Screenrecorder|Record|signal|WhatsApp Video)[-_ ]?\d.*""",
RegexOption.IGNORE_CASE,
)
/**
* [realArtist] is the artist/album-artist tag only, not the display-subtitle fallback,
* which players fill with folder names and the like.
*/
fun isNonMusic(packageName: String, title: String?, realArtist: String?): Boolean {
val name = title?.trim().orEmpty()
if (name.isEmpty()) return false
if (FILE_NAME.matches(name) || RECORDING_NAME.matches(name)) return true
return packageName in GENERAL_PLAYERS && realArtist.isNullOrBlank()
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@musicshare/mobile", "name": "@musicshare/mobile",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.5.0", "version": "1.4.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@expo-google-fonts/poppins": "^0.4.1", "@expo-google-fonts/poppins": "^0.4.1",
+4 -41
View File
@@ -8,68 +8,32 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { client, useMe } from '@/lib/api'; import { client, useMe } from '@/lib/api';
const ADMIN_ACTOR_USERNAME = 'seriouscritter';
/** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */ /** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */
export default function NotificationsPage() { export default function NotificationsPage() {
const { me, loggedOut } = useMe(); const { me, loggedOut } = useMe();
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
// Fetch all notifications for the user
const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>( const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>(
(i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]), (i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]),
([, cursor]: [string, string | null]) => client.notifications(cursor), ([, cursor]: [string, string | null]) => client.notifications(cursor),
{ revalidateOnFocus: false }, { revalidateOnFocus: false },
); );
const items = data?.flatMap((p) => p.notifications) ?? [];
const allItems = data?.flatMap((p) => p.notifications) ?? [];
const unread = data?.[0]?.unread ?? 0; const unread = data?.[0]?.unread ?? 0;
// Filter messages specifically from seriouscritter
const seriousCritterMessages = allItems.filter(n => n.actor?.username === ADMIN_ACTOR_USERNAME);
// Seen once you've looked: clear the bell (rows stay highlighted until you come back). // Seen once you've looked: clear the bell (rows stay highlighted until you come back).
useEffect(() => { useEffect(() => {
if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {}); if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {});
}, [unread, mutate]); }, [unread, mutate]);
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>; if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>;
return ( return (
<div className="stack"> <div className="stack">
<h1>Notifications</h1> <h1>Notifications</h1>
{items.length === 0 && !isLoading && (
{/* Dedicated section for seriouscritter's messages */}
{seriousCritterMessages.length > 0 && (
<div className="admin-section">
<h2 className="muted">From {ADMIN_ACTOR_USERNAME}</h2>
<ul className="notifications">
{seriousCritterMessages.map((n) => {
const { title, body } = notificationText(n);
return (
<li key={n.id} className={n.read ? '' : 'unread'}>
<Link href={notificationPath(n)} className="notification">
<Avatar user={n.actor} size={40} />
<span className="notification-text">
<strong>{title}</strong>
<span>{body</span >
<span className="muted">{timeAgo(n.createdAt)}</span>
</span>
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
</Link>
</li>
);
})}
</ul>
</div>
)}
{/* General notifications list */}
{allItems.length === 0 && !isLoading && (
<p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p> <p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
)} )}
<ul className="notifications"> <ul className="notifications">
{allItems.map((n) => { {items.map((n) => {
const { title, body } = notificationText(n); const { title, body } = notificationText(n);
return ( return (
<li key={n.id} className={n.read ? '' : 'unread'}> <li key={n.id} className={n.read ? '' : 'unread'}>
@@ -77,7 +41,7 @@ export default function NotificationsPage() {
<Avatar user={n.actor} size={40} /> <Avatar user={n.actor} size={40} />
<span className="notification-text"> <span className="notification-text">
<strong>{title}</strong> <strong>{title}</strong>
<span>{body</span > <span>{body}</span>
<span className="muted">{timeAgo(n.createdAt)}</span> <span className="muted">{timeAgo(n.createdAt)}</span>
</span> </span>
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />} {n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
@@ -86,7 +50,6 @@ export default function NotificationsPage() {
); );
})} })}
</ul> </ul>
{data?.at(-1)?.nextCursor && ( {data?.at(-1)?.nextCursor && (
<button className="btn" onClick={() => setSize(size + 1)}> <button className="btn" onClick={() => setSize(size + 1)}>
Show more Show more
@@ -1 +0,0 @@
Videos and screen recordings played on your phone no longer show up as your song, and notifications now have their own section.
+1 -1
View File
@@ -809,7 +809,7 @@
}, },
"apps/mobile": { "apps/mobile": {
"name": "@musicshare/mobile", "name": "@musicshare/mobile",
"version": "1.5.0", "version": "1.4.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@expo-google-fonts/poppins": "^0.4.1", "@expo-google-fonts/poppins": "^0.4.1",